@impeccable/reviews 0.1.5 → 0.1.7

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.
Files changed (41) hide show
  1. package/README.md +21 -20
  2. package/dist/agent.js +9 -4
  3. package/dist/cloudflare-access.js +77 -47
  4. package/dist/cloudflare.js +325 -23
  5. package/dist/fence.js +939 -143
  6. package/dist/focus.js +5 -4
  7. package/dist/host-previews.js +107 -0
  8. package/dist/host-questions.js +90 -20
  9. package/dist/hosted.js +72 -21
  10. package/dist/http.js +2 -0
  11. package/dist/log.js +7 -1
  12. package/dist/login.js +45 -13
  13. package/dist/main.js +32 -17
  14. package/dist/netlify-password.js +8 -6
  15. package/dist/netlify.js +43 -14
  16. package/dist/pi-paths.js +69 -0
  17. package/dist/playbook/cases/cloudflare-previews.md +12 -4
  18. package/dist/playbook/cases/first-preview-failure.md +1 -1
  19. package/dist/playbook/cases/live-app-address.md +15 -0
  20. package/dist/playbook/cases/monorepo.md +1 -1
  21. package/dist/playbook/cases/netlify-previews.md +3 -1
  22. package/dist/playbook/cases/netlify-setup.md +8 -7
  23. package/dist/playbook/cases/preview-protection.md +2 -1
  24. package/dist/playbook/cases/pull-requests.md +4 -4
  25. package/dist/playbook/cases/static-site.md +2 -2
  26. package/dist/playbook/cases/vercel-previews.md +2 -0
  27. package/dist/playbook/cases/vercel-setup.md +9 -6
  28. package/dist/playbook.js +2 -2
  29. package/dist/preflight.js +36 -7
  30. package/dist/report.js +1 -0
  31. package/dist/sandbox.js +364 -50
  32. package/dist/scan.js +134 -0
  33. package/dist/sync.js +44 -7
  34. package/dist/tools.js +397 -88
  35. package/dist/ui/bridge.js +18 -4
  36. package/dist/ui/look.js +1 -1
  37. package/dist/ui/screens.js +5 -3
  38. package/dist/ui/sensitive.js +10 -0
  39. package/dist/vercel-bypass.js +12 -7
  40. package/dist/vercel.js +110 -17
  41. package/package.json +1 -1
package/dist/tools.js CHANGED
@@ -7,25 +7,27 @@ 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, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment } from './fence.js';
10
+ import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
11
11
  import { requestJson } from './http.js';
12
12
  import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
13
- import { HOST_QUESTIONS, HOST_SCREENS } from './host-questions.js';
14
- import { netlifySiteOf, previewHostName, provePreview, readHostedPreviews } from './hosted.js';
15
- import { cfRunner, pagesProject, readCloudflarePreviews } from './cloudflare.js';
13
+ import { HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, sentTo } from './host-questions.js';
14
+ import { enableNetlifyPreviews } from './host-previews.js';
15
+ import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews } from './hosted.js';
16
+ import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
16
17
  import { passCloudflareAccess } from './cloudflare-access.js';
