@impeccable/reviews 0.1.34 → 0.1.36

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.
@@ -9,7 +9,9 @@ import { readSiteCall } from './netlify-password.js';
9
9
  * setting is ever changed: a project whose builds are stopped altogether
10
10
  * (Netlify `stop_builds`, Cloudflare `deployments_enabled` false) is reported
11
11
  * and left as it is. Vercel builds every branch once Git is connected, so its
12
- * fact is read from vercel.json and the project (vercel.ts) and never written.
12
+ * fact is read from vercel.json and the project (vercel.ts), and the one
13
+ * setting written is the project's deployment_status Events, without which
14
+ * Vercel records none of its deployments on GitHub.
13
15
  */
14
16
  /** The branch the test drive pushes, whose build is the preview the setting must allow. */
15
17
  export const TEST_DRIVE_BRANCH = 'impeccable/test-drive';
@@ -1,3 +1,4 @@
1
+ import { basename } from 'node:path';
1
2
  /** The ids the model may name, each worded here whatever else it writes for that id. */
2
3
  export const HOST_QUESTIONS = {
3
4
  'vercel-setup': {
@@ -45,6 +46,25 @@ export const HOST_SIGNED_IN = {
45
46
  confirmLabel: 'Set up',
46
47
  },
47
48
  };
49
+ /**
50
+ * The Vercel setup screen with its CLI signed in already as `user`: it names that account, the new project (`project`,
51
+ * where its name is known) and where it goes, the team the run takes (`team`), the one the person picks next (`pick`) or,
52
+ * with no team, the account itself, so the yes is to that account, project and team (REN-1250).
53
+ */
54
+ export function vercelSignedIn(user, where) {
55
+ const into = where.team ? `in the team ${where.team}` : where.pick ? 'in the team you pick next' : 'of that account';
56
+ const project = where.project ? `a new project, ${where.project}, ` : 'a new project ';
57
+ return { ...HOST_SIGNED_IN['vercel-setup'], hint: `Impeccable reviews each pull request on a preview. Vercel can host those previews: the run sets them up with the Vercel CLI on this machine, signed in as ${user}, in ${project}${into}. What is live stays as it is.` };
58
+ }
59
+ /**
60
+ * The project the Vercel setup makes, named as `vercel link --yes` finds it in the checkout's root (`root`): the folder's
61
+ * own name, which the playbook's `vercel project add` gives it. None where that name is not one Vercel takes as it is
62
+ * (lowercase letters, digits, `.`, `_` and `-`, at most 100, never `---`), since the CLI would change it (REN-1250).
63
+ */
64
+ export function vercelProjectName(root) {
65
+ const name = basename(root);
66
+ return /^[a-z0-9._-]{1,100}$/.test(name) && !name.includes('---') ? name : undefined;
67
+ }
48
68
  /**
49
69
  * A host's CLI that is not on this machine (#396), asked before anything installs it: the package, the version and the
50
70
  * global install with npm. There is no Skip, since the host's setup cannot go on without its CLI: Esc is the person's own install.
@@ -137,6 +157,25 @@ export const HOST_SCREENS = {
137
157
  ...buildWait('render', 'Render', 'set Pull Request Previews to Automatic under the service\'s Previews settings'),
138
158
  ...previewsOff('netlify', 'Netlify', 'turn Deploy Previews on under Branches and deploy contexts → Configure'),
139
159
  ...previewsOff('cloudflare', 'Cloudflare', 'set Preview branch control to All non-Production branches under Builds & deployments'),
160
+ // Vercel builds the pushes and the pull requests, but with deployment_status Events off it tells GitHub nothing of them, and GitHub's record is where Impeccable finds them (REN-1248): the one consent to turning it on as the person, for a project this run did not create (the tool names the project in the prompt).
161
+ 'vercel-previews': {
162
+ id: 'vercel-previews',
163
+ kind: 'confirm',
164
+ title: 'Vercel, but GitHub never hears of the previews',
165
+ hint: "Vercel builds this repository's pull requests, but the project's deployment_status Events setting is off, so GitHub gets no record of the previews, and that record is where Impeccable finds them. The run turns it on as you; nothing else on the project changes.",
166
+ prompt: 'Turn on deployment_status Events?',
167
+ confirmLabel: 'Turn on',
168
+ skipLabel: 'Skip',
169
+ },
170
+ 'vercel-previews-retry': {
171
+ id: 'vercel-previews-retry',
172
+ kind: 'confirm',
173
+ title: 'Vercel, but GitHub never hears of the previews',
174
+ hint: 'deployment_status Events is still off. On the page that opened, turn it on under Git.',
175
+ prompt: 'Changed it?',
176
+ confirmLabel: 'Try again',
177
+ skipLabel: 'Skip',
178
+ },
140
179
  // Render's consent is its one screen (renderConfirm): only the retry is its own.
141
180
  'render-previews-retry': previewsOff('render', 'Render', "set Pull Request Previews to Automatic under the service's Previews settings")['render-previews-retry'],
142
181
  // No Pages project builds the repository's pull requests: the one consent to connecting the repository to the project the checkout names, or to creating one for it where Cloudflare builds a Worker instead (the tool names the project in the prompt), and the sign-in cf needs for either.
package/dist/hosted.js CHANGED
@@ -15,6 +15,24 @@ const execFileAsync = promisify(execFile);
15
15
  /** How many deployments, and how many pull requests, the evidence reads: the newest. */
16
16
  const LIMIT = 20;
17
17
  const STATES = ['error', 'failure', 'inactive', 'in_progress', 'queued', 'pending', 'success'];
18
+ /** Vercel's commit statuses on a commit, the newest of each context its bot posted. */
19
+ function vercelStatusesOf(statuses, sha) {
20
+ const newest = new Map();
21
+ // GitHub lists a commit's statuses newest first.
22
+ for (const s of statuses.map(record)) {
23
+ const context = s?.context;
24
+ if (typeof context !== 'string' || !/^Vercel(?: – [A-Za-z0-9._-]{1,100})?$/.test(context) || record(s.creator)?.login !== 'vercel[bot]' || newest.has(context))
25
+ continue;
26
+ const said = typeof s.description === 'string' ? s.description.trim() : '';
27
+ // As pristine reads them: a skip says so ("Skipped", "Canceled by Ignored Build Step"); any other success is a completed build.
28
+ const state = s.state === 'pending' ? 'building' : s.state === 'failure' || s.state === 'error' ? 'failed' : s.state === 'success' && /^(Skipped|Canceled by Ignored Build Step)\b/i.test(said) ? 'skipped' : s.state === 'success' ? 'built' : null;
29
+ if (!state)
30
+ continue;
31
+ const target = typeof s.target_url === 'string' && /^https:\/\/vercel\.com\/[^\s?#]{1,300}$/.test(s.target_url) ? s.target_url : null;
32
+ newest.set(context, { sha, context, state, targetUrl: target });
33
+ }
34
+ return [...newest.values()];
35
+ }
18
36
  const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
19
37
  const positive = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
20
38
  /** An https address without credentials, a query or a fragment, as origin and path; null for anything else. */
@@ -40,11 +58,11 @@ export async function readHostedPreviews(owner, repo, deps = {}) {
40
58
  const runGh = deps.runGh ?? (async (args) => (await execFileAsync('gh', args, {
41
59
  encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024, signal: deps.signal, env: { ...process.env, GH_PROMPT_DISABLED: '1' },
42
60
  })).stdout);
43
- const list = async (route) => {
61
+ const list = async (route, limit = LIMIT) => {
44
62
  const data = JSON.parse(await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]));
45
63
  if (!Array.isArray(data))
46
64
  throw new Error(`${route} is not a list`);
47
- return data.slice(0, LIMIT);
65
+ return data.slice(0, limit);
48
66
  };
49
67
  const base = `/repos/${owner}/${repo}`;
50
68
  try {
@@ -75,7 +93,10 @@ export async function readHostedPreviews(owner, repo, deps = {}) {
75
93
  pullRequests = numbers(await list(`${base}/commits/${d.sha}/pulls`).catch(() => ((unsaid = true), [])));
76
94
  return { sha: d.sha, environment, state, url, vercel: record(d.creator)?.login === 'vercel[bot]', pullRequests };
77
95
  }));
78
- return { deployments: read.filter((d) => d !== null), ...(unsaid ? { error: 'GitHub could not say which pull requests some previews belong to: read again.' } : {}) };
96
+ const kept = read.filter((d) => d !== null);
97
+ // A project with its deployments on GitHub turned off still posts its commit status: read for every branch, since another project's deployment of the head says nothing of this one's, and the wait keeps the project's own.
98
+ const statuses = typeof head === 'string' ? vercelStatusesOf(await list(`${base}/commits/${head}/statuses?per_page=100`, 100).catch(() => []), head) : [];
99
+ return { deployments: kept, ...(statuses.length ? { vercelStatuses: statuses } : {}), ...(unsaid ? { error: 'GitHub could not say which pull requests some previews belong to: read again.' } : {}) };
79
100
  }