17
- import { commandRunner, connectNetlifySite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
18
+ import { commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
18
19
  import { basicCredentials, PASSWORD_HEADER, passNetlifyLock } from './netlify-password.js';
19
- import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, vercelProjectPage, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
20
+ import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelPreviewsFact, vercelProjectPage, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
20
21
  import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
21
22
  import { fitsTask, stagesOf } from './stages.js';
22
23
  import { redact } from './log.js';
23
24
  import { gitEnvironment } from './sandbox.js';
24
25
  import { openBrowser } from './login.js';
25
- import { TASK_STATUSES } from './sync.js';
26
+ import { STATUS_MAX, statusVoiceError, TASK_STATUSES } from './sync.js';
26
27
  import { askPerson, reportStatus, reportTasks, reportWait } from './ui/bridge.js';
27
28
  import { SEE_THE_REVIEW } from './ui/look.js';
28
- import { noteSensitiveAnswer } from './ui/sensitive.js';
29
+ import { noteSensitiveAnswer, plainText } from './ui/sensitive.js';
30
+ export { STATUS_MAX, statusVoiceError } from './sync.js';
29
31
  /** The first way `value` breaks `schema`, or null when it fits. */
30
32
  export function schemaError(schema, value, at = 'input') {
31
33
  const types = schema.type === undefined ? [] : [schema.type].flat();
@@ -106,15 +108,6 @@ export function resolveWrite(instructions, setupApi, repo, path) {
106
108
  return { error: `No PUT or POST in the instructions' "endpoints" matches ${pathname}. Pick one from there.` };
107
109
  }
108
110
  const NO_INPUT = { type: 'object', properties: {}, additionalProperties: false };
109
- /** The most a status line holds; longer text belongs in the report. */
110
- export const STATUS_MAX = 70;
111
- const STATUS_RULE = `Status lines are in the product voice: a present-tense verb phrase of at most ${STATUS_MAX} characters that says what is happening, such as "Running the build" or "Opening the setup pull request". No "I'm", "I am", "Let me", "I'll" or "trying to"; detail goes in the report. Nothing was saved: call impeccable_progress again with the status rephrased.`;
112
- /** Why a status line breaks the voice, with the rule to rephrase it by; null when it keeps it. The line is never rewritten here. */
113
- export function statusVoiceError(line) {
114
- const text = line.trim();
115
- const why = /^(i['’]m|i am|let me|i['’]ll)\b/i.test(text) || /\btrying to\b/i.test(text) ? 'it narrates like an assistant' : text.length > STATUS_MAX ? `it is ${text.length} characters long` : null;
116
- return why ? `Refused: "${text}" does not fit the status line, because ${why}. ${STATUS_RULE}` : null;
117
- }
118
111
  /** Why the status line and the task list say different things, one task in progress at a time and the status its detail; null when they agree. */
119
112
  function stageError(status, tasks) {
120
113
  const running = tasks.filter((t) => t.status === 'in_progress');
@@ -213,7 +206,7 @@ const PROVE_PREVIEW = {
213
206
  additionalProperties: false,
214
207
  required: ['url', 'expect'],
215
208
  properties: {
216
- url: { type: 'string', minLength: 1, description: 'An address impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed, or a page on it.' },
209
+ url: { type: 'string', minLength: 1, description: 'An address impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed, or the live app\'s address one of them answered as production or a file of this checkout names, or a page on it.' },
217
210
  expect: { type: 'string', minLength: 1, description: 'Words the page shows that a file of this checkout holds, such as the app\'s name in its title.' },
218
211
  },
219
212
  };
@@ -254,6 +247,20 @@ const namesCredential = (q) => {
254
247
  };
255
248
  /** Under the field of every text question typed in the open, where the model reads the answer. */
256
249
  const NEVER_A_CREDENTIAL = 'Never enter a password, a token or a key here.';
250
+ const ENABLE_PREVIEWS = {
251
+ type: 'object',
252
+ additionalProperties: false,
253
+ required: ['host'],
254
+ properties: { host: { type: 'string', enum: ['netlify', 'cloudflare'], description: "The host whose pullRequestPreviews said enabled false in this run." } },
255
+ };
256
+ const CLOUDFLARE_CONNECT = {
257
+ type: 'object',
258
+ additionalProperties: false,
259
+ properties: {
260
+ 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." },
261
+ 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.' },
262
+ },
263
+ };
257
264
  const NETLIFY_CONNECT = {
258
265
  type: 'object',
259
266
  additionalProperties: false,
@@ -279,17 +286,28 @@ function askError(q) {
279
286
  return 'input.options[].selected is for multi only';
280
287
  return new Set(values).size === values.length ? null : 'input.options has a value twice';
281
288
  }
282
- /** Why `url` is not pull request `number` of owner/repo on GitHub; null when it is. */
283
- function pullRequestError(url, number, owner, repo) {
284
- const m = URL.canParse(url) ? /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/.exec(new URL(url).pathname) : null;
285
- const ok = m && new URL(url).origin === 'https://github.com' && m[1].toLowerCase() === owner.toLowerCase() && m[2].toLowerCase() === repo.toLowerCase() && Number(m[3]) === number;
286
- return ok ? null : `${url} is not pull request #${number} of ${owner}/${repo} on GitHub`;
289
+ /**
290
+ * Pull request `number` of owner/repo on GitHub, when `url` is it: its address rebuilt from those validated parts, so no
291
+ * query, fragment or other character the model wrote reaches the browser or the clipboard. Null when `url` is not it.
292
+ */
293
+ function canonicalPullRequest(url, number, owner, repo) {
294
+ const parsed = URL.canParse(url) ? new URL(url) : null;
295
+ const m = parsed && /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/.exec(parsed.pathname);
296
+ const ok = m && parsed.origin === 'https://github.com' && !parsed.username && !parsed.password
297
+ && /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(owner) && /^[A-Za-z0-9._-]{1,100}$/.test(repo)
298
+ && m[1].toLowerCase() === owner.toLowerCase() && m[2].toLowerCase() === repo.toLowerCase() && Number.isSafeInteger(number) && number > 0 && Number(m[3]) === number;
299
+ return ok ? `https://github.com/${owner}/${repo}/pull/${number}` : null;
300
+ }
301
+ /** `pr` with its address rebuilt as pull request `number` of owner/repo on GitHub, or undefined when it is not that. */
302
+ export function ownPullRequest(pr, owner, repo) {
303
+ const url = pr && canonicalPullRequest(pr.url, pr.number, owner, repo);
304
+ return url ? { number: pr.number, url } : undefined;
287
305
  }
288
306
  /** This run's open pull request from `branch`, its head in this repository rather than a fork, with that head's name; or why it is not. */
289
307
  async function runPullRequest(ctx, pr, branch, signal) {
290
- const error = pullRequestError(pr.url, pr.number, ctx.owner, ctx.repo);
291
- if (error)
292
- return { problem: error };
308
+ const url = canonicalPullRequest(pr.url, pr.number, ctx.owner, ctx.repo);
309
+ if (!url)
310
+ return { problem: `${pr.url} is not pull request #${pr.number} of ${ctx.owner}/${ctx.repo} on GitHub` };
293
311
  const found = await ctx.viewPullRequest(pr.number);
294
312
  if ('error' in found)
295
313
  return { problem: `gh pr view ${pr.number} did not find it (${found.error})` };
@@ -303,7 +321,7 @@ async function runPullRequest(ctx, pr, branch, signal) {
303
321
  if (!await (ctx.setupPullHeadReader ?? readSetupPullHead)(ctx.owner, ctx.repo, pr.number, found.headRefName, { signal })) {
304
322
  return { problem: `the branch of #${pr.number} could not be verified in this repository; a fork's branch cannot be recorded` };
305
323
  }
306
- return { branch: found.headRefName };
324
+ return { url, branch: found.headRefName, ...(found.baseRefName ? { base: found.baseRefName } : {}) };
307
325
  }
308
326
  const PREVIEW_COMMAND = { type: 'object', additionalProperties: false, required: ['command'], properties: {
309
327
  command: { type: 'string', minLength: 1, description: 'A command of the repository\'s own scripts or tools, without cd.' },
@@ -518,10 +536,13 @@ export async function verifyLocalPreview(input, root, signal, sandbox) {
518
536
  scratch = await realpath(await mkdtemp(join('/tmp', 'impeccable-preview-')));
519
537
  const snapshot = join(scratch, 'checkout'), home = join(scratch, 'home'), temp = join(scratch, 'tmp');
520
538
  await Promise.all([snapshot, home, temp].map((p) => mkdir(p, { mode: 0o700 })));
521
- // The app's own commands run in a sandbox of their own, writing only in this folder; the check's git and Docker run outside it.
539
+ // The app's own commands run in a sandbox of their own, writing only in this folder; the check's git and Docker run outside it. Without one they would run the repository's code with all the person's access, so the check refuses; only the tests, which hand over no sandbox, run them plain.
522
540
  const inSandbox = sandbox?.preview(scratch);
541
+ if (sandbox && !inSandbox)
542
+ return fail('environment', 'Local preview verification needs a sandbox of its own, and this machine has none for it (Linux, or --no-sandbox): save the build the repository names, leave its proof to the preview job on the pull request, and say so under what is not verified.');
523
543
  const confined = (command, processEnv) => inSandbox?.(command, processEnv) ?? { file: '/bin/sh', args: ['-c', command], env: processEnv };
524
- env = { ...env, ...supplied, ...(serverInput ? { PORT: String(serverInput.port) } : {}), HOME: home, TMPDIR: temp, TMP: temp, TEMP: temp, XDG_CONFIG_HOME: join(home, '.config'), npm_config_cache: join(scratch, 'npm'), npm_config_userconfig: join(home, '.npmrc'), npm_config_globalconfig: join(scratch, 'global-npmrc'), PIP_CONFIG_FILE: '/dev/null', PIP_NO_INPUT: '1', PIP_DISABLE_PIP_VERSION_CHECK: '1', PIP_REQUIRE_VIRTUALENV: 'true', BUNDLE_PATH: join(scratch, 'bundle'), BUNDLE_APP_CONFIG: join(scratch, 'bundle-config') };
544
+ // Built afresh rather than from process.env, so it carries Windows' guard against a git.exe in the checkout itself (IMP-23), after what the model supplied.
545
+ env = { ...env, ...supplied, ...(serverInput ? { PORT: String(serverInput.port) } : {}), NoDefaultCurrentDirectoryInExePath: '1', HOME: home, TMPDIR: temp, TMP: temp, TEMP: temp, XDG_CONFIG_HOME: join(home, '.config'), npm_config_cache: join(scratch, 'npm'), npm_config_userconfig: join(home, '.npmrc'), npm_config_globalconfig: join(scratch, 'global-npmrc'), PIP_CONFIG_FILE: '/dev/null', PIP_NO_INPUT: '1', PIP_DISABLE_PIP_VERSION_CHECK: '1', PIP_REQUIRE_VIRTUALENV: 'true', BUNDLE_PATH: join(scratch, 'bundle'), BUNDLE_APP_CONFIG: join(scratch, 'bundle-config') };
525
546
  const listing = await run('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], root, true, 120_000, signal ?? null, gitEnvironment(env));
526
547
  if (!listing.ok)
527
548
  return fail(stage, 'Could not list checkout files.');
@@ -771,6 +792,27 @@ export async function driveCoversSetup(cwd, setupBranch, driveBranch) {
771
792
  return err.code === 1 ? false : null;
772
793
  }
773
794
  }
795
+ /**
796
+ * What the pushed `head` changes against `base`, as their remote-tracking refs in this checkout show GitHub: each file's
797
+ * `git diff --name-status` letter and its path as it is, never quoted; a rename as a removal and an addition. Null when either ref is missing or git fails.
798
+ */
799
+ export async function pushedChanges(cwd, base, head) {
800
+ try {
801
+ // The two branches as the remote has them, fetched so the diff can be read here: refs/remotes/origin/* sit in the checkout's git folder, where a command in the sandbox can move them.
802
+ const refs = [`refs/heads/${base}`, `refs/heads/${head}`];
803
+ const { stdout: heads } = await execFileAsync('git', ['ls-remote', '--heads', 'origin', ...refs], { cwd, timeout: 60_000, env: gitEnvironment() });
804
+ const sha = new Map(heads.split('\n').filter(Boolean).map((line) => line.split('\t').reverse()));
805
+ if (!refs.every((ref) => sha.has(ref)))
806
+ return null;
807
+ await execFileAsync('git', ['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', 'origin', ...refs], { cwd, timeout: 60_000, env: gitEnvironment() });
808
+ const { stdout } = await execFileAsync('git', ['diff', '-z', '--name-status', '--no-renames', `${sha.get(refs[0])}...${sha.get(refs[1])}`], { cwd, timeout: 10_000, env: gitEnvironment() });
809
+ const fields = stdout.split('\0');
810
+ return Array.from({ length: Math.floor(fields.length / 2) }, (_, i) => ({ status: fields[2 * i], path: fields[2 * i + 1] }));
811
+ }
812
+ catch {
813
+ return null;
814
+ }
815
+ }
774
816
  const LIMIT = 20;
775
817
  const WORKFLOW = '.github/workflows/impeccable-preview.yml';
776
818
  const STATUSES = ['queued', 'requested', 'waiting', 'pending', 'in_progress', 'completed'];
@@ -1437,7 +1479,7 @@ export function createTools(ctx) {
1437
1479
  * A host's build, waited for on the screen as the task in progress, naming `host`, with the time so far: `read` again
1438
1480
  * every so often until `built` says the evidence is final, or the wait runs out. The status line names the host and what it builds.
1439
1481
  */
1440
- async function awaitBuild(host, status, read, built, page, signal) {
1482
+ async function awaitBuild(host, status, read, built, page, signal, access) {
1441
1483
  const first = await read();
1442
1484
  if (built(first))
1443
1485
  return first;
@@ -1455,7 +1497,15 @@ export function createTools(ctx) {
1455
1497
  }
1456
1498
  return evidence;
1457
1499
  };
1458
- // No build in the time: the host's page where a build is turned on, and the wait again on the person's yes.
1500
+ // No build in the time: the host's page where a build is turned on, and the wait again on the person's yes. Where the host's settings build every
1501
+ // branch and still none came, its GitHub app cannot see the repository: the app's installation page instead, at once when the app is not installed at all.
1502
+ if (access) {
1503
+ return onHostPage(`${host.toLowerCase()}-app-access`, () => access.page, (e) => !built(e), access.now ? first : await waited(first), async () => waited(await read()), {
1504
+ skipped: `The person skipped giving ${host}'s GitHub app access to the repository.`,
1505
+ still: `${host}'s GitHub app still cannot see the repository: no build came.`,
1506
+ stopped: `The run stopped while ${host}'s build was waited for.`,
1507
+ }, signal);
1508
+ }
1459
1509
  return onHostPage(`${host.toLowerCase()}-build`, page, (e) => !built(e), await waited(first), async () => waited(await read()), {
1460
1510
  skipped: `The person stopped waiting for ${host}'s build.`,
1461
1511
  still: `${host} built no preview in the time.`,
@@ -1495,6 +1545,14 @@ export function createTools(ctx) {
1495
1545
  let accessStored = false;
1496
1546
  /** What impeccable_cloudflare_access answered, less the token: a skip is asked once, so that answer stands. */
1497
1547
  let accessOutcome;
1548
+ /** 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. */
1549
+ const previewFacts = new Map();
1550
+ /** What impeccable_enable_previews answered for a host: asked once, so that answer stands. */
1551
+ const enableOutcomes = new Map();
1552
+ /** 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. */
1553
+ let cloudflareEvidence;
1554
+ /** What impeccable_cloudflare_connect answered: asked once, so that answer stands. */
1555
+ let connectOutcome;
1498
1556
  // Keep ownership of files for this one run; never recreate it per call.
1499
1557
  const workflowSynchronizer = ctx.workflowSynchronizer ?? createWorkflowSynchronizer();
1500
1558
  const workflowRefusal = (reason) => ({ ok: false, reason, actions: [] });
@@ -1554,7 +1612,7 @@ export function createTools(ctx) {
1554
1612
  ctx.sync.update({ phase: 'running', question: null });
1555
1613
  return answer;
1556
1614
  };
1557
- /** Each host's sign-in this run: confirmed on its screen, or found signed in already, so the screen shows once; skipped, so nothing more is asked for that host. */
1615
+ /** Each host's sign-in this run: confirmed on its screen, so the screen shows once; skipped, so nothing more is asked for that host. */
1558
1616
  const hostSignIns = new Map();
1559
1617
  /** The teams a host's sign-in offered, each by name and slug: the choices a `<host>-team` question shows, whatever the model lists, so the pick the fence takes is a slug. */
1560
1618
  const teamChoices = new Map();
@@ -1567,7 +1625,21 @@ export function createTools(ctx) {
1567
1625
  const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel' };
1568
1626
  /** The Vercel project and the Netlify site this run set up, by name, for the pages of theirs a screen opens. */
1569
1627
  let vercelProject;
1628
+ /** The repository a Vercel project the link file names must be connected to before the run changes it or sends its secret anywhere. */
1629
+ const repository = `${ctx.owner}/${ctx.repo}`;
1570
1630
  let netlifySite;
1631
+ /** The live app's addresses, by origin: those the host tools answered as `production`, each with its host and, on Netlify, its site, so a custom domain's lock is known and the site's key goes with it; and those a proof opened with this app, the only ones impeccable_write saves as the before. */
1632
+ const productionListed = new Map();
1633
+ const provenLive = new Set();
1634
+ /** The linked Vercel project's production address, read from Vercel, and the project it was read from: the one live address the project's own bypass secret is sent to, since a secret is one project's, and the link file can name another project of this repository between the two reads. */
1635
+ let vercelProduction;
1636
+ /** The project whose bypass secret is in hand, by the link it was read or made on. */
1637
+ let bypassProject;
1638
+ /** A host tool answered the live app's address: the proof may open it as the before, with the host's key where the lock is the same site's or project's. */
1639
+ const noteProduction = (production, host, site) => {
1640
+ if (production && URL.canParse(production))
1641
+ productionListed.set(new URL(production).origin, { host, ...(site ? { site } : {}) });
1642
+ };
1571
1643
  const hostCli = (host, pi, signal) => {
1572
1644
  const runner = (browser = false) => (ctx.hostRunner ?? commandRunner)(host, pi.cwd, { signal, browser, timeoutMs: browser ? 300_000 : 30_000 });
1573
1645
  const cli = runner();
@@ -1592,16 +1664,16 @@ export function createTools(ctx) {
1592
1664
  };
1593
1665
  };
1594
1666
  /**
1595
- * A host's sign-in, on `impeccable_ask` with the id `<host>-setup`: a CLI signed in already opens the setup with no
1596
- * screen; else the sign-in screen once, and on its yes the CLI's login in the browser, checked with the CLI, a retry
1597
- * screen when it did not complete, and after two failed retries a skip. The fence opens on the sign-in found or
1598
- * completed (and on the yes for the CLI's install alone): never on anything the model writes.
1667
+ * A host's sign-in, on `impeccable_ask` with the id `<host>-setup`: the setup screen once, worded for a CLI signed in
1668
+ * already (the yes opens the setup with that sign-in, and no login runs) or for one signed out, and on its yes the
1669
+ * CLI's login in the browser, checked with the CLI, a retry screen when it did not complete, and after two failed
1670
+ * retries a skip. The fence opens on the yes, once the sign-in is found or completed (and on the yes for the CLI's
1671
+ * install alone): never on anything the model writes, and never on a sign-in alone.
1599
1672
  */
1600
1673
  async function hostSignIn(host, pi, signal) {
1601
1674
  const cli = hostCli(host, pi, signal);
1602
1675
  const signedIn = async () => {
1603
1676
  agreeToHostSetup(ctx.log, `${host}-setup`);
1604
- hostSignIns.set(host, 'confirmed');
1605
1677
  const { team, choices } = await cli.team();
1606
1678
  if (team)
1607
1679
  chooseHostTeam(ctx.log, `${host}-team`, team);
@@ -1611,16 +1683,16 @@ export function createTools(ctx) {
1611
1683
  if (hostSignIns.get(host) === 'skipped')
1612
1684
  return { skipped: true };
1613
1685
  let status = await cli.status();
1614
- if (status.cli === 'signed-in')
1615
- return signedIn();
1616
1686
  if (!hostSignIns.has(host)) {
1617
- const answer = await ask({ id: `${host}-setup`, ...HOST_QUESTIONS[`${host}-setup`] });
1687
+ const answer = await ask({ id: `${host}-setup`, ...HOST_QUESTIONS[`${host}-setup`], ...(status.cli === 'signed-in' ? HOST_SIGNED_IN[`${host}-setup`] : {}) });
1618
1688
  if (answer.skipped) {
1619
1689
  endSetup(host);
1620
1690
  return { skipped: true };
1621
1691
  }
1622
1692
  hostSignIns.set(host, 'confirmed');
1623
1693
  }
1694
+ if (status.cli === 'signed-in')
1695
+ return signedIn();
1624
1696
  // The CLI is installed through the fence's package-manager rule, which this yes opens; then this is asked again.
1625
1697
  if (status.cli === 'missing') {
1626
1698
  agreeToHostSetup(ctx.log, `${host}-setup`);
@@ -1687,44 +1759,71 @@ export function createTools(ctx) {
1687
1759
  const netlify = (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal });
1688
1760
  const gh = (ctx.hostRunner ?? commandRunner)('gh', pi.cwd, { signal });
1689
1761
  const clone = ctx.hostWait?.clone;
1690
- const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, settings, signal, ...(clone ? { wait: clone } : {}) });
1762
+ const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, linked: linkedByRun(ctx.log), settings, signal, ...(clone ? { wait: clone } : {}) });
1691
1763
  netlifySite = result.site?.name ?? netlifySite;
1764
+ if (result.pullRequestPreviews)
1765
+ previewFacts.set('netlify', result.pullRequestPreviews);
1692
1766
  // A build every 30 s until one reads the repository: the person is giving the app access meanwhile.
1693
1767
  return hostAccess('netlify', result, async () => (await waitForNetlifyAccess(result.site.id, { netlify, signal, wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 30_000 }, ...(clone ? { clone } : {}) })
1694
- ? { connected: true, site: result.site, how: 'github-app' } : result), signal);
1768
+ ? { connected: true, site: result.site, how: 'github-app', ...(result.pullRequestPreviews ? { pullRequestPreviews: result.pullRequestPreviews } : {}) } : result), signal);
1695
1769
  }
1696
1770
  /**
1697
- * The connection of the linked Vercel project with this repository, `vercel git connect`, with the app's access asked
1698
- * when Vercel's GitHub app cannot reach the repository; then, connected, the project locked behind Vercel's login at
1699
- * every address while it has no production deployment, before anything is pushed: Vercel makes a new project's first
1700
- * deployment Production whatever branch it builds, and serves it at the project's own address.
1771
+ * The connection of the linked Vercel project with this repository, `vercel git connect`, for a project this run's own
1772
+ * `vercel project add` made and `vercel link` linked (the link file alone, which the agent can write, proves nothing;
1773
+ * the link alone links an existing project of the folder's name, which may be one of the person's: that one is left
1774
+ * as it is), or one connected to this repository already, with the app's access asked when Vercel's GitHub app
1775
+ * cannot reach the repository; then, connected, the project locked behind Vercel's login at every address while it
1776
+ * has no production deployment, before anything is pushed: Vercel makes a new project's first deployment Production
1777
+ * whatever branch it builds, and serves it at the project's own address.
1701
1778
  */
1702
1779
  async function vercelConnect(pi, signal) {
1703
1780
  if (!hostSetupAgreed(ctx.log, 'vercel'))
1704
1781
  return { connected: false, reason: 'The Vercel sign-in comes first: impeccable_ask with the id vercel-setup.' };
1705
1782
  const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
1783
+ const link = await readLink(pi.cwd);
1784
+ if (!link)
1785
+ return { connected: false, reason: 'This folder is linked to no Vercel project (.vercel/project.json): run vercel link first.' };
1786
+ // The link file is the agent's to write: only one the run's own vercel link wrote, project and team alike, says which project the reads below are of. Any other holds the push, since what it would build cannot be told.
1787
+ const own = linkedByRun(ctx.log).get(link.projectId);
1788
+ if (!own || own.orgId !== link.orgId)
1789
+ return { connected: false, reason: "This folder's .vercel/project.json is not what this run's vercel link wrote (another project, or another team): nothing was connected, and the push stays held. vercel link --yes, on its own, links the project of the folder's name again; else the Vercel setup ends here, and the person removes or relinks the file." };
1790
+ // Read before the connection, which is what would change the project: whether it is this run's. The read after it is another, since the connection sets the production branch.
1791
+ const project = await readLinkedProject(vercel, link);
1792
+ // A project Vercel has no record of, or one the CLI cannot read, may still be one that builds a push: the hold stands, by design.
1793
+ if (project === 'missing')
1794
+ return { connected: false, reason: 'The project this folder is linked to no longer exists on Vercel, as far as the CLI can tell (.vercel/project.json is stale): nothing was connected, and the push stays held. vercel link --yes, on its own, links the project of the folder\'s name again; else the Vercel setup ends here, and the person removes or relinks the file.' };
1795
+ if (!project)
1796
+ return { connected: false, reason: 'The Vercel CLI could not read the linked project (vercel api), so whether this run created it is unknown: nothing was connected, and the push stays held.' };
1797
+ // A project connected to this repository already is this repository's: the connection changes nothing on it, and the lock below is what it gets as the run's own project would.
1798
+ if (project.repository !== repository.toLowerCase()) {
1799
+ const foreign = !(project.name && vercelProjectsAdded(ctx.log).has(`${own.team}/${project.name.toLowerCase()}`)) ? `this run's vercel project add did not make it in ${own.team ? `the team ${own.team}` : 'the personal scope'}, so vercel link linked a project of that name that existed already` : null;
1800
+ if (foreign) {
1801
+ // Nothing builds a push to a repository the project is not connected to, so the push goes; a deploy from here still would build it, and the shell's git connect never runs.
1802
+ settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'connection' });
1803
+ return { connected: false, reason: `The linked project${project.name ? ` ${project.name}` : ''} is not this run's: ${foreign}. It stays as it is, and nothing was connected. The Vercel setup ends here.` };
1804
+ }
1805
+ }
1706
1806
  // Tried again every 20 s until the app reaches the repository: the person is giving it access meanwhile.
1707
1807
  const result = await hostAccess('vercel', await connectVercelProject(vercel), () => waitForVercelAccess(vercel, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 20_000 }, signal }), signal);
1708
1808
  // 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.
1709
- const link = await readLink(pi.cwd);
1710
1809
  if (!result.connected) {
1711
1810
  if (result.access !== undefined)
1712
- settleVercelFirstDeployment(ctx.log, { projectId: link?.projectId ?? null, how: 'connection' });
1811
+ settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'connection' });
1713
1812
  return result;
1714
1813
  }
1715
- let first = link ? await prepareFirstDeployment(vercel, link) : { production: 'unknown', locked: false, reason: 'This folder is linked to no Vercel project (.vercel/project.json): run vercel link first.' };
1814
+ let first = await prepareFirstDeployment(vercel, link, repository);
1716
1815
  vercelProject = first.project ?? vercelProject;
1717
1816
  // Vercel refused the lock: the person turns it on in the project's settings, in this run, and the read sees it.
1718
- if (link && first.production === 'none' && !first.locked) {
1817
+ if (first.production === 'none' && !first.locked) {
1719
1818
  const page = vercelProjectPage('settings/deployment-protection', chosenHostTeam(ctx.log, 'vercel'), vercelProject);
1720
- first = await onHostPage('vercel-lock', () => page, (f) => f.production !== 'exists' && !f.locked, first, () => waitForVercelLock(vercel, link, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 10_000 }, signal }), {
1819
+ 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 }), {
1721
1820
  waiting: "Waiting for Vercel's login on the project",
1722
1821
  skipped: 'The person skipped locking the Vercel project.',
1723
1822
  still: "Vercel's login is still not in front of the project's addresses.",
1724
1823
  stopped: 'The run stopped while the lock was waited for.',
1725
1824
  }, signal);
1726
1825
  }
1727
- if (link && (first.production === 'exists' || first.locked === true))
1826
+ if (first.production === 'exists' || first.locked === true)
1728
1827
  settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'project' });
1729
1828
  return { ...result, ...first };
1730
1829
  }
@@ -1892,6 +1991,10 @@ export function createTools(ctx) {
1892
1991
  if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !== provenPattern)) {
1893
1992
  return 'Refused: the only address pattern saved is the one impeccable_prove_preview answered for a Netlify or Cloudflare Pages preview. Nothing was saved.';
1894
1993
  }
1994
+ // The before is the live app's address, and only one a proof opened with this app: an address no key opens is not set, and one the person saved is never cleared from here.
1995
+ if (target.url.endsWith('/preview') && saved && 'beforeUrl' in saved && (typeof saved.beforeUrl !== 'string' || !URL.canParse(saved.beforeUrl) || !provenLive.has(new URL(saved.beforeUrl).origin))) {
1996
+ return "Refused: beforeUrl is the live app's address that impeccable_prove_preview opened with this app in this run (the production address a host tool answered, or the one the README names), or a page on it; an address no key opens is not set, and a saved one is never cleared from here. Nothing was saved.";
1997
+ }
1895
1998
  const testAccounts = target.url.endsWith('/test-accounts');
1896
1999
  let body = p.body;
1897
2000
  if (testAccounts) {
@@ -1998,7 +2101,7 @@ export function createTools(ctx) {
1998
2101
  reportTasks(tasks);
1999
2102
  return 'Saved.';
2000
2103
  }),
2001
- 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 setup pull request, once recorded, cannot change.', TEST_PR, async (p, _pi, signal) => {
2104
+ 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) => {
2002
2105
  const drive = { number: p.number, url: p.url.trim() };
2003
2106
  const given = p.setup_pr;
2004
2107
  const setup = given && { number: given.number, url: given.url.trim() };
@@ -2007,19 +2110,28 @@ export function createTools(ctx) {
2007
2110
  const found = await runPullRequest(ctx, drive, 'impeccable/test-drive', signal);
2008
2111
  if ('problem' in found)
2009
2112
  return `Refused: ${found.problem}.`;
2010
- let setupBranch = recordedBranches?.setup;
2113
+ drive.url = found.url;
2114
+ // 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).
2115
+ const changes = !recordedBranches && found.base ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, found.base, found.branch) : null;
2116
+ const files = (changes ?? []).filter(({ path }) => !path.split('/').some((part) => part.startsWith('.')));
2117
+ if (files.length && files.every(({ status }) => status === 'A')) {
2118
+ 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.`;
2119
+ }
2120
+ let setupBranch = recordedBranches?.setup, setupBase = recordedBranches?.setupBase;
2011
2121
  if (setup) {
2012
2122
  // The setup pull request carries the preview attempts, so its binding cannot change.
2013
2123
  const head = await runPullRequest(ctx, setup, 'impeccable/setup', signal);
2014
2124
  if ('problem' in head)
2015
2125
  return `Refused: ${head.problem}.`;
2126
+ setup.url = head.url;
2016
2127
  const bound = bindPreviewPullRequest(retry, { ...setup, branch: head.branch });
2017
2128
  if (bound.verdict !== 'allow')
2018
2129
  return `Refused: ${bound.reason}.`;
2019
2130
  setupBranch = head.branch;
2131
+ setupBase = head.base;
2020
2132
  }
2021
2133
  ctx.sync.update({ test_pr: drive, ...(setup ? { setup_pr: setup } : {}) });
2022
- recordedBranches = { drive: found.branch, setup: setupBranch };
2134
+ recordedBranches = { drive: found.branch, setup: setupBranch, setupBase };
2023
2135
  const res = await ctx.sync.flush();
2024
2136
  const what = `the test drive #${drive.number}${setup ? ` and the setup pull request #${setup.number}` : ''}`;
2025
2137
  return res?.ok
@@ -2031,11 +2143,22 @@ export function createTools(ctx) {
2031
2143
  const long = left.find((line) => line.length > STATUS_MAX);
2032
2144
  if (long)
2033
2145
  return `Refused: "${long}" is longer than ${STATUS_MAX} characters. Each line of "left" is one short line the person can act on; the detail belongs in the report. Nothing was saved: call impeccable_report again.`;
2146
+ const control = left.find((line) => plainText(line) !== line);
2147
+ if (control)
2148
+ return `Refused: "${control}" holds a control character or an escape sequence. Each line of "left" is plain text. Nothing was saved: call impeccable_report again.`;
2034
2149
  // The web page keeps the task list as the report leaves it, so no task may still read as waiting or running (#83).
2035
2150
  const open = ctx.sync.payload.tasks.filter((t) => !t.id.startsWith('preview-attempt-') && (t.status === 'pending' || t.status === 'in_progress'));
2036
2151
  if (open.length)
2037
2152
  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.`;
2038
2153
  let markdown = p.markdown;
2154
+ // 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.
2155
+ if (recordedBranches?.setup) {
2156
+ const changes = recordedBranches.setupBase ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, recordedBranches.setupBase, recordedBranches.setup) : null;
2157
+ const verbs = { A: 'added', M: 'changed', D: 'removed' };
2158
+ // A file name is git's to give: one with Markdown in it, or a line break, stays one plain entry.
2159
+ const shown = (path) => path.replace(/[\\`*_[\]<>|~]/g, '\\$&').replace(/[\x00-\x1f\x7f]/g, (c) => JSON.stringify(c).slice(1, -1));
2160
+ 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.'}`;
2161
+ }
2039
2162
  if (previews.active && retry.pr) {
2040
2163
  await observe(signal);
2041
2164
  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(', ')}.` : ''}`);
@@ -2064,7 +2187,7 @@ export function createTools(ctx) {
2064
2187
  const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
2065
2188
  return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
2066
2189
  }),
2067
- tool('impeccable_hosted_previews', 'Hosted previews', 'Read the deployments GitHub has on record for this repository: the 20 newest, each with its latest status, its address, its environment, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. With branch (a branch of this run, pushed, before its pull request exists), only its head\'s, waiting up to five minutes for Vercel\'s deployment of it to finish building, under a status line with its timer, and again on the person\'s yes when the time ran out. A Production deployment of that branch (a project\'s first is, whatever branch it builds) is listed as its preview only while the project this folder is linked to is locked at every address (impeccable_vercel_connect locks it) and made it, read from Vercel at that moment, since a production deployment is otherwise served at the project\'s own address without a login; then no proof opens it. It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, pi, signal) => {
2190
+ tool('impeccable_hosted_previews', 'Hosted previews', 'Read the deployments GitHub has on record for this repository: the 20 newest, each with its latest status, its address, its environment, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. Without branch, where GitHub records a production deployment Vercel made and this folder is linked to the project (.vercel/project.json) the Vercel CLI reads as this repository\'s, production is the live app\'s address, the project\'s own domain, for the before. With branch (a branch of this run, pushed, before its pull request exists), only its head\'s, waiting up to five minutes for Vercel\'s deployment of it to finish building, under a status line with its timer, and again on the person\'s yes when the time ran out. A Production deployment of that branch (a project\'s first is, whatever branch it builds) is listed as its preview only while the project this folder is linked to is locked at every address (impeccable_vercel_connect locks it) and made it, read from Vercel at that moment, since a production deployment is otherwise served at the project\'s own address without a login; then no proof opens it. It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, pi, signal) => {
2068
2191
  const branch = p.branch;
2069
2192
  const read = () => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, branch });