80
101
  catch {
81
102
  return unread;
package/dist/main.js CHANGED
@@ -13,7 +13,7 @@ import { copyText, letTabsHear, openBrowser, progressPage, screenPrompt, setupAu
13
13
  import { checkoutJournal } from './leftovers.js';
14
14
  import { createLog, UserError } from './log.js';
15
15
  import { firstMessage, playbook, setupContext } from './playbook.js';
16
- import { backToStartBranch, checkoutFrontEnd, currentBranch, onOrigin, parseFlags, readCheckout, skipWorkingFolderForCommands, stateSaysNoFrontEnd, USAGE } from './preflight.js';
16
+ import { backToStartBranch, checkoutFrontEnd, currentBranch, dropEmptyRunBranches, localBranches, onOrigin, parseFlags, readCheckout, skipWorkingFolderForCommands, stateSaysNoFrontEnd, USAGE } from './preflight.js';
17
17
  import { startSandbox } from './sandbox.js';
18
18
  import { scanRecorder } from './scan.js';
19
19
  import { createReporter, reportingOff } from './report.js';
@@ -129,12 +129,16 @@ export async function main(argv) {
129
129
  const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
130
130
  const slug = `${checkout.owner}/${checkout.repo}`;
131
131
  const startBranch = currentBranch(checkout.root);
132
+ // The branches the person had, which the end never removes (REN-1245).
133
+ const startBranches = localBranches(checkout.root);
132
134
  // What the run's write and edit tools change, from before the agent's first step: the end takes back what no commit carries (#421).
133
135
  const journal = checkoutJournal(checkout.root);
134
136
  backToStart = () => {
135
137
  const kept = journal.undo();
136
138
  const branch = backToStartBranch(checkout.root, startBranch);
137
- return [...kept, ...(branch ? [branch] : []), ...journal.appeared(startBranch)];
139
+ // A branch the run made and left empty goes too, whoever took the checkout back to its branch.
140
+ const empty = dropEmptyRunBranches(checkout.root, startBranch, startBranches);
141
+ return [...kept, ...(branch ? [branch] : []), ...empty, ...journal.appeared(startBranch)];
138
142
  };
139
143
  // From the checkout's top, where the sandbox lays its own protections of a checkout.
140
144
  process.chdir(checkout.root);
@@ -317,6 +321,9 @@ export async function main(argv) {
317
321
  const end = ending(report);
318
322
  // The person's no left no app with a preview Impeccable can open: the screen says why alone, with Close, and the report follows it here (#413).
319
323
  const ended = Boolean(report.ended);
324
+ // The run's own words for why stay off that screen, here and in the report (REN-1245).
325
+ if (ended && report.reason)
326
+ log.write('setup ended', { reason: report.reason });
320
327
  // One pull request a screen, each opened in the browser as its screen appears: the test drive (each app's, on a repository with several), then the setup pull request. Only the last of them goes on the clipboard, which would keep no other (#418); the others offer Copy link [C].
321
328
  const hand = { open: openBrowser, copy: copyText };
322
329
  const last = !ended && setupPr ? -1 : (drives?.length ?? 1) - 1;
@@ -14,7 +14,8 @@ It applies as well **before the first pull request**: Vercel deploys the reposit
14
14
  - **The live app**: with the checkout linked to the project (`.vercel/project.json`) and a production deployment of it on GitHub's record, the evidence answers the project's own domain as `production`; live-app-address says how it becomes the before, in step 2's write. Unlinked, GitHub's record names no address of it that is open.
15
15
  - **The aim** holds here: the CLI does the work, and the person is asked only what the playbook's aim allows.
16
16
  - **Behind Vercel's login** (`gate: "vercel-protection"`): Deployment Protection stands in front of the previews. Get past it as preview-protection says, which proves the preview, and go on with step 2; when nothing got past it, that case says how the run ends.
17
- - **`pullRequestPreviews` in impeccable_hosted_previews' answer**: what vercel.json and the linked project say of the test drive's branch, read with the evidence and never changed by the run, since the setting is the repository's own or has no command (impeccable_enable_previews takes no Vercel). `enabled: false` (`git.deploymentEnabled` in vercel.json turns the branch off, or Vercel records no deployments on GitHub, where Impeccable finds its previews): push no test drive on Vercel. Set the preview up as server-previews says, and say in one line under "What was skipped, and why" that Vercel builds no preview of the pull requests, with the `reason`, and the exact change for the person: the vercel.json rule to lift, or Vercel GitHub deployments under the project's Settings → Git. `enabled: 'unknown'` (an Ignored Build Step decides commit by commit, or the CLI could not read the project): go on, and say so in one line under "What is not verified".
17
+ - **`pullRequestPreviews` in impeccable_hosted_previews' answer**: what vercel.json and the linked project say of the test drive's branch, read with the evidence. `enabled: false` from vercel.json (`git.deploymentEnabled` turns the branch off, the repository's own setting): push no test drive on Vercel. Set the preview up as server-previews says, and say in one line under "What was skipped, and why" that Vercel builds no preview of the pull requests, with the `reason` and the vercel.json rule to lift. `enabled: false` with `cause: "previews"` (deployment_status Events off: Vercel builds the pushes but records no deployment on GitHub, where Impeccable finds its previews): before the test drive's push, impeccable_enable_previews (`host: "vercel"`) asks the person on one screen and turns it on. `enabled`, go on; `skipped` is a no that ends the setup, as the playbook says; `refused`, go on, since the wait finds Vercel's build by its commit status, and put "Turn on deployment_status Events under the Vercel project's Settings → Git" in `left`. `enabled: 'unknown'` (an Ignored Build Step decides commit by commit, or the CLI could not read the project): go on, and say so in one line under "What is not verified".
18
+ - **`githubDeployments: false` in the answer of the wait for a pushed branch**: as vercel-setup's "No preview for the pushed test drive" says.
18
19
  - **Another app, or no answer from any of the three**: Vercel's previews do not show this app. Set the preview up as server-previews says instead, and say under "What is not verified" what the Vercel address answered.
19
20
  - **The pull requests**: no setup pull request; the test drive branches from the default branch, and Vercel builds its preview from the push, which pull-requests' step 4 waits for and proves the test drive's page on before the pull request opens. In place of pull-requests' step 3, build the test drive branch once with the install and build Vercel runs (`vercel.json`'s `installCommand` and `buildCommand`, else the package's), as plain commands (`pnpm install`, then `pnpm run build`) and not with impeccable_verify_preview, which is for a preview Impeccable starts itself, so a page that does not build is never pushed.
20
21
  - The evidence is GitHub's: link, deploy and change nothing on Vercel, and save no address pattern, since Vercel reports each preview's address to GitHub itself.
@@ -11,7 +11,7 @@ The checkout is set up for Vercel (`vercel.json`, `.vercel/project.json`, a `ver
11
11
  - **Skip**: a no that ends the setup, as the playbook says: say under "What was skipped, and why" that Vercel was found and the sign-in was skipped. The steps below do not apply.
12
12
  2. **Create the project, then link it** (run each Vercel command plain, on its own: its answer is short, a pipe to `tail` is refused, and a command chained after it leaves the project unrecorded): `vercel project add <the folder's name> --team <the team>` creates the project in that team, under the name `vercel link` gives a project (the checkout folder's name, lowercase); then `vercel link --yes --team <the team>`, in the checkout's root (never with `--cwd` or after a `cd`), links the folder to the project of that name, which is now the run's. The CLI names no team on its own without a terminal, so the option is given whenever there is a team. Done when the link prints the linked project.
13
13
  - **`vercel project add` fails** (the name is in use: the project is the person's, not this run's): run no link; the Vercel setup ends there, and with the checkout linked to no project its CLI stops and the hold on pushes goes. Go on as "A step fails before the test drive is pushed" says, naming the project under "What was skipped, and why". When `.vercel/project.json` was in the checkout before the run, that link is not this run's, and the hold on pushes stays, by design: no test drive, as "No preview for the pushed test drive" says, and the report says under "What was skipped, and why" that the person removes `.vercel/project.json` or links the project themselves and runs the setup again.
14
- 3. **Connect the repository, and lock the project before its first deployment**, with impeccable_vercel_connect: it runs the connection on the linked project, when that project is the one step 2 created or one connected to this repository already, asks the person for Vercel's GitHub app's access when the app cannot reach the repository, and then, on a project with no production deployment, puts Vercel's login in front of every address of the project, its own included. Vercel makes a new project's first deployment Production whatever branch it builds and serves it at the project's own address, and the test drive's push is that first deployment. When Vercel refuses the lock, the tool asks the person on one screen to turn it on in the project's settings and waits for it. Done when it answered `connected: true` with `production: "exists"` or `locked: true`; a project connected by the link already is, and it says so.
14
+ 3. **Connect the repository, and lock the project before its first deployment**, with impeccable_vercel_connect: it runs the connection on the linked project, when that project is the one step 2 created or one connected to this repository already, asks the person for Vercel's GitHub app's access when the app cannot reach the repository, and then, on a project with no production deployment, puts Vercel's login in front of every address of the project, its own included. Vercel makes a new project's first deployment Production whatever branch it builds and serves it at the project's own address, and the test drive's push is that first deployment. When Vercel refuses the lock, the tool asks the person on one screen to turn it on in the project's settings and waits for it. It also turns on the project's deployment_status Events where it is off: Vercel records its deployments on GitHub only with it on, a project the CLI creates starts with it off, and GitHub's record is where the wait finds the test drive's preview. The tool turns it on at once for the project step 2 created, and after one screen for any other (`deploymentEvents` in the answer). Done when it answered `connected: true` with `production: "exists"` or `locked: true`, and no `deploymentEvents` skipped; a project connected by the link already is, and it says so.
15
15
  4. **Build and push the test drive** (the Test drive task) as pull-requests' steps 2 to 4 say (on a repository with several apps, where the run sets up more than one, that app's own test drive, on `impeccable/test-drive-<appKey>` with its page in that app alone, as the monorepo case's step 6 says), with the changes vercel-previews' reference gives: from the default branch, no setup pull request, built once with plain commands. Stage the new page and the page that links to it by name: `vercel link` writes `.vercel/` and a `.env.local` that holds a Vercel token, and may add `.vercel` to `.gitignore`, and none of that goes into the pull request. Its push starts the project's first deployment. Done when `git push -u origin impeccable/test-drive` (the app's own branch, where it has one) succeeded.
16
16
  5. **Its preview, proven and saved, then its pull request** as pull-requests' step 4 (Vercel) and step 5 say: the wait for the pushed head's deployment, the test drive's page proven on the address it lists (past Vercel's login as preview-protection says), impeccable_write to `preview` as vercel-previews' step 2 says, then the pull request and its recording. The playbook's step 6 then has no pull request left to open: go on with the playbook's step 4. Done when impeccable_test_pr answered "Recorded".
17
17
 
@@ -20,13 +20,15 @@ The checkout is set up for Vercel (`vercel.json`, `.vercel/project.json`, a `ver
20
20
  - **The live app**: a project created here has none yet, which the report says; one that deploys production already (`production: "exists"`) answers its address as `productionUrl`, and live-app-address says how it becomes the before.
21
21
  - **The aim** holds here: the CLI does the work, and the person is asked only what the playbook's aim allows.
22
22
  - **A step fails before the test drive is pushed** (the sign-in not completed, the project not created, the repository not connected, or step 3 answering that the linked project is not this run's: `vercel link` linked one of the person's projects, named like the folder, which is left as it is): the Vercel setup ends there. Set the preview up as server-previews says, say under "What was skipped, and why" what stopped it, and put the person's step in `left` when there is one, such as "Give Vercel's GitHub app access to the repository", with the `access` page in the report. The sign-in, the team or the GitHub app's access skipped is a no that ends the setup, as the playbook says.
23
- - **`pullRequestPreviews` in impeccable_hosted_previews' answer**: what vercel.json and the linked project say of the test drive's branch, read with the evidence and never changed by the run, since the setting is the repository's own or has no command (impeccable_enable_previews takes no Vercel). `enabled: false` (`git.deploymentEnabled` in vercel.json turns the branch off, or Vercel records no deployments on GitHub, where Impeccable finds its previews): the Vercel setup ends before step 4, as "A step fails before the test drive is pushed" says, and say in one line under "What was skipped, and why" that Vercel builds no preview of the pull requests, with the `reason`, and the exact change for the person: the vercel.json rule to lift, or Vercel GitHub deployments under the project's Settings → Git. `enabled: 'unknown'` (an Ignored Build Step decides commit by commit, or the CLI could not read the project): go on, and say so in one line under "What is not verified".
23
+ - **`pullRequestPreviews` in impeccable_hosted_previews' answer**: what vercel.json and the linked project say of the test drive's branch, read with the evidence. `enabled: false` from vercel.json (`git.deploymentEnabled` turns the branch off, the repository's own setting): the Vercel setup ends before step 4, as "A step fails before the test drive is pushed" says, and say in one line under "What was skipped, and why" that Vercel builds no preview of the pull requests, with the `reason` and the vercel.json rule to lift. `enabled: false` with `cause: "previews"` (deployment_status Events off) is step 3's to turn on. `enabled: 'unknown'` (an Ignored Build Step decides commit by commit, or the CLI could not read the project): go on, and say so in one line under "What is not verified".
24
+ - **`deploymentEvents` from step 3**: `enabled`, go on. `skipped` is a no that ends the setup, as the playbook says: push nothing. `refused` (Vercel kept the run and the person from changing it): go on with step 4, since the wait finds Vercel's build by its commit status, and put "Turn on deployment_status Events under the Vercel project's Settings → Git" in `left`, since Impeccable's reviews find each later preview on GitHub's record.
24
25
  - **A push before step 3** is refused, since a connected project builds every push: run impeccable_vercel_connect first.
25
26
  - **On a repository with several apps**, the project step 2 creates builds the repository's root: impeccable_vercel_connect connects and locks it only where an app this run sets up is the root's, and otherwise answers so, and the Vercel setup ends there for that app, as "A step fails before the test drive is pushed" says.
26
27
  - **`locked: false`** from step 3, with `production` not "exists" (the person skipped the tool's screen, or the lock was still off after its retries): push nothing, and set up no other preview either, since the connected project would build that push as its first deployment, public at its own address. Mark the Test drive task failed, and say under "What was skipped, and why" what stopped the lock (the answer's `reason`). The screen skipped is a no that ends the setup, as the playbook says.
27
28
  - **No preview for the pushed test drive**: save no method, set up no other preview and open no pull request, as pull-requests' "No preview, no test drive" says; the project and the connection stay, and the report names them.
28
29
  - **The build failed** (`state` is `failure` or `error`): `vercel deploy --yes` builds this checkout on Vercel and prints why it failed. When the cause is in the test drive's own change, correct it, commit, push once more and wait for that build; else the report names the cause.
29
- - **No deployment within five minutes**: the wait's own screen opens the project's Git settings and waits again on the person's yes. When they skip it, or none came after its retries, say under "What is not verified" that Vercel built no push here, and that the project's Git connection is the thing to check.
30
+ - **No deployment within five minutes**: the wait's own screen opens the project's Git settings and waits again on the person's yes. When they skip it, or none came after its retries, that is the end of the wait (a second call reads once and waits no more): say under "What is not verified" that Vercel built no push here, and that the project's Git connection is the thing to check.
31
+ - **`githubDeployments: false` in the wait's answer**: Vercel's commit status on the head settled it, and GitHub records no deployment of it; `reason` says, in Vercel's words, whether it built, failed or skipped the branch. A deployment listed is the test drive's build: prove it as usual. Whatever came of it, the report's reason and `left` say what Vercel did and name deployment_status Events under the project's Settings → Git as the one change, and say Vercel built the branch whenever `reason` does. "No preview was built" is the wording of a branch with no deployment and no commit status of Vercel's.
30
32
  - **Behind Vercel's login** (`gate: "vercel-protection"`) with nothing getting past it: preview-protection says what the report names.
31
33
  - **The first deployment** of a new project is called Production, whatever branch it builds. Locked in step 3, and still so when its deployment is read, it is the test drive's preview: impeccable_hosted_previews lists it, and the proof opens it past Vercel's login as preview-protection says. Unlocked by then, or another project's, the evidence lists no address for it and the proof refuses it: no test drive, as "No preview for the pushed test drive" says, and the report says under "What is not verified" that Vercel serves the test drive's build at the project's own address (`vercel project ls` prints it) without a login until the default branch deploys, and that Vercel Authentication for All Deployments, under the project's Settings → Deployment Protection, puts its login back in front. A project that deployed production before the run (`production: "exists"`) builds the push as a Preview, and keeps its addresses as they were.
32
34
  - **Behind Vercel's login** (`gate: "vercel-protection"`): a project the CLI creates starts with Vercel Authentication on, and step 3 locks it at every address, so expect it, and go on as the preview-protection case says.
package/dist/preflight.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync, statSync } from 'node:fs';
2
+ import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { folderOf } from './host-previews.js';
@@ -314,22 +314,100 @@ export function currentBranch(root) {
314
314
  return '';
315
315
  }
316
316
  }
317
+ /** A branch the run makes its pull requests on: impeccable/setup, impeccable/test-drive or an app's impeccable/test-drive-<app key>, with -2, -3 where an earlier run took it. */
318
+ const RUN_BRANCH = /^impeccable\/(?:setup(?:-\d+)?|test-drive(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?)$/;
319
+ /** The checkout's local branches; null when git cannot say. */
320
+ export function localBranches(root) {
321
+ try {
322
+ return new Set(execFileSync('git', ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, env: gitEnvironment() }).split('\n').filter(Boolean));
323
+ }
324
+ catch {
325
+ return null;
326
+ }
327
+ }
328
+ /**
329
+ * Once the checkout is back on `start`: each of the run's branches that was not in the checkout at its start (`before`),
330
+ * is on no remote, and holds no commit of its own (its tip is `start`'s), as a branch made before a no that ended the
331
+ * setup is (REN-1245), is removed, so the checkout ends as it started. A branch the person had is never touched: with
332
+ * `before` unread (null), none is removed. The done screen's lines for the ones git would not remove.
333
+ */
334
+ export function dropEmptyRunBranches(root, start, before) {
335
+ if (!before || !start || currentBranch(root) !== start)
336
+ return [];
337
+ const git = (args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000, env: gitEnvironment() }).trim();
338
+ const lines = [];
339
+ let tip;
340
+ try {
341
+ tip = git(['rev-parse', '--verify', '-q', `refs/heads/${start}`]);
342
+ }
343
+ catch {
344
+ return [];
345
+ }
346
+ for (const branch of localBranches(root) ?? []) {
347
+ if (before.has(branch) || !RUN_BRANCH.test(branch) || onOrigin(root, branch))
348
+ continue;
349
+ try {
350
+ if (git(['rev-parse', '--verify', '-q', `refs/heads/${branch}`]) !== tip)
351
+ continue;
352
+ // -d, never -D: git removes it only as merged into the checkout's branch.
353
+ git(['branch', '-d', branch]);
354
+ }
355
+ catch {
356
+ lines.push(`${branch}, a branch the setup made, is still in your checkout: git branch -d ${branch} removes it.`);
357
+ }
358
+ }
359
+ return lines;
360
+ }
361
+ /** What `vercel link` appends to the root .gitignore: each of its lines the file lacks, after a line break where the file ended without one. */
362
+ const LINK_LINES = /^(?:\r?\n)?(?:(?:\.vercel|\.env\*)\r?\n)+$/;
363
+ /**
364
+ * The root .gitignore taken back to the start branch's (`HEAD`, nothing staged) where all that changed is the lines
365
+ * `vercel link` appends, and only where git then shows no untracked file it hides now: a start branch that does not
366
+ * ignore the link's token file, .env.local, keeps the lines. Any other change is the person's, and stays (REN-1250).
367
+ * Whether it was taken back.
368
+ */
369
+ function restoreLinkLines(root, git) {
370
+ const path = join(root, '.gitignore');
371
+ const now = readFileSync(path, 'utf8');
372
+ const before = git(['cat-file', 'blob', 'HEAD:.gitignore']);
373
+ if (git(['cat-file', 'blob', ':.gitignore']) !== before || !now.startsWith(before) || !LINK_LINES.test(now.slice(before.length)))
374
+ return false;
375
+ // File by file, so a file the lines hide inside a folder git shows already counts too.
376
+ const shown = () => git(['status', '--porcelain=v1', '-z', '--untracked-files=all']).split('\0').filter((line) => line.startsWith('?? '));
377
+ const visible = new Set(shown());
378
+ writeFileSync(path, before);
379
+ let restored = false;
380
+ try {
381
+ restored = shown().every((line) => visible.has(line));
382
+ }
383
+ finally {
384
+ // Whatever stopped the check, the link's lines go back, so nothing they hide is shown to git add.
385
+ if (!restored)
386
+ writeFileSync(path, now);
387
+ }
388
+ return restored;
389
+ }
317
390
  /**
318
391
  * The run makes its pull requests on branches of its own, and git leaves the checkout on the last it made: the end goes back
319
392
  * to the branch the run started on (`start`), so what the person commits next does not land on the test drive. Only from one
320
- * of the run's branches, and only with no change to a tracked file, which would go along; git refuses where an untracked
321
- * file would be overwritten. The run's own files no commit carries are taken back before this (checkoutJournal). Null when the checkout is back, or never left; else the line the done screen shows.
393
+ * of the run's branches, and only with no change to a tracked file, which would go along, but to a .gitignore the two
394
+ * branches hold alike, which goes along named, or goes back where it holds only `vercel link`'s lines (restoreLinkLines);
395
+ * git refuses where an untracked file would be overwritten. The run's own files no commit carries are taken back before
396
+ * this (checkoutJournal). Null when the checkout is back with nothing carried, or never left; else the line the done
397
+ * screen shows.
322
398
  */
323
399
  export function backToStartBranch(root, start) {
324
400
  const now = currentBranch(root);
325
- if (now === start || !/^impeccable\/(?:setup(?:-\d+)?|test-drive(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?)$/.test(now))
401
+ if (now === start || !RUN_BRANCH.test(now))
326
402
  return null;
327
403
  if (!start)
328
404
  return `Your checkout is on ${now}, the branch the setup made.`;
329
405
  const git = (args, input) => execFileSync('git', args, { cwd: root, encoding: 'utf8', input, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'], timeout: 10_000, maxBuffer: 64 * 1024 * 1024, env: gitEnvironment() });
330
406
  try {
331
407
  // A content filter is a program git runs outside the sandbox as it reads or writes a file, from settings that may name a script in the checkout a sandboxed command could have changed: a checkout with any file a filter takes, here or on the start branch, is left to the person. Neither read runs one.
332
- const paths = [...new Set([...git(['ls-files', '-z']).split('\0'), ...git(['diff', '--name-only', '-z', '--no-ext-diff', '--no-textconv', 'HEAD', start, '--']).split('\0')].filter(Boolean))].map((path) => `${path}\0`).join('');
408
+ // The files the run's branch and the start branch hold differently.
409
+ const differ = git(['diff', '--name-only', '-z', '--no-ext-diff', '--no-textconv', 'HEAD', start, '--']).split('\0').filter(Boolean);
410
+ const paths = [...new Set([...git(['ls-files', '-z']).split('\0'), ...differ].filter(Boolean))].map((path) => `${path}\0`).join('');
333
411
  const listed = git(['check-attr', '--stdin', '-z', 'filter'], paths).split('\0');
334
412
  // path, attribute, value, ...
335
413
  for (let i = 2; i < listed.length; i += 3)
@@ -341,12 +419,23 @@ export function backToStartBranch(root, start) {
341
419
  throw new Error('a content filter on the start branch');
342
420
  // Named, so the done screen says which files keep the checkout here (#421).
343
421
  const changed = git(['status', '--porcelain=v1', '-z', '--untracked-files=no', '--no-renames']).split('\0').filter(Boolean).map((line) => line.slice(3));
344
- if (changed.length)
345
- return `Your checkout is still on ${now}, since ${listPaths(changed)} changed there: git checkout ${start} takes it back to the branch you started on.`;
422
+ // A .gitignore both branches hold alike, which a host's link changed (vercel link adds .vercel and .env*, before the test drive's branch is made), goes along to the start branch, where the root's goes back as it was when the link's lines are all that changed and hide nothing the start branch's lines do not; else it stays as it is, named (REN-1250), since taking the lines back would show the link's token file, .env.local, to git add. Any other change keeps the checkout here.
423
+ const carried = changed.filter((path) => (path === '.gitignore' || path.endsWith('/.gitignore')) && !differ.includes(path));
424
+ const held = changed.filter((path) => !carried.includes(path));
425
+ if (held.length)
426
+ return `Your checkout is still on ${now}, since ${listPaths(held)} changed there: git checkout ${start} takes it back to the branch you started on.`;
346
427
  // The closing -- makes git read the name as the branch, never as a path.
347
428
  // An ignored file of the person's where the start branch tracks a file of that name stops the checkout, rather than being overwritten.
348
429
  git(['-c', 'submodule.recurse=false', 'checkout', '-q', '--no-overwrite-ignore', start, '--']);
349
- return null;
430
+ let restored = false;
431
+ try {
432
+ restored = carried.includes('.gitignore') && restoreLinkLines(root, git);
433
+ }
434
+ catch {
435
+ // Left as it is, and named.
436
+ }
437
+ const left = restored ? carried.filter((path) => path !== '.gitignore') : carried;
438
+ return left.length ? `${listPaths(left)} changed during the setup, from a command it ran or from you, and ${left.length === 1 ? 'stays' : 'stay'} uncommitted on ${start} as ${left.length === 1 ? 'it is' : 'they are'}.` : null;
350
439
  }
351
440
  catch {
352
441
  return `Your checkout is still on ${now}: git checkout ${start} takes it back to the branch you started on.`;
package/dist/tools.js CHANGED
@@ -10,7 +10,7 @@ import { defineTool } from '@earendil-works/pi-coding-agent';
10
10
  import { agreeToCliInstall, agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endAppSetup, endHostSetup, HOST_CLI_VERSIONS, hostCliInstallArgs, hostCliInstallCommand, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
11
11
  import { requestJson } from './http.js';
12
12
  import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
13
- import { APPS_QUESTION, cliInstallScreen, cliManualScreen, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, NETLIFY_PASSWORD_WHERE, netlifyPasswordReplace, renderConfirm, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste } from './host-questions.js';
13
+ import { APPS_QUESTION, cliInstallScreen, cliManualScreen, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, NETLIFY_PASSWORD_WHERE, netlifyPasswordReplace, renderConfirm, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste, vercelProjectName, vercelSignedIn } from './host-questions.js';
14
14
  import { enableNetlifyPreviews, folderOf } from './host-previews.js';
15
15
  import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews, readmeNamesApp, renderSiteOf, vercelDeploymentOf } from './hosted.js';
16
16
  import { closedPulls, deploymentsOf, hostingApp, hostingRead, liveHostOf, renderSitesOf } from './hosting.js';
@@ -19,7 +19,7 @@ import { cloudflareFromHosting, cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connect
19
19
  import { passCloudflareAccess, TOKEN_NAME } from './cloudflare-access.js';
20
20
  import { netlifyFromHosting, commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
21
21
  import { netlifyToken, passNetlifyLock, setSitePassword } from './netlify-password.js';
22
- import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelDashboardScope, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelRepoProject, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
22
+ import { connectVercelProject, DEPLOYMENT_EVENTS, DEPLOYMENTS_OFF, deploymentOfStatus, enableGithubDeployments, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelDashboardScope, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelRepoProject, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
23
23
  import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
24
24
  import { fitsTask, stagesOf, workStarted } from './stages.js';
25
25
  import { commandLine, redact } from './log.js';
@@ -165,10 +165,11 @@ function stageError(status, tasks) {
165
165
  }
166
166
  /** The line under the last screen's heading, about the pull request that screen ends on: the setup pull request when there is one, else the test drive; and, where a host builds the previews and so no setup pull request was needed, one line saying so. Without a test drive, what stopped it, from what the run saved and met (#321). */
167
167
  export function resultLine(report) {
168
+ // A setup the person's no ended shows its own line alone: the run's words for why stay in the report (#413, REN-1245).
169
+ if (report.ended)
170
+ return SETUP_ENDS[report.ended].line;
168
171
  // The run's own words for why no method was saved, where it gave them (REN-1108).
169
172
  const why = report.reason && !report.methodSaved ? ` Why: ${report.reason.replace(/[.\s]+$/, '')}.` : '';
170
- if (report.ended)
171
- return `${SETUP_ENDS[report.ended].line}${why}`;
172
173
  const lead = report.walled ? `Impeccable's own check of the preview met a wall (${report.walled}), so the test drive's review can't see its pages yet. The report, printed here when this screen closes, says what the run found.`
173
174
  : !report.testPr ? `${report.unhonoured ?? noTestDrive(report)}${why} The report, printed here when this screen closes, says what the run found.` : report.setupPr ? 'The setup pull request adds the workflow that lets Impeccable run on your previews. Merge it when you are ready.' : SEE_THE_REVIEW;
174
175
  // --signed-in-test-drive left an app without a test drive: said beside another app's drive or a wall too (#410).
@@ -282,7 +283,7 @@ const REPORT = {
282
283
  description: `What waits on the person besides merging the setup pull request and closing the test drive: one line each, at most ${STATUS_MAX} characters, such as "Add the admin test account on the setup page". The terminal counts them. Leave it out when nothing does.`,
283
284
  items: { type: 'string', minLength: 1 },
284
285
  },
285
- reason: { type: 'string', minLength: 1, description: 'On a repository of one app where no preview method was saved: why, in plain words of at most 200 characters, such as "Render\'s pull request previews are off, and the person skipped turning them on". The last screen shows it. Names only, never an address or a key.' },
286
+ reason: { type: 'string', minLength: 1, description: 'On a repository of one app where no preview method was saved: why, in plain words of at most 200 characters, such as "Render\'s pull request previews are off, and the person skipped turning them on". The last screen shows it, unless the person\'s no ended the setup. Names only, never an address or a key.' },
286
287
  apps: {
287
288
  type: 'array',
288
289
  description: 'On a repository with several apps: for each picked app whose preview was not proven or keyed, why, in plain words of at most 200 characters, such as "not checked: the person stopped after the first app". The last screen shows it on that app\'s line. Names only, never an address or a key.',
@@ -406,6 +407,19 @@ const namesCredential = (q) => {
406
407
  const text = [q.title, q.prompt, q.hint].filter(Boolean).join('\n');
407
408
  return CREDENTIAL.test(text) || CREDENTIAL.test(text.replace(/_|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g, ' '));
408
409
  };
410
+ /** What Vercel's commit statuses on a pushed branch's head say, where GitHub records no deployment of Vercel's for it (REN-1249): the cause in Vercel's own words; none while it is still building. */
411
+ function statusReason(branch, statuses, listed) {
412
+ const said = (state) => statuses.some((s) => s.state === state);
413
+ if (said('built'))
414
+ return listed
415
+ ? `Vercel built ${branch}; the deployment its commit status names is listed, as Vercel reads it. ${DEPLOYMENTS_OFF}`
416
+ : `Vercel built ${branch}, as its commit status says, but the Vercel CLI could not read which deployment that was, so it has no address here. ${DEPLOYMENTS_OFF}`;
417
+ if (said('failed'))
418
+ return `Vercel's build of ${branch} failed, as its commit status says. ${DEPLOYMENTS_OFF}`;
419
+ if (said('skipped'))
420
+ return `Vercel skipped ${branch}, as its commit status says: an Ignored Build Step or a branch rule kept it from building.`;
421
+ return undefined;
422
+ }
409
423
  /** Under the field of every text question typed in the open, where the model reads the answer. */
410
424
  const NEVER_A_CREDENTIAL = 'Never enter a password, a token or a key here.';
411
425
  /** The app a screen is for, on a repository with several apps: the person's no on it ends the setup for that app alone (#413). */
@@ -414,7 +428,7 @@ const ENABLE_PREVIEWS = {
414
428
  type: 'object',
415
429
  additionalProperties: false,
416
430
  required: ['host'],
417
- properties: { host: { type: 'string', enum: ['netlify', 'cloudflare'], description: "The host whose pullRequestPreviews said enabled false in this run." }, appKey: FOR_APP },
431
+ properties: { host: { type: 'string', enum: ['netlify', 'cloudflare', 'vercel'], description: "The host whose pullRequestPreviews said enabled false in this run." }, appKey: FOR_APP },
418
432
  };
419
433
  const CLOUDFLARE_CONNECT = {
420
434
  type: 'object',
@@ -439,6 +453,9 @@ const ASKS_ADDRESS = /\b(urls?|domains?|(web|site|live|production|preview|app's)
439
453
  /** What schemaError cannot say about a question: options belong to single and multi, at least two, each value once, and each other field to its kinds. */
440
454
  function askError(q) {
441
455
  const values = (q.options ?? []).map((o) => o.value);
456
+ // A prompt the person cannot read as a question, such as "x", never reaches the screen, where Skip would read as their no (REN-1255).
457
+ if ((q.prompt.match(/\p{L}[\p{L}\p{N}'’-]*/gu) ?? []).length < 2)
458
+ return `input.prompt ${JSON.stringify(q.prompt.trim())} is not a question the person can answer, so nothing was shown. Ask only when the run needs the person's answer, with a prompt that says what they answer or agree to; with nothing to ask, go on`;
442
459
  if (q.sensitive && q.kind !== 'text')
443
460
  return 'input.sensitive is for text questions only';
444
461
  if (q.prefill !== undefined && !(q.kind === 'account' || (q.kind === 'text' && !q.sensitive)))
@@ -1738,36 +1755,43 @@ export function createTools(ctx) {
1738
1755
  const accountOf = new Map();
1739
1756
  /** The Vercel project the bypass tool read for each address its secret opened, and its team where that is not the CLI's current scope. */
1740
1757
  const vercelNamed = new Map();
1741
- /** What the last screen says the run used on each host, from the previews it opened. */
1742
- const hostsUsed = () => [...usedPreviews].flatMap((url) => {
1743
- const origin = new URL(url).origin;
1744
- const label = pagesProject(origin);
1745
- if (label)
1746
- return [{ host: 'Cloudflare', project: label, ...(accountOf.has(`cloudflare ${label}`) ? { account: accountOf.get(`cloudflare ${label}`) } : {}) }];
1747
- const worker = /^https:\/\/\{branch\}-([^.]+)\./.exec(workerPatterns.get(origin) ?? '')?.[1];
1748
- if (worker)
1749
- return [{ host: 'Cloudflare', project: worker }];
1750
- const site = netlifySiteOf(url);
1751
- if (site)
1752
- return [{ host: 'Netlify', project: site, ...(accountOf.has(`netlify ${site}`) ? { account: accountOf.get(`netlify ${site}`) } : {}) }];
1753
- const render = renderSiteOf(url);
1754
- if (render)
1755
- return [{ host: 'Render', project: render.site }];
1756
- const read = vercelNamed.get(origin);
1757
- const named = read?.project ?? listedApps.get(origin)?.name;
1758
- const vercel = vercelDeploymentOf(url, named);
1759
- const team = vercel?.team ?? read?.team;
1760
- const project = vercel?.project ?? named;
1761
- return project ? [{ host: 'Vercel', project, ...(team ? { account: team } : {}) }] : [];
1762
- });
1758
+ /** The Vercel projects impeccable_vercel_connect took as this run's, by name, each with its team ('' for the personal scope): the last screen names them however the run ended, as the report does (REN-1250). */
1759
+ const vercelSetUp = new Map();
1760
+ /** What the last screen says the run used on each host: the Vercel projects it set up, and the previews it opened. */
1761
+ const hostsUsed = () => [...[...vercelSetUp].map(([project, team]) => ({ host: 'Vercel', project, ...(team ? { account: team } : {}) })), ...[...usedPreviews].flatMap((url) => {
1762
+ const origin = new URL(url).origin;
1763
+ const label = pagesProject(origin);
1764
+ if (label)
1765
+ return [{ host: 'Cloudflare', project: label, ...(accountOf.has(`cloudflare ${label}`) ? { account: accountOf.get(`cloudflare ${label}`) } : {}) }];
1766
+ const worker = /^https:\/\/\{branch\}-([^.]+)\./.exec(workerPatterns.get(origin) ?? '')?.[1];
1767
+ if (worker)
1768
+ return [{ host: 'Cloudflare', project: worker }];
1769
+ const site = netlifySiteOf(url);
1770
+ if (site)
1771
+ return [{ host: 'Netlify', project: site, ...(accountOf.has(`netlify ${site}`) ? { account: accountOf.get(`netlify ${site}`) } : {}) }];
1772
+ const render = renderSiteOf(url);
1773
+ if (render)
1774
+ return [{ host: 'Render', project: render.site }];
1775
+ const read = vercelNamed.get(origin);
1776
+ const named = read?.project ?? listedApps.get(origin)?.name;
1777
+ const vercel = vercelDeploymentOf(url, named);
1778
+ const team = vercel?.team ?? read?.team;
1779
+ const project = vercel?.project ?? named;
1780
+ return project ? [{ host: 'Vercel', project, ...(team ? { account: team } : {}) }] : [];
1781
+ })];
1782
+ /** The builds, by what was waited for (`key`), whose wait ran out after its retries or that the person stopped waiting for: read again, never waited for again (REN-1250). */
1783
+ const buildsGivenUp = new Set();
1763
1784
  /**
1764
1785
  * A host's build, waited for on the screen as the task in progress, naming `host`, with the time so far: `read` again
1765
1786
  * 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.
1787
+ * A build whose wait (`key`) ran out or was skipped earlier in the run is read once and not waited for again.
1766
1788
  */
1767
- async function awaitBuild(host, status, read, built, page, signal, access) {
1789
+ async function awaitBuild(host, key, status, read, built, page, signal, access) {
1768
1790
  const first = await read();
1769
1791
  if (built(first))
1770
1792
  return first;
1793
+ if (buildsGivenUp.has(key))
1794
+ return { ...first, reason: `No second wait: ${host}'s build was waited for already in this run, until its retries ran out or the person skipped them, and it is still not built. There is no preview of it, as "No preview, no test drive" says.` };
1771
1795
  const wait = ctx.previewWait ?? { totalMs: 300_000, everyMs: 15_000 };
1772
1796
  const waited = async (evidence) => {
1773
1797
  said(status);
@@ -1784,19 +1808,21 @@ export function createTools(ctx) {
1784
1808
  };
1785
1809
  // 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
1786
1810
  // 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.
1787
- if (access) {
1788
- return onHostPage(`${host.toLowerCase()}-app-access`, () => access.page, (e) => !built(e), access.now ? first : await waited(first), async () => waited(await read()), {
1811
+ const given = access
1812
+ ? await onHostPage(`${host.toLowerCase()}-app-access`, () => access.page, (e) => !built(e), access.now ? first : await waited(first), async () => waited(await read()), {
1789
1813
  skipped: `The person skipped giving ${host}'s GitHub app access to the repository.`,
1790
1814
  declines: { app: access.app },
1791
1815
  still: `${host}'s GitHub app still cannot see the repository: no build came.`,
1792
1816
  stopped: `The run stopped while ${host}'s build was waited for.`,
1817
+ }, signal)
1818
+ : await onHostPage(`${host.toLowerCase()}-build`, page, (e) => !built(e), await waited(first), async () => waited(await read()), {
1819
+ skipped: `The person stopped waiting for ${host}'s build.`,
1820
+ still: `${host} built no preview in the time.`,
1821
+ stopped: `The run stopped while ${host}'s build was waited for.`,
1793
1822
  }, signal);
1794
- }
1795
- return onHostPage(`${host.toLowerCase()}-build`, page, (e) => !built(e), await waited(first), async () => waited(await read()), {
1796
- skipped: `The person stopped waiting for ${host}'s build.`,
1797
- still: `${host} built no preview in the time.`,
1798
- stopped: `The run stopped while ${host}'s build was waited for.`,
1799
- }, signal);
1823
+ if (!built(given) && !signal?.aborted)
1824
+ buildsGivenUp.add(key);
1825
+ return given;
1800
1826
  }
1801
1827
  /** What a wait for a pull request's build is for: the test drive's preview, or another pull request's. */
1802
1828
  const subjectOf = async (pull) => {
@@ -1922,7 +1948,7 @@ export function createTools(ctx) {
1922
1948
  * it back (`turnOn`); where the host kept it, the person changes it on the host's page and the run reads again, twice at most.
1923
1949
  */
1924
1950
  const turnOnPreviews = async (host, turnOn, signal) => {
1925
- said(`Turning on pull-request previews on ${{ netlify: 'Netlify', cloudflare: 'Cloudflare Pages', render: 'Render' }[host]}`);
1951
+ said(host === 'vercel' ? `Turning on ${DEPLOYMENT_EVENTS} on Vercel` : `Turning on pull-request previews on ${{ netlify: 'Netlify', cloudflare: 'Cloudflare Pages', render: 'Render' }[host]}`);
1926
1952
  for (let tries = 0;; tries++) {
1927
1953
  const outcome = await turnOn();
1928
1954
  if (outcome.fact.enabled === true) {
@@ -2123,9 +2149,11 @@ export function createTools(ctx) {
2123
2149
  */
2124
2150
  async function hostSignIn(host, pi, signal) {
2125
2151
  const cli = hostCli(host, pi, signal);
2152
+ /** The team, read once the CLI is signed in: before the setup screen, which names it, where it was signed in already. */
2153
+ let teamRead;
2126
2154
  const signedIn = async () => {
2127
2155
  agreeToHostSetup(ctx.log, `${host}-setup`);
2128
- const { team, choices } = await cli.team();
2156
+ const { team, choices } = await (teamRead ?? cli.team());
2129
2157
  if (team)
2130
2158
  chooseHostTeam(ctx.log, `${host}-team`, team);
2131
2159
  teamChoices.set(host, choices);
@@ -2135,7 +2163,13 @@ export function createTools(ctx) {
2135
2163
  return { skipped: true };
2136
2164
  let status = await cli.status();
2137
2165
  if (!hostSignIns.has(host)) {
2138
- const answer = await ask({ id: `${host}-setup`, ...HOST_QUESTIONS[`${host}-setup`], ...(status.cli === 'signed-in' ? HOST_SIGNED_IN[`${host}-setup`] : {}) });
2166
+ // Signed in already, Vercel's screen names the account, the new project and the team it goes into, which the yes is to (REN-1250).
2167
+ let words = status.cli === 'signed-in' ? HOST_SIGNED_IN[`${host}-setup`] : undefined;
2168
+ if (status.cli === 'signed-in' && host === 'vercel' && status.user) {
2169
+ const { team, choices } = await (teamRead = cli.team());
2170
+ words = vercelSignedIn(status.user, { team, pick: choices.length > 1, project: vercelProjectName(pi.cwd) });
2171
+ }
2172
+ const answer = await ask({ id: `${host}-setup`, ...HOST_QUESTIONS[`${host}-setup`], ...words });
2139
2173
  if (answer.skipped) {
2140
2174
  endSetup(host);
2141
2175
  return { skipped: true };
@@ -2280,6 +2314,8 @@ export function createTools(ctx) {
2280
2314
  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.` };
2281
2315
  }
2282
2316
  }
2317
+ if (project.name)
2318
+ vercelSetUp.set(project.name, own.team);
2283
2319
  // Tried again every 20 s until the app reaches the repository: the person is giving it access meanwhile.
2284
2320
  const result = await hostAccess('vercel', await connectVercelProject(vercel), () => waitForVercelAccess(vercel, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 20_000 }, signal }), signal, connectedApp);
2285
2321
  // 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.
@@ -2288,6 +2324,12 @@ export function createTools(ctx) {
2288
2324
  settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'connection' });
2289
2325
  return result;
2290
2326
  }
2327
+ // Vercel records its deployments on GitHub, where the wait finds the test drive's, only with deployment_status Events on, and a project the CLI creates starts with it off (REN-1248).
2328
+ const created = Boolean(project.name && vercelProjectsAdded(ctx.log).has(`${own.team}/${project.name.toLowerCase()}`));
2329
+ const events = project.githubDeployments ? undefined : await vercelDeploymentsOn(vercel, link, project.name, !created, connectedApp, signal);
2330
+ // The person's no ends the setup: nothing is pushed, so the hold stays and nothing more changes on the project.
2331
+ if (events?.status === 'skipped')
2332
+ return { ...result, deploymentEvents: events };
2291
2333
  let first = await prepareFirstDeployment(vercel, link, repository);
2292
2334
  vercelProject = first.project ?? vercelProject;
2293
2335
  // Vercel refused the lock: the person turns it on in the project's settings, in this run, and the read sees it.
@@ -2303,7 +2345,36 @@ export function createTools(ctx) {
2303
2345
  }
2304
2346
  if (first.production === 'exists' || first.locked === true)
2305
2347
  settleVercelFirstDeployment(ctx.log, { projectId: link.projectId, how: 'project' });
2306
- return { ...result, ...first };
2348
+ return { ...result, ...first, ...(events ? { deploymentEvents: events } : {}) };
2349
+ }
2350
+ /** What impeccable_hosted_previews last read of each app's Vercel project ('' for a repository of one app): the fact impeccable_enable_previews acts on for that app. */
2351
+ const vercelFacts = new Map();
2352
+ /** What turning on deployment_status Events answered, by Vercel project: asked once, so that answer stands. */
2353
+ const deploymentEventsOutcomes = new Map();
2354
+ /**
2355
+ * The project's deployment_status Events, turned on as the person through the CLI where they are off: at once for a
2356
+ * project this run created, after the one consent screen (`consent`) for any other. Where Vercel refuses the run, the
2357
+ * person turns it on on the project's Settings → Git and the run reads again. A skip is a no that ends the setup.
2358
+ */
2359
+ async function vercelDeploymentsOn(vercel, link, project, consent, app, signal) {
2360
+ const standing = deploymentEventsOutcomes.get(link.projectId);
2361
+ if (standing?.status === 'skipped')
2362
+ decline('no-previews', app);
2363
+ if (standing)
2364
+ return standing;
2365
+ const answer = (status, more) => {
2366
+ const told = { status, ...(project ? { project } : {}), ...more };
2367
+ deploymentEventsOutcomes.set(link.projectId, told);
2368
+ if (status === 'skipped')
2369
+ decline('no-previews', app);
2370
+ return told;
2371
+ };
2372
+ const screen = HOST_SCREENS['vercel-previews'];
2373
+ if (consent && (await ask({ ...screen, prompt: `Turn on ${DEPLOYMENT_EVENTS} for ${project ?? 'the Vercel project'}?` })).skipped)
2374
+ return answer('skipped', { reason: `The person skipped turning on ${DEPLOYMENT_EVENTS}.` });
2375
+ const page = await vercelPageFor(vercel, link, 'settings/git', project);
2376
+ const { status, ...more } = await turnOnPreviews('vercel', () => enableGithubDeployments(vercel, link, repository, page), signal);
2377
+ return answer(status, more);
2307
2378
  }
2308
2379
  async function syncWorkflowTool(pi, signal) {
2309
2380
  try {
@@ -2899,7 +2970,7 @@ export function createTools(ctx) {
2899
2970
  // A test account never comes back to the model, even if the API echoed it.
2900
2971
  return testAccounts ? redact(answerText(response), typed.values()) : answerText(response);
2901
2972
  }),
2902
- tool('impeccable_ask', 'Ask the person', 'Ask the person one question in a dialog, and wait for the answer; the web page shows the run waiting meanwhile. Answers {"value": "…"} for text and single, {"values": […]} for multi, {"confirmed": true} for confirm, {"username": "…", "password": "secret:…"} for account, or {"skipped": true}. A host\'s setup question (id "vercel-setup", "vercel-team", "netlify-setup" or "netlify-team") is worded by this tool: give it that id and its kind. So is the apps question on a repository with several apps (id "apps", kind "multi"), which lists the state\'s apps itself, has no Skip, and answers {"values": […]} (at least one app) or {"values": [key], "named": true} when the command was started for that app (it shows one confirm screen naming the app instead of the list, and the list when the person asks for other apps there); where no answer comes, nothing is written and the run ends. netlify-setup and vercel-setup are the host\'s sign-in: it shows its screen only when that CLI is signed out, signs the person in through the browser with a retry, and answers {"confirmed": true, "signedIn": true}, with "team" (the one the project goes into) or "teams" to choose from when the person has several, {"confirmed": true, "cli": "missing"} when the CLI is to be installed first and this asked again, or {"skipped": true}.', ASK, async (p, pi, signal) => {
2973
+ tool('impeccable_ask', 'Ask the person', 'Ask the person one question in a dialog, and wait for the answer; the web page shows the run waiting meanwhile. Ask only when the run needs their answer, with a prompt that says what they answer or agree to: it refuses one of fewer than two words. Answers {"value": "…"} for text and single, {"values": […]} for multi, {"confirmed": true} for confirm, {"username": "…", "password": "secret:…"} for account, or {"skipped": true}. A host\'s setup question (id "vercel-setup", "vercel-team", "netlify-setup" or "netlify-team") is worded by this tool: give it that id and its kind. So is the apps question on a repository with several apps (id "apps", kind "multi"), which lists the state\'s apps itself, has no Skip, and answers {"values": […]} (at least one app) or {"values": [key], "named": true} when the command was started for that app (it shows one confirm screen naming the app instead of the list, and the list when the person asks for other apps there); where no answer comes, nothing is written and the run ends. netlify-setup and vercel-setup are the host\'s sign-in: it shows its screen only when that CLI is signed out, signs the person in through the browser with a retry, and answers {"confirmed": true, "signedIn": true}, with "team" (the one the project goes into) or "teams" to choose from when the person has several, {"confirmed": true, "cli": "missing"} when the CLI is to be installed first and this asked again, or {"skipped": true}.', ASK, async (p, pi, signal) => {
2903
2974
  // A skip never stops the run, so every question offers one, but the apps question, which needs at least one app (#405).
2904
2975
  const { appKey: forApp, ...asked } = p;
2905
2976
  if (asked.id === APPS_QUESTION.id && appsRepo)
@@ -3145,7 +3216,7 @@ export function createTools(ctx) {
3145
3216
  if (!left.includes('Watch the review on the test drive'))
3146
3217
  left.push('Watch the review on the test drive');
3147
3218
  }
3148
- const { phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore } = ctx.sync.payload;
3219
+ const { phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore, ended: endedBefore } = ctx.sync.payload;
3149
3220
  // Why an app was not proven or keyed goes with its summary only as plain words, with no address (a scheme, or a host with a path or query) and nothing shaped like a secret; anything else is dropped.
3150
3221
  const appsBefore = new Map(appSummaries);
3151
3222
  // Each app whose setup ended on the person's no says so on the web page, unless the report gives its own words below.
@@ -3170,11 +3241,11 @@ export function createTools(ctx) {
3170
3241
  // The list as the run left the apps: a Preview method done for some apps only says whose (REN-1108).
3171
3242
  const tasks = taskOrder.sort(labelled(ctx.sync.payload.tasks));
3172
3243
  reportTasks(tasks);
3173
- ctx.sync.update({ tasks, phase: 'completed', status: ended ? SETUP_ENDS[ended].title : 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, ...(several ? { test_prs: Object.fromEntries(kept) } : {}), left: left.slice(0, 20) });
3244
+ ctx.sync.update({ tasks, phase: 'completed', status: ended ? SETUP_ENDS[ended].title : 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, ...(several ? { test_prs: Object.fromEntries(kept) } : {}), left: left.slice(0, 20), ended });
3174
3245
  const res = await ctx.sync.flush();
3175
3246
  if (res && res.status >= 400 && res.status < 500) {
3176
3247
  // A definite no: the run is still open on the server, so the session goes back to what it was and the agent can send the report again.
3177
- ctx.sync.update({ phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore });
3248
+ ctx.sync.update({ phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore, ended: endedBefore });
3178
3249
  // Its reasons go with it: the report sent again gives its own.
3179
3250
  if (appsBefore.size) {
3180
3251
  for (const [key, was] of appsBefore)
@@ -3190,7 +3261,7 @@ export function createTools(ctx) {
3190
3261
  const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
3191
3262
  return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
3192
3263
  }),
3193
- 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) => {
3264
+ 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, three times at most; once those ran out or the person skipped, another call for it reads once and waits no more. 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. Where GitHub records no deployment of Vercel\'s for that head (the project\'s deployment_status Events off), Vercel\'s commit status on it ends the wait at once: the deployment it names is listed, read from Vercel, and githubDeployments false with reason says what Vercel did (built, failed or skipped). It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, pi, signal) => {
3194
3265
  const branch = p.branch;
3195
3266
  const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
3196
3267
  if (refused)
@@ -3227,21 +3298,44 @@ export function createTools(ctx) {
3227
3298
  return named !== undefined ? named === app.name : projectMadeDeployment(vercel, app.link, app.project, new URL(d.url).host);
3228
3299
  };
3229
3300
  const read = async () => {
3230
- const evidence = await raw();
3231
- if (!app)
3232
- return evidence;
3233
- const kept = await Promise.all(evidence.deployments.map(owned));
3234
- return { ...evidence, deployments: evidence.deployments.filter((_, i) => kept[i]) };
3301
+ const { vercelStatuses, ...evidence } = await raw();
3302
+ const kept = app ? await Promise.all(evidence.deployments.map(owned)) : [];
3303
+ const mine = app ? evidence.deployments.filter((_, i) => kept[i]) : evidence.deployments;
3304
+ // Vercel reported the head's build only as its commit status, as it does with deployment_status Events off (REN-1248), where GitHub lists no deployment of
3305
+ // Vercel's of this project: the deployment the status names, read from Vercel as the person, stands for the one GitHub never got.
3306
+ if (branch === undefined || !vercelStatuses?.length || mine.some((d) => d.vercel))
3307
+ return { ...evidence, deployments: mine };
3308
+ const linked = app ? app.link : await readLink(pi.cwd);
3309
+ const own = !app && linked ? await readOwnProject(vercel, linked, repository) : null;
3310
+ const name = app?.name ?? (own && !('reason' in own) ? own.name : undefined) ?? vercelProject;
3311
+ const statuses = [];
3312
+ const made = [];
3313
+ for (const s of vercelStatuses) {
3314
+ // A context names its project; the one plain "Vercel" status may be any project's, and the deployment it names tells.
3315
+ if (s.context !== 'Vercel' && (!name || s.context !== `Vercel – ${name}`))
3316
+ continue;
3317
+ const d = linked && s.targetUrl && (s.state === 'built' || s.state === 'failed') ? await deploymentOfStatus(vercel, linked, s.targetUrl, s.sha) : null;
3318
+ if (d === 'other')
3319
+ continue;
3320
+ // Unread, the plain one is this project's only where its page in Vercel's dashboard, vercel.com/<scope>/<project>/<id>, names this project.
3321
+ if (!d && s.context === 'Vercel' && (!name || s.targetUrl?.split('/')[4] !== name))
3322
+ continue;
3323
+ statuses.push(s);
3324
+ if (d)
3325
+ made.push({ sha: s.sha, environment: `${d.production ? 'Production' : 'Preview'} – ${d.name || name || ''}`, state: s.state === 'built' ? 'success' : 'failure', url: d.url, vercel: true, pullRequests: [] });
3326
+ }
3327
+ return { ...evidence, deployments: [...mine, ...made], ...(statuses.length ? { vercelStatuses: statuses } : {}) };
3235
3328
  };
3236
3329
  // 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
3237
3330
  // project is its Production one, even from a branch. Another integration's deployment of the same commit says nothing about it. Without it a preview is a Preview.
3238
3331
  const isPreview = (d) => /preview/i.test(d.environment ?? '');
3239
3332
  const ofBranch = (d) => (branch !== undefined ? d.vercel : isPreview(d));
3240
3333
  // A head's build is still building until it has a final status.
3241
- const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => ofBranch(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
3334
+ // Vercel's commit status settles it too, so a build GitHub will never record a deployment of ends the wait at once.
3335
+ const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => ofBranch(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error')) || Boolean(evidence.vercelStatuses?.some((s) => s.state !== 'building'));
3242
3336
  // The project's Git settings, for the screen a build that never comes opens: the app's project's own page, else the linked project's.
3243
3337
  const gitPage = branch !== undefined ? await vercelPageFor(vercel, app ? app.link : await readLink(pi.cwd), 'settings/git', app?.name ?? vercelProject) : '';
3244
- const evidence = branch !== undefined ? await awaitBuild('Vercel', `Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, () => gitPage, signal) : await read();
3338
+ const evidence = branch !== undefined ? await awaitBuild('Vercel', `vercel ${appKey ?? ''} ${branch}`, `Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, () => gitPage, signal) : await read();
3245
3339
  /** The project the evidence is of: the app's, else the one this folder is linked to. */
3246
3340
  const link = async () => (app ? app.link : readLink(pi.cwd));
3247
3341
  /** The hosts of the linked project's deployments while it is locked at every address, read from Vercel once a Production deployment turns up, and once per call. */
@@ -3279,6 +3373,7 @@ export function createTools(ctx) {
3279
3373
  const fact = await (ctx.vercelPreviewsReader ?? vercelPreviewsFact)(pi.cwd, `${ctx.owner}/${ctx.repo}`, () => vercel, app ? { directory: app.folder, link: app.link } : undefined);
3280
3374
  if (fact) {
3281
3375
  previewFacts.set('vercel', fact);
3376
+ vercelFacts.set(appKey ?? '', fact);
3282
3377
  evidence.pullRequestPreviews = fact;
3283
3378
  }
3284
3379
  }
@@ -3295,9 +3390,12 @@ export function createTools(ctx) {
3295
3390
  }
3296
3391
  if (app?.name)
3297
3392
  noteApp(appKey, { host_project: app.name });
3298
- return JSON.stringify({ ...(appKey !== undefined ? { appKey, project: app.name } : {}), ...evidence, ...(production ? { production } : {}) });
3393
+ // What Vercel's commit status says, where GitHub records no deployment of Vercel's for the head: the cause in Vercel's own words, never "no build".
3394
+ const told = branch !== undefined && evidence.vercelStatuses?.length ? statusReason(branch, evidence.vercelStatuses, evidence.deployments.some((d) => d.vercel && d.state === 'success' && d.url)) : undefined;
3395
+ const { vercelStatuses: _statuses, ...shown } = evidence;
3396
+ return JSON.stringify({ ...(appKey !== undefined ? { appKey, project: app.name } : {}), ...shown, ...(production ? { production } : {}), ...(told ? { githubDeployments: false, reason: told } : {}) });
3299
3397
  }),
3300
- 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 (from the setup state's hosting record where it lists them or carries readAt, else read from GitHub here), 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) => {
3398
+ 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 (from the setup state's hosting record where it lists them or carries readAt, else read from GitHub here), 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, three times at most; once those ran out or the person skipped, another call for it reads once and waits no more. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
3301
3399
  const pull = p.pull;
3302
3400
  const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
3303
3401
  if (refused)
@@ -3316,7 +3414,7 @@ export function createTools(ctx) {
3316
3414
  // A pull request's preview is still building until Netlify's deploy of it is built or has failed.
3317
3415
  const states = ['success', 'ready', 'failure', 'error'];
3318
3416
  const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
3319
- const evidence = pull !== undefined ? await awaitBuild('Netlify', `Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, (e) => {
3417
+ const evidence = pull !== undefined ? await awaitBuild('Netlify', `netlify ${appKey ?? ''} #${pull}`, `Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, (e) => {
3320
3418
  // The waited pull request's own site; else the one site known, so another app's site never opens.
3321
3419
  const known = new Set([...e.sites, ...(netlifySite ? [netlifySite] : [])]);
3322
3420
  const site = e.previews.find((preview) => preview.pullRequest === pull)?.site ?? (known.size === 1 ? [...known][0] : undefined);
@@ -3349,7 +3447,7 @@ export function createTools(ctx) {
3349
3447
  noteApp(appKey, { host_project: site });
3350
3448
  return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
3351
3449
  }),
3352
- 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 (from the setup state's hosting record where it lists them or carries readAt, else read from GitHub here), or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in; where cf is not installed here, the person is asked to install it first, on its own screen). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds); where its branch builds make previews, they are listed too, on workers.dev (the alias https://<branch>-<worker>.<subdomain>.workers.dev, with the pattern it stands for), and with no Pages project production is the Worker's own address; 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) => {
3450
+ 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 (from the setup state's hosting record where it lists them or carries readAt, else read from GitHub here), or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in; where cf is not installed here, the person is asked to install it first, on its own screen). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds); where its branch builds make previews, they are listed too, on workers.dev (the alias https://<branch>-<worker>.<subdomain>.workers.dev, with the pattern it stands for), and with no Pages project production is the Worker's own address; 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, three times at most; once those ran out or the person skipped, another call for it reads once and waits no more; 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) => {
3353
3451
  const pull = p.pull;
3354
3452
  const branch = p.branch;
3355
3453
  if (pull !== undefined && branch !== undefined)
@@ -3385,7 +3483,7 @@ export function createTools(ctx) {
3385
3483
  // unless it covers them all, or Cloudflare built a preview of the repository already, which it could not without the app.
3386
3484
  const app = cloudflareEvidence?.githubApp;
3387
3485
  const access = branch !== undefined && previewFacts.get('cloudflare')?.enabled === true && app?.repositories !== 'all' && !cloudflareEvidence?.previews.length ? { page: app?.page ?? CLOUDFLARE_APP_PAGE, now: app?.installed === false, ...(typeof p.appKey === 'string' ? { app: p.appKey } : {}) } : undefined;
3388
- let waited = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare ${pattern ? 'Workers' : 'Pages'} to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal, access) : await read();
3486
+ let waited = subject ? await awaitBuild('Cloudflare', `cloudflare ${typeof p.appKey === 'string' ? p.appKey : ''} ${branch ?? `#${pull}`}`, `Waiting for Cloudflare ${pattern ? 'Workers' : 'Pages'} to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal, access) : await read();
3389
3487
  // cf not on this machine (#396): its install is asked before anything installs it, and the evidence read again with it.
3390
3488
  if (!subject && waited.cli === 'missing') {
3391
3489
  await installCf(pi, signal);
@@ -3521,7 +3619,7 @@ export function createTools(ctx) {
3521
3619
  return JSON.stringify({ ...proof, appKey: app.appKey });
3522
3620
  return JSON.stringify(proof);
3523
3621
  }),
3524
- tool('impeccable_render_previews', 'Render previews', "Read whether Render builds this repository's pull requests, on a repository with several apps for the app's own folder (appKey): the Render site, from the render CLI signed in on this machine (the service built from this repository, in the app's folder by its Root Directory), else the one site the setup state's hosting record names for the app (Render's deployments, its live address), else the one Render's deployments on GitHub and the checkout's files name (on a repository with several apps, a file in the app's own folder, or a line of the root README naming that app and no other); its previews as GitHub records them (from the state's record where it lists them or carries readAt, else read from GitHub here), each with its pull request, its address and its state (a closed pull request's address too, read from its earlier successful status, though Render took that preview down; pullState closed marks one the record knows is closed, and impeccable_prove_preview refuses it); production, the site's own address, for the before; pattern, the address every pull request's preview has; and pullRequestPreviews (enabled false, with the cause, where the service's previews are off or manual; 'unknown' without the CLI's answer). cli says whether the render CLI answered (signed-in), is missing, could not list the services (unread, with the reason), or lists none of this repository's (none). Once a site is found, it shows the person one screen naming Render, with the site's address to click, asked once for each app: confirmed true, or false when the person skipped it, which is a no that ends the setup for that app. Where the CLI says the previews are off, that screen is also the consent to turning them on, and on the yes the CLI turns them on and reads them back, with a retry screen on Render's page where it could not: turnedOn in the answer says how that ended (enabled, by run or person; skipped; refused, with the reason and the page). With pull (a pull request of this run, a draft included: Render builds the preview as soon as a pull request opens), only that pull request's preview of the site the plain read found, waiting up to five minutes for it to finish building, under a status line with its timer, and again on the person's yes when the time ran out. Never asks for an address.", RENDER_PREVIEWS, async (p, pi, signal) => {
3622
+ tool('impeccable_render_previews', 'Render previews', "Read whether Render builds this repository's pull requests, on a repository with several apps for the app's own folder (appKey): the Render site, from the render CLI signed in on this machine (the service built from this repository, in the app's folder by its Root Directory), else the one site the setup state's hosting record names for the app (Render's deployments, its live address), else the one Render's deployments on GitHub and the checkout's files name (on a repository with several apps, a file in the app's own folder, or a line of the root README naming that app and no other); its previews as GitHub records them (from the state's record where it lists them or carries readAt, else read from GitHub here), each with its pull request, its address and its state (a closed pull request's address too, read from its earlier successful status, though Render took that preview down; pullState closed marks one the record knows is closed, and impeccable_prove_preview refuses it); production, the site's own address, for the before; pattern, the address every pull request's preview has; and pullRequestPreviews (enabled false, with the cause, where the service's previews are off or manual; 'unknown' without the CLI's answer). cli says whether the render CLI answered (signed-in), is missing, could not list the services (unread, with the reason), or lists none of this repository's (none). Once a site is found, it shows the person one screen naming Render, with the site's address to click, asked once for each app: confirmed true, or false when the person skipped it, which is a no that ends the setup for that app. Where the CLI says the previews are off, that screen is also the consent to turning them on, and on the yes the CLI turns them on and reads them back, with a retry screen on Render's page where it could not: turnedOn in the answer says how that ended (enabled, by run or person; skipped; refused, with the reason and the page). With pull (a pull request of this run, a draft included: Render builds the preview as soon as a pull request opens), only that pull request's preview of the site the plain read found, waiting up to five minutes for it to finish building, under a status line with its timer, and again on the person's yes when the time ran out, three times at most; once those ran out or the person skipped, another call for it reads once and waits no more. Never asks for an address.", RENDER_PREVIEWS, async (p, pi, signal) => {
3525
3623
  const pull = p.pull;
3526
3624
  const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
3527
3625
  if (refused)
@@ -3564,7 +3662,7 @@ export function createTools(ctx) {
3564
3662
  return { previews: previewsOf(evidence, known.site), ...(evidence.error ? { error: evidence.error } : {}) };
3565
3663
  };
3566
3664
  const final = (e) => Boolean(e.error) || e.previews.some((preview) => preview.state === 'success' || preview.state === 'failure' || preview.state === 'error');
3567
- const waited = await awaitBuild('Render', `Waiting for Render to build ${await subjectOf(pull)}`, read, final, () => known.page, signal);
3665
+ const waited = await awaitBuild('Render', `render ${appKey ?? ''} #${pull}`, `Waiting for Render to build ${await subjectOf(pull)}`, read, final, () => known.page, signal);
3568
3666
  list(waited.previews);
3569
3667
  for (const origin of renderListed)
3570
3668
  renderBuilt.add(origin);
@@ -3654,7 +3752,26 @@ export function createTools(ctx) {
3654
3752
  noteApp(appKey, { host_project: result.site.name });
3655
3753
  return JSON.stringify(appKey !== undefined ? { appKey, ...result } : result);
3656
3754
  }),
3657
- 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) => {
3755
+ 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, impeccable_cloudflare_previews or impeccable_hosted_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; Vercel, with cause previews: deployment_status Events, which records Vercel's deployments on GitHub, where Impeccable finds them) 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). A vercel.json rule that turns Vercel's builds off is the repository's own, and is never changed here.", ENABLE_PREVIEWS, async (p, pi, signal) => {
3756
+ if (p.host === 'vercel') {
3757
+ // The project the fact was read of: the app's own on a repository with several apps, else the one this folder is linked to.
3758
+ const fact = vercelFacts.get(appsRepo ? p.appKey : '');
3759
+ const off = fact?.enabled === false && fact.cause === 'previews';
3760
+ const unread = `Refused: this is for a Vercel project whose pullRequestPreviews said enabled false with cause previews (${DEPLOYMENT_EVENTS} off) in this run. Read impeccable_hosted_previews first; a vercel.json rule is the repository's own to change.`;
3761
+ if (!off && !deploymentEventsOutcomes.size)
3762
+ return unread;
3763
+ const vercel = hostRunner('vercel', pi.cwd, { signal });
3764
+ const app = appsRepo ? await vercelApp(pi, p.appKey, vercel) : null;
3765
+ if (app && 'reason' in app)
3766
+ return JSON.stringify({ appKey: p.appKey, status: 'refused', reason: app.reason });
3767
+ const link = app ? app.link : await readLink(pi.cwd);
3768
+ if (!link)
3769
+ return 'Refused: this folder is linked to no Vercel project (.vercel/project.json), so which project to change cannot be told.';
3770
+ // The answer stands for the project, as before.
3771
+ if (!off && !deploymentEventsOutcomes.has(link.projectId))
3772
+ return unread;
3773
+ return JSON.stringify(await vercelDeploymentsOn(vercel, link, app ? app.name : fact?.project || undefined, true, p.appKey, signal));
3774
+ }
3658
3775
  const host = p.host;
3659
3776
  const standing = enableOutcomes.get(host);
3660
3777
  // The answer stands for the run, as before; its no ends the setup only of an app on the project it was for.
@@ -3755,7 +3872,7 @@ export function createTools(ctx) {
3755
3872
  cloudflareEvidence = { ...evidence, project, ...(outcome.fact ? { pullRequestPreviews: outcome.fact } : {}) };
3756
3873
  return answer('connected', { created: outcome.created, ...(outcome.subdomain ? { subdomain: outcome.subdomain } : {}), ...(outcome.productionBranch ? { productionBranch: outcome.productionBranch } : {}) });
3757
3874
  }),
3758
- 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) => {
3875
+ 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; deploymentEvents, present where the project's deployment_status Events was off (Vercel then records no deployment on GitHub, where Impeccable finds its previews), says how turning it on as the person ended: at once for a project this run created, after one screen for any other; status enabled, skipped (the person's no, which ends the setup: push nothing) or refused (reason, and page where the person turns it on); 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) => {
3759
3876
  const refused = appRefusal(undefined, 'connected', false);
3760
3877
  if (refused)
3761
3878
  return refused;
@@ -3891,6 +4008,15 @@ export function createTools(ctx) {
3891
4008
  bypassConsented.add(`${store}/${link.projectId}`);
3892
4009
  return JSON.stringify({ ...of, protected: true, consent: 'given' });
3893
4010
  }
4011
+ // No proof will meet the login now, so the web page learns it from here, as a proof's would tell it (#365): at one of the project's own deployments, which the login covers (REN-1246).
4012
+ const deployed = [...project.deploymentHosts][0];
4013
+ if (deployed) {
4014
+ const met = { gate: 'vercel-protection', preview_host: deployed };
4015
+ if (app)
4016
+ noteApp(app.appKey, { ...met, preview_proven: false });
4017
+ else if (!appsRepo)
4018
+ ctx.sync.update({ preview_gate: met });
4019
+ }
3894
4020
  const outcome = noAccess({ status: 'skipped', ...of, protected: true, reason: `The person skipped creating a bypass secret before the test drive's push. Vercel's login stands in front of the previews of ${project.name ? `the Vercel project ${project.name}` : 'the Vercel project'}, so a test drive pushed now could not be reviewed. A secret made in the project's Settings → Deployment Protection → Protection Bypass for Automation, named "Impeccable reviews", and saved on the setup page lets reviews in.` });
3895
4021
  bypassOutcomes.set(store, outcome);
3896
4022
  return JSON.stringify(outcome);
package/dist/ui/look.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
2
- import { fitsTask, taskInProgress, workStarted } from '../stages.js';
2
+ import { fitsTask, stagesOf, taskInProgress, workStarted } from '../stages.js';
3
3
  /**
4
4
  * How the wizard's screens look, drawn as lines from what they show and the
5
5
  * terminal's size, with no terminal behind them: the smoke renders every
@@ -270,12 +270,17 @@ function runPage(scene, f, inner, st) {
270
270
  : '○';
271
271
  const ink = t.status === 'in_progress' || t.status === 'failed' ? st.full : t.status === 'skipped' ? st.faint : st.soft;
272
272
  const row = (detail) => `${mark} ${t.title}${detail ? ` · ${detail}` : ''}`;
273
- return { ink, row, line: t.status === 'skipped' || t.status === 'failed' ? row(t.status) : row() };
273
+ // Preview method back to pending while Test drive runs waits for the test drive's preview, which proves the method, as the playbook's Tasks rule says: its row says so, so its work never reads as undone (REN-1255).
274
+ const proving = t.started && t.status === 'pending' && running >= 0 && stagesOf(t.title).includes('Preview method') && stagesOf(scene.tasks[running].title).includes('Pull requests');
275
+ return { ink, row, line: t.status === 'skipped' || t.status === 'failed' ? row(t.status) : row(), proving };
274
276
  });
275
- // The list's left edge is set by the rows without the wait's words, so it stays put while a wait comes and goes (#390).
277
+ // The list's left edge is set by the rows without the wait's words or the method's, so it stays put while they come and go (#390).
276
278
  const widest = Math.max(...rows.map(({ line }) => visibleWidth(line)));
277
279
  const room = inner - Math.max(0, Math.floor((inner - Math.min(widest, inner)) / 2));
278
- const lines = rows.map(({ ink, row, line }, i) => {
280
+ const lines = rows.map(({ ink, row, line, proving }, i) => {
281
+ // The words that fit from the list's edge, shorter at a narrow width, and none where even those would be cut.
282
+ if (proving)
283
+ return ink(['finishes with the test drive', 'with the test drive'].map(row).find((words) => visibleWidth(words) <= room) ?? line);
279
284
  if (i !== running || !scene.wait)
280
285
  return ink(line);
281
286
  // The task in progress says what the run waits for and the time so far, never how long it may wait, so a screen
package/dist/vercel.js CHANGED
@@ -103,6 +103,63 @@ export const readDeploymentApi = (link, host) => ['api', `/v13/deployments/${enc
103
103
  * (https://vercel.com/docs/deployment-protection/methods-to-protect-deployments/vercel-authentication#how-to-manage-vercel-authentication-with-the-api).
104
104
  */
105
105
  export const lockProjectApi = (link) => projectApi(link, ['--field', 'ssoProtection={"deploymentType":"all"}']);
106
+ /**
107
+ * Vercel's deployments recorded on GitHub, which is where Impeccable finds Vercel's previews: Settings → Git →
108
+ * "deployment_status Events" in the dashboard, `gitProviderOptions.createDeployments` to the API. A project the CLI or
109
+ * the API creates starts with it disabled.
110
+ */
111
+ export const githubDeploymentsApi = (link) => projectApi(link, ['--field', 'gitProviderOptions={"createDeployments":"enabled"}']);
112
+ /** The dashboard's label for that setting, as a screen or a report names it. */
113
+ export const DEPLOYMENT_EVENTS = 'deployment_status Events';
114
+ /** Why Impeccable sees none of a project's previews while that setting is off. */
115
+ export const DEPLOYMENTS_OFF = `Vercel builds the project's pushes but records no deployment of them on GitHub, where Impeccable finds Vercel's previews: ${DEPLOYMENT_EVENTS} is off under the project's Settings → Git.`;
116
+ /**
117
+ * Turns on the project's deployments on GitHub through the CLI signed in as the person, where they are off, and reads the
118
+ * project back: enabled only once Vercel says so. The project counts only as this repository's (readOwnProject). `page`
119
+ * is the project's Settings → Git, where the person turns it on when Vercel refused the run.
120
+ */
121
+ export async function enableGithubDeployments(vercel, link, repository, page) {
122
+ const fact = (project) => 'reason' in project
123
+ ? { project: '', enabled: 'unknown', reason: project.reason }
124
+ : { project: project.name ?? '', enabled: project.githubDeployments, ...(project.githubDeployments ? {} : { cause: 'previews', reason: DEPLOYMENTS_OFF }) };
125
+ const before = await readOwnProject(vercel, link, repository);
126
+ if ('reason' in before || before.githubDeployments)
127
+ return { fact: fact(before), page, changed: false, ...('reason' in before ? { reason: before.reason } : {}) };
128
+ try {
129
+ await vercel(githubDeploymentsApi(link));
130
+ }
131
+ catch {
132
+ return { fact: fact(before), page, changed: false, reason: `Vercel refused to turn on ${DEPLOYMENT_EVENTS} for the project: that takes the Member role or Project Administrator.` };
133
+ }
134
+ const after = await readOwnProject(vercel, link, repository);
135
+ return { fact: fact(after), page, changed: !('reason' in after) && after.githubDeployments };
136
+ }
137
+ /**
138
+ * The deployment a commit status of Vercel's names, read through the CLI: its `target_url` is the deployment's page in
139
+ * Vercel's dashboard, `https://vercel.com/<scope>/<project>/<id>`, whose last part is the deployment's id less `dpl_`.
140
+ * Only a deployment of the linked project, of the commit `sha`, counts: `other` for another project's or commit's, null
141
+ * where it could not be read.
142
+ */
143
+ export async function deploymentOfStatus(vercel, link, targetUrl, sha) {
144
+ const id = /^https:\/\/vercel\.com\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/([A-Za-z0-9]{8,64})$/.exec(targetUrl)?.[1];
145
+ if (!id)
146
+ return null;
147
+ try {
148
+ const d = await printedProject(vercel, readDeploymentApi(link, `dpl_${id}`));
149
+ const host = typeof d?.url === 'string' && /^[a-z0-9.-]{1,253}$/i.test(d.url) ? d.url.toLowerCase() : undefined;
150
+ if (!d)
151
+ return null;
152
+ // Vercel named another project's deployment, or one of another commit: a status that is not this build's.
153
+ if (d.projectId !== link.projectId || record(d.meta)?.githubCommitSha !== sha)
154
+ return 'other';
155
+ if (!host)
156
+ return null;
157
+ return { url: `https://${host}`, name: typeof d.name === 'string' ? d.name : '', production: d.target === 'production' };
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
106
163
  const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
107
164
  const printedProject = async (vercel, args) => {
108
165
  const printed = await vercel(args);
@@ -268,7 +325,7 @@ export async function vercelPreviewsFact(cwd, repository, vercel, app) {
268
325
  if (own && 'reason' in own)
269
326
  return { ...fact, enabled: 'unknown', reason: own.reason };
270
327
  if (project && !project.githubDeployments)
271
- return { ...fact, enabled: false, reason: 'The project records no deployments on GitHub (Vercel GitHub deployments are off), and GitHub\'s record is where Impeccable finds Vercel\'s previews.' };
328
+ return { ...fact, enabled: false, cause: 'previews', reason: DEPLOYMENTS_OFF };
272
329
  if (project?.ignoredBuildStep)
273
330
  return { ...fact, enabled: 'unknown', reason: 'An Ignored Build Step is set on the project (Settings → Git), which decides commit by commit whether a push builds.' };
274
331
  return fact;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impeccable/reviews",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "description": "Set up a repository for Impeccable for PRs from your terminal.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "private": false,