2070
2193
  // With `branch` the evidence holds only the deployments of its head, and Vercel's is its build whatever environment it is called: the first deployment of a
@@ -2087,7 +2210,7 @@ export function createTools(ctx) {
2087
2210
  if (branch !== undefined && !isPreview(d)) {
2088
2211
  locked ??= await (async () => {
2089
2212
  const link = await readLink(pi.cwd);
2090
- return link ? lockedDeploymentHosts((ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal }), link) : new Set();
2213
+ return link ? lockedDeploymentHosts((ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal }), link, repository) : new Set();
2091
2214
  })();
2092
2215
  if (!locked.has(url.host)) {
2093
2216
  publicProduction.add(url.origin);
@@ -2098,9 +2221,36 @@ export function createTools(ctx) {
2098
2221
  if (d.vercel)
2099
2222
  vercelMade.add(url.origin);
2100
2223
  }
2101
- return JSON.stringify(evidence);
2224
+ // What vercel.json and the linked project say of the test drive's branch rides with the plain evidence, never with a wait: reported, never changed.
2225
+ // The fact and the live app read the same linked project: one `vercel api` call answers both.
2226
+ const reads = new Map();
2227
+ const vercel = (args) => {
2228
+ const key = JSON.stringify(args);
2229
+ if (!reads.has(key))
2230
+ reads.set(key, (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal })(args));
2231
+ return reads.get(key);
2232
+ };
2233
+ if (branch === undefined) {
2234
+ const fact = await (ctx.vercelPreviewsReader ?? vercelPreviewsFact)(pi.cwd, `${ctx.owner}/${ctx.repo}`, () => vercel);
2235
+ if (fact) {
2236
+ previewFacts.set('vercel', fact);
2237
+ evidence.pullRequestPreviews = fact;
2238
+ }
2239
+ }
2240
+ // The live app, where GitHub's record shows a production deployment Vercel made: its own address there is behind Vercel's login under Standard Protection, so the
2241
+ // linked project is read for its alias, the project's own domain, and only as this repository's project.
2242
+ let production;
2243
+ if (branch === undefined && evidence.deployments.some((d) => d.vercel && d.state === 'success' && !isPreview(d))) {
2244
+ const link = await readLink(pi.cwd);
2245
+ const project = link ? await readOwnProject(vercel, link, repository) : null;
2246
+ production = project && !('reason' in project) ? project.productionUrl : undefined;
2247
+ if (production && link)
2248
+ vercelProduction = { origin: new URL(production).origin, projectId: link.projectId };
2249
+ noteProduction(production, 'vercel');
2250
+ }
2251
+ return JSON.stringify({ ...evidence, ...(production ? { production } : {}) });
2102
2252
  }),
2103
- tool('impeccable_netlify_previews', 'Netlify previews', "Read whether this repository's pull requests get Netlify deploy previews: the 10 newest, each with its site, its address, its state and where that was read: GitHub's commit status or Netlify's bot comment on the pull request, or the Netlify CLI on this machine when it is signed in (read only: it never signs in). cli says whether that CLI is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent. With pull (a pull request of this run, a draft included: Netlify builds a deploy preview only for a pull request), only that pull request's, waiting up to five minutes for one to finish building, under a status line with its timer, and again on the person's yes when the time ran out. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
2253
+ tool('impeccable_netlify_previews', 'Netlify previews', "Read whether this repository's pull requests get Netlify deploy previews: the 10 newest, each with its site, its address, its state and where that was read: GitHub's commit status or Netlify's bot comment on the pull request, or the Netlify CLI on this machine when it is signed in (read only: it never signs in). production is the live site's own address, for the before. cli says whether that CLI is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent. With pull (a pull request of this run, a draft included: Netlify builds a deploy preview only for a pull request), only that pull request's, waiting up to five minutes for one to finish building, under a status line with its timer, and again on the person's yes when the time ran out. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
2104
2254
  const pull = p.pull;
2105
2255
  const read = () => (ctx.netlifyPreviewReader ?? readNetlifyPreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull });
2106
2256
  // A pull request's preview is still building until Netlify's deploy of it is built or has failed.
@@ -2116,9 +2266,12 @@ export function createTools(ctx) {
2116
2266
  for (const p of evidence.previews)
2117
2267
  if (p.state === 'success' || p.state === 'ready')
2118
2268
  netlifyListed.add(new URL(p.url).origin);
2269
+ if (evidence.pullRequestPreviews)
2270
+ previewFacts.set('netlify', evidence.pullRequestPreviews);
2271
+ noteProduction(evidence.production, 'netlify', liveSite(evidence));
2119
2272
  return JSON.stringify(evidence);
2120
2273
  }),
2121
- 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). 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. 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. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
2274
+ 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) => {
2122
2275
  const pull = p.pull;
2123
2276
  const branch = p.branch;
2124
2277
  if (pull !== undefined && branch !== undefined)
@@ -2128,7 +2281,14 @@ export function createTools(ctx) {
2128
2281
  // GitHub's record unread ends the wait; cf's alone does not, since the check run still says when the build ends.
2129
2282
  const final = (evidence) => Boolean(evidence.unread?.includes('GitHub')) || evidence.previews.some((preview) => preview.state !== 'building');
2130
2283
  const subject = branch !== undefined ? (testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`) : pull !== undefined ? await subjectOf(pull) : '';
2131
- const evidence = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal) : await read();
2284
+ // 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),
2285
+ // unless it covers them all, or Cloudflare built a preview of the repository already, which it could not without the app.
2286
+ const app = cloudflareEvidence?.githubApp;
2287
+ 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;
2288
+ 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();
2289
+ const evidence = subject ? { ...waited, ...(app ? { githubApp: app } : {}) } : waited;
2290
+ if (!subject)
2291
+ cloudflareEvidence = evidence;
2132
2292
  cloudflareListed.clear();
2133
2293
  for (const preview of evidence.previews) {
2134
2294
  if (preview.state !== 'success')
@@ -2137,24 +2297,40 @@ export function createTools(ctx) {
2137
2297
  if (preview.alias)
2138
2298
  cloudflareListed.add(new URL(preview.alias).origin);
2139
2299
  }
2300
+ if (evidence.pullRequestPreviews)
2301
+ previewFacts.set('cloudflare', evidence.pullRequestPreviews);
2302
+ noteProduction(evidence.production, 'cloudflare');
2140
2303
  return JSON.stringify(evidence);
2141
2304
  }),
2142
- tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed for a pull request or a pushed branch (a successful Preview deployment, a deploy preview that is ready, or a Pages build that succeeded), or a page on it, as a visitor signed in nowhere, and prove it: it answers, and the page shows words a file of this checkout holds. proven true lets impeccable_write save intendedMethod "preview-deploy"; for Netlify and Cloudflare Pages it answers the address pattern to save beside it. gate "vercel-protection", "netlify-protection" or "cloudflare-access" means the host\'s protection stands in front of it. Nothing of the page comes back.', PROVE_PREVIEW, async (p, pi, signal) => {
2305
+ tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed for a pull request or a pushed branch (a successful Preview deployment, a deploy preview that is ready, or a Pages build that succeeded), or a page on it, as a visitor signed in nowhere, and prove it: it answers, and the page shows words a file of this checkout holds. proven true lets impeccable_write save intendedMethod "preview-deploy"; for Netlify and Cloudflare Pages it answers the address pattern to save beside it. gate "vercel-protection", "netlify-protection" or "cloudflare-access" means the host\'s protection stands in front of it. Or open the live app\'s address, the production one of those tools answered or one a file of this checkout names (the README\'s live-site line), or a page on it, the same way: proven true lets impeccable_write save it as beforeUrl, and nothing else. A key the run got in place for the same project or site goes with it. Nothing of the page comes back.', PROVE_PREVIEW, async (p, pi, signal) => {
2143
2306
  const url = p.url;
2144
- if (URL.canParse(url) && publicProduction.has(new URL(url).origin))
2307
+ const origin = URL.canParse(url) ? new URL(url).origin : null;
2308
+ if (origin && publicProduction.has(origin))
2145
2309
  return "Refused: Vercel called this deployment Production, as it calls a project's first whatever branch it builds, and no project of this folder's that is locked at every address made it, so it is served at a project's own address without a login: it is no preview, and nothing was opened. No test drive, as the vercel-setup case says.";
2146
- if (!URL.canParse(url) || !(listedPreviews.has(new URL(url).origin) || netlifyListed.has(new URL(url).origin) || cloudflareListed.has(new URL(url).origin)))
2147
- return 'Refused: open the address of a preview that impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed for a pull request or a pushed branch, or a page on it.';
2148
- const site = netlifySiteOf(url);
2310
+ const listed = origin !== null && (listedPreviews.has(origin) || netlifyListed.has(origin) || cloudflareListed.has(origin));
2311
+ // The live app's address, for the before: one a host tool answered as production, or one a file the checkout tracks names, such as the README's live-site line, when it is the site's or project's own address on one of the hosts: a deploy preview or branch alias a file links stays a preview, listed or not, and an address elsewhere, which could be on the person's own network, is only a host tool's to name.
2312
+ const known = origin !== null ? productionListed.get(origin) : undefined;
2313
+ const live = origin !== null && !listed && (known !== undefined || (liveShaped(url) && (await checkoutNames(pi.cwd, origin))));
2314
+ if (!origin || (!listed && !live))
2315
+ return "Refused: open the address of a preview that impeccable_hosted_previews, impeccable_netlify_previews or impeccable_cloudflare_previews listed for a pull request or a pushed branch, or a page on it; or, for the before, the live app's address one of them answered as production, or the site's or project's own address on one of the hosts that a file of this checkout names.";
2316
+ // The Netlify site, by the address's shape or, for the site's custom domain, as its tool named it: its password goes to the site's own addresses.
2317
+ const site = netlifySiteOf(url) ?? known?.site;
2149
2318
  // A saved service token opens its Pages project's previews: Access admits it wherever the application's policy covers.
2150
- const project = pagesProject(new URL(url).origin);
2319
+ const project = pagesProject(origin);
2151
2320
  const token = accessToken && accessStored && project && accessProjects.has(project) ? accessToken : undefined;
2152
2321
  const proof = await provePreview(url, p.expect, pi.cwd, {
2153
- fetch: ctx.previewFetch, signal,
2154
- ...(bypassSecret && bypassOrigins.has(new URL(url).origin) ? { bypass: bypassSecret } : {}),
2322
+ fetch: ctx.previewFetch, signal, live: live ? (known?.host ?? true) : false,
2323
+ // The project's bypass secret opens the project's own addresses: the previews it proved, and the production address read from the same project.
2324
+ ...(bypassSecret && (bypassOrigins.has(origin) || (live && origin === vercelProduction?.origin && vercelProduction.projectId === bypassProject)) ? { bypass: bypassSecret } : {}),
2155
2325
  ...(sitePassword && site && passwordSites.has(site) ? { password: sitePassword } : {}),
2156
2326
  ...(token ? { access: token } : {}),
2157
2327
  });
2328
+ // A live address proven is the before, and nothing else: no method, no pattern and no lock to get past follow from it.
2329
+ if (live) {
2330
+ if (proof.proven)
2331
+ provenLive.add(origin);
2332
+ return JSON.stringify(proof);
2333
+ }
2158
2334
  hostedPreviewProven ||= proof.proven;
2159
2335
  if (proof.proven)
2160
2336
  provenHost = previewHostName(url) ?? provenHost;
@@ -2166,7 +2342,7 @@ export function createTools(ctx) {
2166
2342
  accessGated = { url, expect: p.expect };
2167
2343
  return JSON.stringify(proof);
2168
2344
  }),
2169
- tool('impeccable_netlify_connect', 'Connect the repository to Netlify', "Connect the Netlify site this folder is linked to with this repository, through the site's settings and Netlify's GitHub app, so that pull requests build deploy previews. The run does it; when Netlify's app cannot read the repository, this asks the person once for its access on GitHub and waits. connected true means Netlify read the repository; how \"already\" means the site was built from it before. A connection left undone answers why, and the GitHub page for the person in access.", NETLIFY_CONNECT, async (p, pi, signal) => {
2345
+ tool('impeccable_netlify_connect', 'Connect the repository to Netlify', "Connect the Netlify site this folder is linked to with this repository, through the site's settings and Netlify's GitHub app, so that pull requests build deploy previews: a site this run created with netlify sites:create, or one built from this repository already. A site built from another repository, or not created by this run, is left as it is, with the reason. The run does it; when Netlify's app cannot read the repository, this asks the person once for its access on GitHub and waits. connected true means Netlify read the repository; how \"already\" means the site was built from it before. A connection left undone answers why, and the GitHub page for the person in access.", NETLIFY_CONNECT, async (p, pi, signal) => {
2170
2346
  const command = p.command;
2171
2347
  const publish = p.publish;
2172
2348
  if (command !== undefined && (command.length > 200 || /[\r\n\0]/.test(command)))
@@ -2175,15 +2351,145 @@ export function createTools(ctx) {
2175
2351
  return 'Refused: publish is a folder relative to the checkout, such as "dist".';
2176
2352
  return JSON.stringify(await netlifyConnect(pi, { command, publish }, signal));
2177
2353
  }),
2178
- tool('impeccable_vercel_connect', 'Connect the repository to Vercel', "Connect the Vercel project this folder is linked to with this repository (vercel git connect), so that pull requests get preview deployments, and lock a project that has no production deployment yet: Vercel makes a new project's first deployment Production, whatever branch it builds, and serves it at the project's own address, so this puts Vercel's login in front of every address of the project (Deployment Protection on All Deployments) before anything is pushed; the fence holds every push until this settled the first deployment. The run does it; when Vercel's GitHub app cannot reach the repository, this asks the person once for its access on GitHub and waits, and when Vercel refuses the lock, it asks the person once to turn it on in the project's settings and waits. connected true means the repository is connected, or was already; production \"exists\" means the project deploys production already, so a push of another branch is a Preview; locked true means every address of the project is behind Vercel's login, so its first deployment is no public build; locked false, with reason, means push nothing. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => JSON.stringify(await vercelConnect(pi, signal))),
2179
- tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No secret ever comes back.', NO_INPUT, async (_p, pi, signal) => {
2354
+ 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) => {
2355
+ const host = p.host;
2356
+ const standing = enableOutcomes.get(host);
2357
+ if (standing)
2358
+ return JSON.stringify(standing);
2359
+ const fact = previewFacts.get(host);
2360
+ if (!fact || fact.enabled !== false)
2361
+ return `Refused: this is for a host whose pullRequestPreviews said enabled false in this run. Read ${host === 'netlify' ? 'impeccable_netlify_previews or impeccable_netlify_connect' : 'impeccable_cloudflare_previews'} first.`;
2362
+ if (host === 'cloudflare' && fact.cause === 'source')
2363
+ return `Refused: the Pages project ${fact.project} is connected to no repository, so there is no setting to turn on. impeccable_cloudflare_connect connects it.`;
2364
+ const name = host === 'netlify' ? 'Netlify' : 'Cloudflare Pages';
2365
+ const answer = (status, more) => {
2366
+ const told = { status, project: fact.project, ...more };
2367
+ enableOutcomes.set(host, told);
2368
+ return JSON.stringify(told);
2369
+ };
2370
+ const screen = HOST_SCREENS[`${host}-previews`];
2371
+ if ((await ask({ ...screen, prompt: `Turn on pull-request previews for ${fact.project}?` })).skipped)
2372
+ return answer('skipped', { reason: 'The person skipped turning pull-request previews on.' });
2373
+ said(`Turning on pull-request previews on ${name}`);
2374
+ const turnOn = async () => {
2375
+ if (host === 'netlify')
2376
+ return enableNetlifyPreviews((ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }), fact.project);
2377
+ const cf = cfRunner({}, ctx.hostRunner);
2378
+ return enableCloudflarePreviews(cf, fact.project, await cfStatus(cf, signal), signal);
2379
+ };
2380
+ // The run's change, read back; where the host kept the setting, the person changes it on the host's page and the run reads again, twice at most.
2381
+ for (let tries = 0;; tries++) {
2382
+ const outcome = await turnOn();
2383
+ if (outcome.fact.enabled === true) {
2384
+ previewFacts.set(host, outcome.fact);
2385
+ return answer('enabled', { by: outcome.changed ? 'run' : 'person' });
2386
+ }
2387
+ const why = outcome.reason ?? outcome.fact.reason;
2388
+ if (tries === 2 || signal?.aborted)
2389
+ return answer('refused', { reason: why, page: outcome.page });
2390
+ const retry = HOST_SCREENS[`${host}-previews-retry`];
2391
+ // The host's page opens once, before the first retry screen, which speaks of it; the browser may not have opened (BROWSER=none, no opener), so the address is on the screen as well.
2392
+ if (tries === 0)
2393
+ (ctx.openBrowser ?? openBrowser)(outcome.page);
2394
+ if ((await ask({ ...retry, hint: `${retry.hint} If it did not open, open this link: ${outcome.page}` })).skipped)
2395
+ return answer('skipped', { reason: why, page: outcome.page });
2396
+ }
2397
+ }),
2398
+ 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) => {
2399
+ if (connectOutcome)
2400
+ return JSON.stringify(connectOutcome);
2401
+ const evidence = cloudflareEvidence;
2402
+ if (!evidence)
2403
+ return 'Refused: read impeccable_cloudflare_previews first: it says whether a Pages project builds this repository.';
2404
+ const fact = evidence.pullRequestPreviews;
2405
+ if (fact && fact.enabled !== false)
2406
+ return `Refused: the Pages project ${fact.project} is connected to the repository already${fact.enabled === true ? ' and builds its pull requests' : ''}.`;
2407
+ if (fact && fact.cause !== 'source')
2408
+ return `Refused: the Pages project ${fact.project} is connected to the repository, and its settings turn pull-request previews off: impeccable_enable_previews turns them on.`;
2409
+ const create = !fact;
2410
+ if (create && !evidence.worker && !evidence.project)
2411
+ return 'Refused: nothing says the repository is set up for Cloudflare: no Pages project is built from it, the checkout names none, and no Worker is built from it. Set the preview up as the server-previews or static-site case says.';
2412
+ const project = fact?.project || evidence.project || pagesProjectName(ctx.repo);
2413
+ const answer = (status, more) => {
2414
+ connectOutcome = { status, project, created: create, ...more };
2415
+ return JSON.stringify(connectOutcome);
2416
+ };
2417
+ // The one screen: the connection of the project named, or the new project for the repository.
2418
+ const screen = HOST_SCREENS[create ? 'cloudflare-create' : 'cloudflare-connect'];
2419
+ if ((await ask({ ...screen, prompt: create ? `Create the Pages project ${project} for ${ctx.owner}/${ctx.repo}?` : `Connect ${ctx.owner}/${ctx.repo} to the Pages project ${project}?` })).skipped)
2420
+ return answer('skipped', { reason: `The person skipped ${create ? 'creating the Pages project' : 'connecting the repository to the Pages project'}.` });
2421
+ // cf signed in with pages:write, as it is or after Cloudflare's sign-in in the browser, keeping the scopes it held; a sign-in that did not complete offers a retry, twice.
2422
+ const cf = cfRunner({}, ctx.hostRunner);
2423
+ const login = cfRunner({ browser: true, timeoutMs: 300_000 }, ctx.hostRunner);
2424
+ const writes = (status) => status.cli === 'signed-in' && (!status.scopes || status.scopes.includes('pages:write'));
2425
+ let status = await cfStatus(cf, signal);
2426
+ if (!writes(status)) {
2427
+ if (status.cli === 'missing' || status.cli === 'unknown')
2428
+ return answer('refused', { reason: "Cloudflare's CLI did not run here." });
2429
+ if ((await ask(HOST_SCREENS['cloudflare-setup'])).skipped)
2430
+ return answer('skipped', { reason: 'The person skipped the Cloudflare sign-in.' });
2431
+ for (let attempt = 0;; attempt++) {
2432
+ said("Opening the browser for Cloudflare's login");
2433
+ const scopes = [...new Set([...(status.scopes ?? []).filter((scope) => scope !== 'offline_access'), 'account:read', 'user:read', 'pages:write'])];
2434
+ await login(['auth', 'login', '--force', '--no-device', '--scopes', ...scopes], { signal }).catch(() => undefined);
2435
+ signal?.throwIfAborted();
2436
+ status = await cfStatus(cf, signal);
2437
+ if (writes(status))
2438
+ break;
2439
+ if (status.cli === 'signed-in')
2440
+ return answer('refused', { reason: 'The Cloudflare sign-in gave the run no pages:write.' });
2441
+ if (attempt === 2)
2442
+ return answer('refused', { reason: 'The Cloudflare sign-in did not complete.' });
2443
+ if ((await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped)
2444
+ return answer('skipped', { reason: 'The person skipped the Cloudflare sign-in.' });
2445
+ }
2446
+ }
2447
+ said(create ? `Creating the Pages project ${project}` : `Connecting the repository to ${project}`);
2448
+ const gh = (ctx.hostRunner ?? commandRunner)('gh', pi.cwd, { signal });
2449
+ const outcome = await connectCloudflareProject(cf, gh, status, { owner: ctx.owner, repo: ctx.repo }, { project, ...(evidence.account ? { account: evidence.account } : {}), ...(create ? { create: { command: p.command, outputDir: p.outputDir } } : {}) }, signal);
2450
+ if (outcome.status !== 'connected')
2451
+ return answer('refused', { reason: outcome.reason });
2452
+ if (outcome.fact)
2453
+ previewFacts.set('cloudflare', outcome.fact);
2454
+ cloudflareEvidence = { ...evidence, project, ...(outcome.fact ? { pullRequestPreviews: outcome.fact } : {}) };
2455
+ return answer('connected', { created: outcome.created, ...(outcome.subdomain ? { subdomain: outcome.subdomain } : {}), ...(outcome.productionBranch ? { productionBranch: outcome.productionBranch } : {}) });
2456
+ }),
2457
+ tool('impeccable_vercel_connect', 'Connect the repository to Vercel', "Connect the Vercel project this folder is linked to with this repository (vercel git connect), so that pull requests get preview deployments, and lock a project that has no production deployment yet. Only a project this run made with vercel project add and linked with vercel link, or one connected to this repository already, is connected: a project that existed before the run (vercel link links an existing project of the folder's name) is left as it is, with the reason, and the Vercel setup ends there. Vercel makes a new project's first deployment Production, whatever branch it builds, and serves it at the project's own address, so this puts Vercel's login in front of every address of the project (Deployment Protection on All Deployments) before anything is pushed; the fence holds every push until this settled the first deployment. The run does it; when Vercel's GitHub app cannot reach the repository, this asks the person once for its access on GitHub and waits, and when Vercel refuses the lock, it asks the person once to turn it on in the project's settings and waits. connected true means the repository is connected, or was already; production \"exists\" means the project deploys production already, so a push of another branch is a Preview; locked true means every address of the project is behind Vercel's login, so its first deployment is no public build; locked false, with reason, means push nothing; with production \"exists\", productionUrl is the live app's address, for the before. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => {
2458
+ const answer = await vercelConnect(pi, signal);
2459
+ // A project that deploys production already names the live app's address, for the before.
2460
+ const link = answer.productionUrl ? await readLink(pi.cwd) : null;
2461
+ if (answer.productionUrl && link)
2462
+ vercelProduction = { origin: new URL(answer.productionUrl).origin, projectId: link.projectId };
2463
+ noteProduction(answer.productionUrl, 'vercel');
2464
+ return JSON.stringify(answer);
2465
+ }),
2466
+ tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it reads the project this folder is linked to (.vercel/project.json; once this run\'s own vercel link wrote that file, only what it wrote counts) and refuses unless that project is connected to this repository, shows Deployment Protection in its settings (a page that looks like Vercel\'s login is the deployment\'s own to serve, so with none set the sign-in is the app\'s own: prove a page a visitor opens without signing in) and made the preview\'s deployment (among its latest, or read by its address), since the secret goes only to a preview the linked project serves; then it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No secret ever comes back.', NO_INPUT, async (_p, pi, signal) => {
2180
2467
  if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
2181
2468
  return 'Refused: this is for a preview that Vercel made, listed by impeccable_hosted_previews, which impeccable_prove_preview answered with gate "vercel-protection". Prove one first.';
2182
2469
  if (bypassOutcome?.status === 'skipped')
2183
2470
  return JSON.stringify(bypassOutcome);
2184
2471
  const at = gated;
2185
- const origin = new URL(at.url).origin;
2186
- // A secret in hand already: an address of the same project opens with it, and no other project's does.
2472
+ const { origin, host } = new URL(at.url);
2473
+ // Whose preview this is: a deployment status anyone with push access records gives any address, and the page there says nothing, so the project the
2474
+ // link file names, read as the person and connected to this repository, has to have made the deployment before any secret of it goes there.
2475
+ const link = await readLink(pi.cwd);
2476
+ if (!link)
2477
+ return `Refused: nothing was sent to ${at.url}. This folder is linked to no Vercel project (.vercel/project.json), and a bypass secret goes only to a preview the linked project serves.`;
2478
+ // Once this run's own vercel link wrote the file, only that link counts, as impeccable_vercel_connect holds it: rewritten since, it would name another project of the person's. A project the person linked before the run has no such record, and the reads below vouch for it.
2479
+ const links = linkedByRun(ctx.log);
2480
+ const own = links.get(link.projectId);
2481
+ if ([...links.values()].some((l) => l.orgId !== undefined) && own?.orgId !== link.orgId)
2482
+ return `Refused: nothing was sent to ${at.url}. This folder's .vercel/project.json is not what this run's vercel link wrote (another project, or another team), and a bypass secret goes only to a preview the linked project serves.`;
2483
+ const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
2484
+ const project = await readOwnProject(vercel, link, repository);
2485
+ if ('reason' in project)
2486
+ return `Refused: nothing was sent to ${at.url}. ${project.reason} A bypass secret goes only to a preview the linked project serves.`;
2487
+ // The page at the address is the deployment's to word, whatever host it is on, so the lock is told from the project's settings, read as the person: with none, no login of Vercel's stood in front.
2488
+ if (!project.previewsProtected)
2489
+ return `Refused: nothing was sent to ${at.url}. Vercel's settings show no Deployment Protection on the project${project.name ? ` (${project.name})` : ''}, so what the address answered is the deployment's own page and the sign-in is the app's own: prove a page a visitor opens without signing in. A bypass secret goes only to a preview the linked project serves.`;
2490
+ if (!(await projectMadeDeployment(vercel, link, project, host)))
2491
+ return `Refused: nothing was sent to ${at.url}. The Vercel project this folder is linked to${project.name ? ` (${project.name})` : ''} did not make the deployment at ${host}, and a bypass secret goes only to a preview the linked project serves.`;
2492
+ // A secret in hand already: another address of the same project opens with it.
2187
2493
  if (bypassSaved && bypassSecret && (await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: bypassSecret })).proven) {
2188
2494
  bypassOrigins.add(origin);
2189
2495
  return JSON.stringify(bypassSaved);
@@ -2191,13 +2497,13 @@ export function createTools(ctx) {
2191
2497
  /** The store took this attempt's secret: what was in hand no longer matches it, unless the setting followed. */
2192
2498
  let stored = false;
2193
2499
  const { value, ...told } = await passVercelLogin({
2194
- run: (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal }),
2195
- link: () => readLink(pi.cwd),
2196
- consent: async () => !(await ask(HOST_SCREENS['vercel-bypass'])).skipped,
2500
+ run: vercel,
2501
+ link: async () => link,
2502
+ consent: async () => !(await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped,
2197
2503
  paste: async () => {
2198
- // The locked preview may be another project's than the one this run connected, so the person picks the project.
2199
- (ctx.openBrowser ?? openBrowser)(vercelProjectPage('settings/deployment-protection'));
2200
- const answer = await ask(HOST_SCREENS['vercel-bypass-key']);
2504
+ // The linked project's own settings page, in its team where the run knows it; else Vercel's picker.
2505
+ (ctx.openBrowser ?? openBrowser)(vercelProjectPage('settings/deployment-protection', chosenHostTeam(ctx.log, 'vercel'), project.name));
2506
+ const answer = await ask(sentTo(HOST_SCREENS['vercel-bypass-key'], at.url));
2201
2507
  return answer.skipped ? undefined : answer.value?.trim();
2202
2508
  },
2203
2509
  prove: (secret) => provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: secret }),
@@ -2212,6 +2518,7 @@ export function createTools(ctx) {
2212
2518
  if (value) {
2213
2519
  // 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.
2214
2520
  bypassSecret = value;
2521
+ bypassProject = link.projectId;
2215
2522
  bypassOrigins.clear();
2216
2523
  bypassOrigins.add(origin);
2217
2524
  hostedPreviewProven = true;
@@ -2221,12 +2528,13 @@ export function createTools(ctx) {
2221
2528
  else if (told.status === 'save-failed' && stored) {
2222
2529
  // The store holds the secret this attempt wrote without the setting that sends it: nothing in hand stands until a save goes through. A store the attempt never changed keeps the earlier one good.
2223
2530
  bypassSecret = undefined;
2531
+ bypassProject = undefined;
2224
2532
  bypassOrigins.clear();
2225
2533
  bypassSaved = undefined;
2226
2534
  }
2227
2535
  return JSON.stringify(bypassOutcome);
2228
2536
  }),
2229
- 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, the one way a request can carry Netlify\'s site password: as basic credentials in the Authorization header. It does the rest with the Netlify CLI signed in as the person: it reads the site\'s protection from Netlify; for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when the CLI cannot read the site or Netlify does not give the password back, one screen taking a pasted one; opens the preview with it; and saves it for reviews. For a site behind Netlify\'s team login it shows one screen asking to make the site public, signs the Netlify CLI in, in the browser, on that yes where it is signed out, makes it public as the person, and where Netlify refuses opens Netlify\'s Visitor access page with a screen to try again once the person changed it there. Answers {"status": …}: saved (the preview is proven, so the method may be saved; password says whether it was existing or typed, and site which Netlify site it is), opened (the site is public and the preview proven, so the method may be saved; madePublic says whether the run or the person made it public, and on any other answer that the run made it public all the same), login (Netlify kept its team login in front after the retries; reason says so), skipped (the person skipped the screen; reason says what came before), refused (nothing opened the preview; reason says why) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No password ever comes back.', NO_INPUT, async (_p, pi, signal) => {
2537
+ 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, the one way a request can carry Netlify\'s site password: as basic credentials in the Authorization header. It does the rest with the Netlify CLI signed in as the person: it reads the site\'s protection from Netlify, which is what makes the site the person\'s (a site the CLI cannot read as them gets no password: refused, with the reason); for a password Netlify gives back, it shows one screen asking to store it for Impeccable, or, when Netlify does not give the password back, one screen taking a pasted one; opens the preview with it; and saves it for reviews. For a site behind Netlify\'s team login it shows one screen asking to make the site public, signs the Netlify CLI in, in the browser, on that yes where it is signed out, makes it public as the person, and where Netlify refuses opens Netlify\'s Visitor access page with a screen to try again once the person changed it there. Answers {"status": …}: saved (the preview is proven, so the method may be saved; password says whether it was existing or typed, and site which Netlify site it is), opened (the site is public and the preview proven, so the method may be saved; madePublic says whether the run or the person made it public, and on any other answer that the run made it public all the same), login (Netlify kept its team login in front after the retries; reason says so), skipped (the person skipped the screen; reason says what came before), refused (nothing opened the preview; reason says why) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No password ever comes back.', NO_INPUT, async (_p, pi, signal) => {
2230
2538
  const site = gated?.gate === 'netlify-protection' ? netlifySiteOf(gated.url) : null;
2231
2539
  if (!gated || !site)
2232
2540
  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.';
@@ -2261,7 +2569,7 @@ export function createTools(ctx) {
2261
2569
  run: (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }),
2262
2570
  site,
2263
2571
  consent: async () => {
2264
- if (passwordConsented.has(site) || !(await ask(HOST_SCREENS['netlify-password'])).skipped)
2572
+ if (passwordConsented.has(site) || !(await ask(sentTo(HOST_SCREENS['netlify-password'], at.url))).skipped)
2265
2573
  return passwordConsented.add(site), true;
2266
2574
  return false;
2267
2575
  },
@@ -2281,7 +2589,7 @@ export function createTools(ctx) {
2281
2589
  return !(await ask({ ...screen, hint: `${screen.hint} If it did not open, open this link: ${page}` })).skipped;
2282
2590
  },
2283
2591
  paste: async () => {
2284
- const answer = await ask(HOST_SCREENS['netlify-password-key']);
2592
+ const answer = await ask(sentTo(HOST_SCREENS['netlify-password-key'], at.url));
2285
2593
  return answer.skipped ? undefined : answer.value?.trim();
2286
2594
  },
2287
2595
  prove,
@@ -2319,7 +2627,7 @@ export function createTools(ctx) {
2319
2627
  }
2320
2628
  return JSON.stringify(passwordOutcome);
2321
2629
  }),
2322
- tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token, Cloudflare\'s sign-in in the browser where cf, Cloudflare\'s CLI, is not signed in here with Access (with a retry), then the token named "Impeccable reviews" created through cf and allowed in the policy of the application in front of the preview; one screen taking a Cloudflare API token only where that sign-in cannot create it; on a skip, or where that cannot be done, one screen taking a pasted client id and secret. It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out, apiToken true when the person\'s Cloudflare API token created it), skipped (nothing was saved; reason says what stopped it), refused (the typed token did not open the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
2630
+ tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token, Cloudflare\'s sign-in in the browser where cf, Cloudflare\'s CLI, is not signed in here with Access (with a retry), then the token named "Impeccable reviews" created through cf and allowed in the policy of the application in front of the preview; one screen taking a Cloudflare API token only where that sign-in cannot create it; on a skip, or where that cannot be done, one screen taking a pasted client id and secret, only once an Access application in the person\'s accounts was read to cover the preview (none read: refused, with the reason, since the token goes to no other address). It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out, apiToken true when the person\'s Cloudflare API token created it), skipped (nothing was saved; reason says what stopped it), refused (no Access application of the person\'s covers the preview, or the typed token did not open it; reason says which) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
2323
2631
  if (!accessGated)
2324
2632
  return 'Refused: this is for a Cloudflare Pages preview impeccable_cloudflare_previews listed, which impeccable_prove_preview answered with gate "cloudflare-access". Prove one first.';
2325
2633
  if (accessOutcome?.status === 'skipped')
@@ -2341,7 +2649,9 @@ export function createTools(ctx) {
2341
2649
  return null;
2342
2650
  };
2343
2651
  let answer;
2344
- if (accessToken && (await prove(accessToken)).proven) {
2652
+ const project = pagesProject(new URL(at.url).origin);
2653
+ // The token in hand goes only to a project it opened already, as the proofs send it: any other address is read for an application of the person's first.
2654
+ if (accessToken && project && accessProjects.has(project) && (await prove(accessToken)).proven) {
2345
2655
  // The token in hand opens this address too: stored already, or stored now.
2346
2656
  const failed = accessStored ? null : await save(accessToken);
2347
2657
  answer = { status: failed ? 'save-failed' : 'saved', ...accessMade, ...(failed ? { reason: failed } : {}), value: accessToken };
@@ -2349,7 +2659,7 @@ export function createTools(ctx) {
2349
2659
  else {
2350
2660
  answer = await passCloudflareAccess({
2351
2661
  url: at.url,
2352
- consent: async () => !(await ask(HOST_SCREENS['cloudflare-access'])).skipped,
2662
+ consent: async () => !(await ask(sentTo(HOST_SCREENS['cloudflare-access'], at.url))).skipped,
2353
2663
  cf: cfRunner({}, ctx.hostRunner),
2354
2664
  login: cfRunner({ browser: true, timeoutMs: 300_000 }, ctx.hostRunner),
2355
2665
  retry: async () => !(await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped,
@@ -2359,7 +2669,7 @@ export function createTools(ctx) {
2359
2669
  return typed.skipped ? undefined : typed.value;
2360
2670
  },
2361
2671
  paste: async () => {
2362
- const typed = await ask(HOST_SCREENS['cloudflare-access-key']);
2672
+ const typed = await ask(sentTo(HOST_SCREENS['cloudflare-access-key'], at.url));
2363
2673
  return typed.skipped ? undefined : { id: typed.username, secret: typed.password };
2364
2674
  },
2365
2675
  prove,
@@ -2377,7 +2687,6 @@ export function createTools(ctx) {
2377
2687
  accessToken = value;
2378
2688
  accessStored = told.status === 'saved';
2379
2689
  accessMade = { ...(told.token ? { token: told.token } : {}), ...(told.application ? { application: told.application } : {}), ...(told.expires ? { expires: told.expires } : {}), ...(told.apiToken ? { apiToken: told.apiToken } : {}) };
2380
- const project = pagesProject(new URL(at.url).origin);
2381
2690
  if (project)
2382
2691
  accessProjects.add(project);
2383
2692
  }