@impeccable/reviews 0.1.30 → 0.1.32

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.
@@ -71,9 +71,9 @@ const when = (value) => (typeof value === 'string' && Number.isFinite(Date.parse
71
71
  /**
72
72
  * GitHub's record, read from the setup state's hosting record (hosting.ts) instead of GitHub: Cloudflare's check runs
73
73
  * and its bot's comments as Impeccable's copy holds them, a Workers Builds check's Worker among them; null where the
74
- * record lists none for the app, so GitHub is read here.
74
+ * record lists none for the app and is not `read` (hosting.ts `hostingRead`), so GitHub is read here.
75
75
  */
76
- export function cloudflareFromHosting(app) {
76
+ export function cloudflareFromHosting(app, read = false) {
77
77
  const previews = [];
78
78
  const heads = [];
79
79
  let worker;
@@ -100,7 +100,7 @@ export function cloudflareFromHosting(app) {
100
100
  if (!heads.some((h) => h.sha === p.sha))
101
101
  heads.push({ ...(p.pull !== null ? { number: p.pull } : {}), sha: p.sha, at });
102
102
  }
103
- return previews.length ? { previews, heads, failed: false, ...(worker ? { worker: { script: worker.script, build: worker.build } } : {}) } : null;
103
+ return previews.length || read ? { previews, heads, failed: false, ...(worker ? { worker: { script: worker.script, build: worker.build } } : {}) } : null;
104
104
  }
105
105
  /**
106
106
  * GitHub's record: the Cloudflare check run on each head (or, with `only`, that pull request's; with `branch`, the
package/dist/hosting.js CHANGED
@@ -27,12 +27,45 @@ export function hostingOf(state, repo) {
27
27
  if (!p || !sha || !source)
28
28
  return [];
29
29
  const pull = typeof p.pull === 'number' && Number.isSafeInteger(p.pull) && p.pull > 0 ? p.pull : null;
30
- return [{ pull, sha, url: address(p.url), alias: address(p.alias), state: text(p.state), at: text(p.at), source, environment: text(p.environment), site: text(p.site) }];
30
+ const pullState = p.pullState === 'open' || p.pullState === 'closed' ? p.pullState : null;
31
+ return [{ pull, sha, url: address(p.url), alias: address(p.alias), state: text(p.state), at: text(p.at), source, environment: text(p.environment), site: text(p.site), pullState }];
31
32
  });
32
33
  return [{ key, liveUrl: address(a.liveUrl), previews }];
33
34
  });
34
- return { apps };
35
+ const unread = Array.isArray(hosting.unread) ? hosting.unread.flatMap((u) => text(u) ?? []) : [];
36
+ return { apps, readAt: text(hosting.readAt), unread, ...(hosting.readPending === true ? { readPending: true } : {}) };
35
37
  }
38
+ /**
39
+ * The record once it is whole: while the state says a read of it is pending, the state is read again every few seconds,
40
+ * up to a minute by the clock, each read bounded by what is left of it. Past that, its previews are left out and it
41
+ * counts as never read, so each host tool reads its previews itself, as it does without a record; the live addresses stay.
42
+ */
43
+ export async function settledHosting(first, repo, readState, opts = {}) {
44
+ const { everyMs = 3_000, upToMs = 60_000, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now } = opts;
45
+ const deadline = now() + upToMs;
46
+ let hosting = hostingOf(first, repo);
47
+ while (hosting?.readPending && now() + everyMs < deadline) {
48
+ await sleep(everyMs);
49
+ const state = await readState(deadline - now());
50
+ if (state.ok)
51
+ hosting = hostingOf(state.data, repo) ?? hosting;
52
+ }
53
+ return hosting?.readPending ? { apps: hosting.apps.map((a) => ({ ...a, previews: [] })), readAt: null, unread: hosting.unread } : hosting;
54
+ }
55
+ /** The reports each host's previews are read from, besides the deployments every read takes. */
56
+ const REPORTS = { netlify: ['statuses', 'comments'], cloudflare: ['check-runs', 'comments'], render: [] };
57
+ /**
58
+ * Whether the record is the whole of GitHub's record of the host's earlier previews, an empty list included: Impeccable
59
+ * read the repository's copy (`readAt`), and took every report the host's previews are read from. Else a host tool
60
+ * reads GitHub itself where the record lists none.
61
+ */
62
+ export function hostingRead(hosting, host) {
63
+ if (!hosting?.readAt)
64
+ return false;
65
+ return !REPORTS[host].some((report) => hosting.unread.includes(report));
66
+ }
67
+ /** The pull requests whose previews the app's record lists as closed: on Render, evidence of the site and the pattern, never a preview to prove. */
68
+ export const closedPulls = (app) => new Set(app?.previews.flatMap((p) => (p.pullState === 'closed' && p.pull !== null ? [p.pull] : [])) ?? []);
36
69
  /** The record's entry for an app: the one named, or, on a repository of one app, its only one. */
37
70
  export function hostingApp(hosting, appKey) {
38
71
  if (!hosting)
package/dist/log.js CHANGED
@@ -26,6 +26,30 @@ export function redact(text, secrets = []) {
26
26
  export function scrub(text, secrets = [], field) {
27
27
  return redactShapes(redact(text, secrets), field);
28
28
  }
29
+ /** Flags whose next argument is a value of no interest to the log: gh's host, cf's profile. */
30
+ const VALUED = new Set(['--hostname', '--profile']);
31
+ /**
32
+ * A host CLI's call as one debug-log line, for counting the reads a run made: the command, its subcommand words, gh
33
+ * api's method and an API route without its query. It stops at the first argument that is none of those, so no flag's
34
+ * value (a `--data` body, a password, a token) is ever written.
35
+ */
36
+ export function commandLine(name, args) {
37
+ const parts = [name];
38
+ for (let i = 0; i < args.length; i++) {
39
+ const arg = args[i];
40
+ if (arg === '--method' && /^[A-Z]{3,7}$/.test(args[i + 1] ?? ''))
41
+ parts.push(args[++i]);
42
+ else if (VALUED.has(arg))
43
+ i++;
44
+ else if (/^[A-Za-z][A-Za-z:-]{0,39}$/.test(arg))
45
+ parts.push(arg);
46
+ else if (/^\/[\w./%~-]*(?:\?.*)?$/.test(arg))
47
+ parts.push(arg.split('?')[0]);
48
+ else
49
+ break;
50
+ }
51
+ return parts.join(' ');
52
+ }
29
53
  export function createLog(debug, dir = tmpdir()) {
30
54
  const secrets = new Set();
31
55
  const secret = (value) => void secrets.add(value);
package/dist/main.js CHANGED
@@ -7,7 +7,7 @@ import { runAgent } from './agent.js';
7
7
  import { runSkillCommand } from './skill.js';
8
8
  import { fenceExtension, guardedFileOperations, hostsAgreed, placeOf, previewRetryState } from './fence.js';
9
9
  import { accountsSaved, focusInstructions, focusState, severalApps, signInItem } from './focus.js';
10
- import { hostingOf } from './hosting.js';
10
+ import { settledHosting } from './hosting.js';
11
11
  import { requestJson } from './http.js';
12
12
  import { copyText, letTabsHear, openBrowser, progressPage, screenPrompt, setupAuth, signIn } from './login.js';
13
13
  import { checkoutJournal } from './leftovers.js';
@@ -234,6 +234,8 @@ export async function main(argv) {
234
234
  ]);
235
235
  if (!instructions.ok || !state.ok)
236
236
  throw new UserError(`Impeccable's setup API did not answer: ${instructions.error ?? state.error}.`);
237
+ // A hosting record behind GitHub is read again while the questions below are asked (hosting.ts `settledHosting`).
238
+ const hosting = settledHosting(state.data, token.repository.name, (timeoutMs) => requestJson(`${setupApi}/state`, { token: token.access_token, timeoutMs }, log));
237
239
  // When the checkout cannot tell, the setup state's own verdict decides. When it has a web front end Impeccable skipped, the person adds it first.
238
240
  if (stateSaysNoFrontEnd(state.data, token.repository.name)) {
239
241
  if (frontEnd === 'unknown')
@@ -395,7 +397,7 @@ export async function main(argv) {
395
397
  sandbox,
396
398
  pushAccess: push.outcome,
397
399
  severalApps: severalApps(state.data),
398
- hosting: hostingOf(state.data, token.repository.name),
400
+ hosting: await hosting,
399
401
  ...(flags.app ? { app: flags.app } : {}),
400
402
  start: checkout.start,
401
403
  ...(flags.signedInTestDrive ? { signedInTestDrive: { accountsSaved: accountsSaved(state.data, token.repository.name) } } : {}),
package/dist/netlify.js CHANGED
@@ -124,8 +124,8 @@ async function fromGitHub(owner, repo, gh, only) {
124
124
  }
125
125
  return { previews: found, heads: new Map(heads.map((head) => [head.number, head.sha])), failed };
126
126
  }
127
- /** What GitHub records, read from the setup state's hosting record (hosting.ts) instead of GitHub: Netlify's commit statuses and its bot's comments as Impeccable's copy holds them; null where the record lists none for the app, so GitHub is read here. */
128
- export function netlifyFromHosting(app) {
127
+ /** What GitHub records, read from the setup state's hosting record (hosting.ts) instead of GitHub: Netlify's commit statuses and its bot's comments as Impeccable's copy holds them; null where the record lists none for the app and is not `read` (hosting.ts `hostingRead`), so GitHub is read here. */
128
+ export function netlifyFromHosting(app, read = false) {
129
129
  const previews = [];
130
130
  const heads = new Map();
131
131
  // A commit status names the head and carries the build's state; a comment proves nothing of the current build, so it stands only where no status says (as from GitHub's record).
@@ -141,7 +141,7 @@ export function netlifyFromHosting(app) {
141
141
  // A bot's comment posts once the preview is ready, as from GitHub's record; a status carries its own state.
142
142
  previews.push(p.source === 'comment' ? { pullRequest: p.pull, site: p.site, url, state: 'ready', from: 'bot comment', at: when(p.at) } : { pullRequest: p.pull, site: p.site, url, state: STATES.find((s) => s === p.state) ?? null, from: 'commit status', at: when(p.at) });
143
143
  }
144
- return previews.length ? { previews, heads, failed: false } : null;
144
+ return previews.length || read ? { previews, heads, failed: false } : null;
145
145
  }
146
146
  /** A page of the person's site list, and how many pages are read at most: what `netlify sites:list` reads. */
147
147
  const SITES_PER_PAGE = 100;
@@ -8,7 +8,7 @@ Render deploys the app: impeccable_render_previews (with the app's `appKey` on a
8
8
  - **`confirmed: false`**: the person skipped the Render screen, a no that ends the setup for the app, as the playbook says.
9
9
  - **`turnedOn`** says how turning the service's previews on ended, where the CLI said they were off and the screen was the consent: `enabled`, go on; `skipped` or `refused`, push no test drive (pull-requests' "No preview, no test drive"), and say under "What was skipped, and why" that Render's pull request previews are off for the site, with the `reason`, and name the `page`; `left` stays empty, since the person was asked in this run.
10
10
  - **No `site`**: Render's previews do not show this app. Go on as server-previews says, with `unread` under "What is not verified" where the CLI did not answer.
11
- 2. **Prove the newest preview** with impeccable_prove_preview: the address of the newest listed preview whose state is `success` (or a page on it a visitor opens without signing in), and words the page shows that the checkout holds. Done when it answers `proven: true`; when an address no longer answers (Render takes a closed pull request's preview down), try the next newest, three at most. With none listed, or none that answers, the test drive's own preview is the proof: go on to pull-requests' step 4.
11
+ 2. **Prove the newest preview** with impeccable_prove_preview: the address of the newest listed preview whose state is `success` (or a page on it a visitor opens without signing in), and words the page shows that the checkout holds; a closed pull request's preview, `pullState: "closed"`, names the site and the pattern only, and counts as none listed, since Render takes it down. Done when it answers `proven: true`; when an address no longer answers, try the next newest, three at most. With none listed, or none that answers, the test drive's own preview is the proof: go on to pull-requests' step 4.
12
12
  3. **Save the method** with impeccable_write to `preview`: `{"previewUrlPattern": "<the proof's pattern>", "intendedMethod": "preview-deploy"}`, with the app's `appKey` in a monorepo, since each app's site has a pattern of its own. Done when the answer's `keys` show both stored or unchanged.
13
13
 
14
14
  ### Reference
package/dist/tools.js CHANGED
@@ -13,7 +13,7 @@ import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps }
13
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';
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
- import { deploymentsOf, hostingApp, liveHostOf, renderSitesOf } from './hosting.js';
16
+ import { closedPulls, deploymentsOf, hostingApp, hostingRead, liveHostOf, renderSitesOf } from './hosting.js';
17
17
  import { enableRenderPreviews, parseRenderDeployment, readRenderService, RENDER_DASHBOARD, renderSitesNamed } from './render.js';
18
18
  import { cloudflareFromHosting, cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
19
19
  import { passCloudflareAccess } from './cloudflare-access.js';
@@ -22,7 +22,7 @@ import { netlifyToken, passNetlifyLock, setSitePassword } from './netlify-passwo
22
22
  import { connectVercelProject, 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
- import { redact } from './log.js';
25
+ import { commandLine, redact } from './log.js';
26
26
  import { scan } from './scan.js';
27
27
  import { gitEnvironment, installEnvironment, ownFolder } from './sandbox.js';
28
28
  import { openBrowser } from './login.js';
@@ -1626,6 +1626,24 @@ export function createWorkflowSynchronizer(opts = {}) {
1626
1626
  }
1627
1627
  export function createTools(ctx) {
1628
1628
  const text = (t, terminate = false) => ({ content: [{ type: 'text', text: t }], details: undefined, ...(terminate ? { terminate } : {}) });
1629
+ /** A host CLI (gh, render, netlify, cf, vercel, npm) as the host tools run it: each call one line in the debug log, its command and route alone (log.ts `commandLine`), so a run's reads can be counted. */
1630
+ const hostRunner = (name, cwd, opts) => {
1631
+ // Made on its first call: a reader handed one it never calls runs nothing.
1632
+ let run;
1633
+ return async (args) => {
1634
+ const line = `host call: ${commandLine(name, args)}`;
1635
+ try {
1636
+ run ??= (ctx.hostRunner ?? commandRunner)(name, cwd, opts);
1637
+ const out = await run(args);
1638
+ ctx.log.write(line);
1639
+ return out;
1640
+ }
1641
+ catch (err) {
1642
+ ctx.log.write(`${line} (failed)`);
1643
+ throw err;
1644
+ }
1645
+ };
1646
+ };
1629
1647
  const retry = previewRetryState(ctx.log);
1630
1648
  retry.repository ??= `${ctx.owner}/${ctx.repo}`;
1631
1649
  // Once the list is up: the agent's first call sets it, with the reading in progress.
@@ -1682,6 +1700,8 @@ export function createTools(ctx) {
1682
1700
  const workerPatterns = new Map();
1683
1701
  /** The origins of the successful Render previews impeccable_render_previews listed, of the app's own site. */
1684
1702
  const renderListed = new Set();
1703
+ /** Of those, the ones built in this run, which its wait with `pull` listed: the only ones whose 404 is asked for again, since Render may not serve their build yet. */
1704
+ const renderBuilt = new Set();
1685
1705
  let hostedPreviewProven = false;
1686
1706
  /**
1687
1707
  * The address pattern each proof answered, by the app it was proven for, and the last one under '' for a repository
@@ -1982,7 +2002,7 @@ export function createTools(ctx) {
1982
2002
  liveApps.set(new URL(production).origin, appKey);
1983
2003
  };
1984
2004
  const hostCli = (host, pi, signal) => {
1985
- const runner = (browser = false) => (ctx.hostRunner ?? commandRunner)(host, pi.cwd, { signal, browser, timeoutMs: browser ? 300_000 : 30_000 });
2005
+ const runner = (browser = false) => hostRunner(host, pi.cwd, { signal, browser, timeoutMs: browser ? 300_000 : 30_000 });
1986
2006
  const cli = runner();
1987
2007
  if (host === 'vercel') {
1988
2008
  return {
@@ -2082,7 +2102,7 @@ export function createTools(ctx) {
2082
2102
  /** The run's own install of a host CLI's package, for installHostCli: npm's pinned global install, from the command's own folder and npm cache, as the agent's install of one runs; whether it completed. */
2083
2103
  const npmInstall = (pkg, signal) => async () => {
2084
2104
  try {
2085
- await (ctx.hostRunner ?? commandRunner)('npm', ownFolder(), { signal, timeoutMs: 600_000, env: installEnvironment() })(hostCliInstallArgs(pkg));
2105
+ await hostRunner('npm', ownFolder(), { signal, timeoutMs: 600_000, env: installEnvironment() })(hostCliInstallArgs(pkg));
2086
2106
  return true;
2087
2107
  }
2088
2108
  catch {
@@ -2090,7 +2110,7 @@ export function createTools(ctx) {
2090
2110
  }
2091
2111
  };
2092
2112
  /** cf, Cloudflare's CLI, which only the tools run: where it is missing here, its install is asked first (#396). */
2093
- const cfPresent = async (pi, signal) => (await cfStatus(cfRunner({ checkout: pi.cwd }, ctx.hostRunner), signal)).cli !== 'missing';
2113
+ const cfPresent = async (pi, signal) => (await cfStatus(cfRunner({ checkout: pi.cwd }, hostRunner), signal)).cli !== 'missing';
2094
2114
  const installCf = (pi, signal) => installHostCli('cloudflare', () => cfPresent(pi, signal), npmInstall('cf', signal), signal);
2095
2115
  /**
2096
2116
  * A host's sign-in, on `impeccable_ask` with the id `<host>-setup`: the setup screen once, worded for a CLI signed in
@@ -2194,8 +2214,8 @@ export function createTools(ctx) {
2194
2214
  // The site's settings change on the host: only once the person opened the Netlify setup, as the fence holds for the CLI. A site built from this repository with Deploy Previews on needs no setup (#311): the connection changes nothing on it, and only checks that Netlify's GitHub app still reads the repository, asking for its access when it does not.
2195
2215
  if (!hostSetupAgreed(ctx.log, 'netlify') && !previewing)
2196
2216
  return { connected: false, reason: 'The Netlify sign-in comes first: impeccable_ask with the id netlify-setup.' };
2197
- const netlify = (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal });
2198
- const gh = (ctx.hostRunner ?? commandRunner)('gh', pi.cwd, { signal });
2217
+ const netlify = hostRunner('netlify', pi.cwd, { signal });
2218
+ const gh = hostRunner('gh', pi.cwd, { signal });
2199
2219
  const clone = ctx.hostWait?.clone;
2200
2220
  const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, linked: linkedByRun(ctx.log), settings, signal, ...(clone ? { wait: clone } : {}), ...(directory !== undefined ? { directory } : {}), ...(previewing ? { ownSite: true } : {}) });
2201
2221
  netlifySite = result.site?.name ?? netlifySite;
@@ -2221,7 +2241,7 @@ export function createTools(ctx) {
2221
2241
  async function vercelConnect(pi, signal) {
2222
2242
  if (!hostSetupAgreed(ctx.log, 'vercel'))
2223
2243
  return { connected: false, reason: 'The Vercel sign-in comes first: impeccable_ask with the id vercel-setup.' };
2224
- const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
2244
+ const vercel = hostRunner('vercel', pi.cwd, { signal });
2225
2245
  const link = await readLink(pi.cwd);
2226
2246
  if (!link)
2227
2247
  return { connected: false, reason: 'This folder is linked to no Vercel project (.vercel/project.json): run vercel link first.' };
@@ -3179,7 +3199,7 @@ export function createTools(ctx) {
3179
3199
  const vercel = (args) => {
3180
3200
  const key = JSON.stringify(args);
3181
3201
  if (!reads.has(key))
3182
- reads.set(key, (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal })(args));
3202
+ reads.set(key, hostRunner('vercel', pi.cwd, { signal })(args));
3183
3203
  return reads.get(key);
3184
3204
  };
3185
3205
  for (const origin of listedPreviews)
@@ -3192,7 +3212,7 @@ export function createTools(ctx) {
3192
3212
  const app = appKey !== undefined ? await vercelApp(pi, appKey, vercel) : undefined;
3193
3213
  if (app && 'reason' in app)
3194
3214
  return JSON.stringify({ appKey, deployments: [], unmatched: app.reason });
3195
- const raw = () => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, branch });
3215
+ const raw = () => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, branch, runGh: hostRunner('gh', pi.cwd, { signal }) });
3196
3216
  // Vercel names each project's deployments on GitHub "Preview – <project>"; one without a name is told by the project's own record of it.
3197
3217
  const owned = async (d) => {
3198
3218
  if (!app)
@@ -3273,7 +3293,7 @@ export function createTools(ctx) {
3273
3293
  noteApp(appKey, { host_project: app.name });
3274
3294
  return JSON.stringify({ ...(appKey !== undefined ? { appKey, project: app.name } : {}), ...evidence, ...(production ? { production } : {}) });
3275
3295
  }),
3276
- 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 holds them, 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) => {
3296
+ 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) => {
3277
3297
  const pull = p.pull;
3278
3298
  const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
3279
3299
  if (refused)
@@ -3285,10 +3305,10 @@ export function createTools(ctx) {
3285
3305
  return JSON.stringify({ appKey, sites: [], previews: [], unmatched: `The setup state names no folder for the app ${appKey}.` });
3286
3306
  // Each site's team is for the last screen alone.
3287
3307
  const onTeams = (teams) => Object.entries(teams).forEach(([name, team]) => accountOf.set(`netlify ${name}`, team));
3288
- // GitHub's record as the setup state holds it, where it lists Netlify's previews of this app; a wait on a pull request reads GitHub, since its preview is newer than the record.
3308
+ // GitHub's record as the setup state holds it, where it lists Netlify's previews of this app, or none on a record Impeccable read; a wait on a pull request reads GitHub, since its preview is newer than the record.
3289
3309
  const known = pull === undefined ? hostingFor(appKey) : undefined;
3290
- const github = known ? netlifyFromHosting(known) : null;
3291
- const read = () => (ctx.netlifyPreviewReader ?? readNetlifyPreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, onTeams, ...(directory !== undefined ? { directory } : {}), ...(github ? { github } : {}) });
3310
+ const github = known ? netlifyFromHosting(known, hostingRead(ctx.hosting, 'netlify')) : null;
3311
+ const read = () => (ctx.netlifyPreviewReader ?? readNetlifyPreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, onTeams, gh: hostRunner('gh', pi.cwd, { signal }), netlify: hostRunner('netlify', pi.cwd, { signal }), ...(directory !== undefined ? { directory } : {}), ...(github ? { github } : {}) });
3292
3312
  // A pull request's preview is still building until Netlify's deploy of it is built or has failed.
3293
3313
  const states = ['success', 'ready', 'failure', 'error'];
3294
3314
  const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
@@ -3325,7 +3345,7 @@ export function createTools(ctx) {
3325
3345
  noteApp(appKey, { host_project: site });
3326
3346
  return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
3327
3347
  }),
3328
- 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 holds them, 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) => {
3348
+ 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) => {
3329
3349
  const pull = p.pull;
3330
3350
  const branch = p.branch;
3331
3351
  if (pull !== undefined && branch !== undefined)
@@ -3345,10 +3365,10 @@ export function createTools(ctx) {
3345
3365
  let accountName;
3346
3366
  // A Worker Preview's build names no address: its alias is made from the pattern the plain read's Worker preview stood for.
3347
3367
  const pattern = cloudflareEvidence?.previews.find((preview) => preview.pattern)?.pattern;
3348
- // GitHub's record as the setup state holds it, where it lists Cloudflare's previews of this app; a wait on a pull request or a pushed branch reads GitHub, since its build is newer than the record.
3368
+ // GitHub's record as the setup state holds it, where it lists Cloudflare's previews of this app, or none on a record Impeccable read; a wait on a pull request or a pushed branch reads GitHub, since its build is newer than the record.
3349
3369
  const known = pull === undefined && branch === undefined ? hostingFor(appKey) : undefined;
3350
- const github = known ? cloudflareFromHosting(known) : null;
3351
- const readAll = () => (ctx.cloudflarePreviewReader ?? readCloudflarePreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, branch, onAccountName: (name) => (accountName = name), ...(directory !== undefined && pull === undefined && branch === undefined ? { directory } : {}), ...(appProject ? { project: appProject.project } : {}), ...(pattern ? { pattern } : {}), ...(github ? { github } : {}) });
3370
+ const github = known ? cloudflareFromHosting(known, hostingRead(ctx.hosting, 'cloudflare')) : null;
3371
+ const readAll = () => (ctx.cloudflarePreviewReader ?? readCloudflarePreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, branch, gh: hostRunner('gh', pi.cwd, { signal }), run: hostRunner, onAccountName: (name) => (accountName = name), ...(directory !== undefined && pull === undefined && branch === undefined ? { directory } : {}), ...(appProject ? { project: appProject.project } : {}), ...(pattern ? { pattern } : {}), ...(github ? { github } : {}) });
3352
3372
  const read = async () => {
3353
3373
  const evidence = await readAll();
3354
3374
  return appProject ? { ...evidence, previews: evidence.previews.filter((preview) => pagesProject(preview.url) === appProject.label) } : evidence;
@@ -3430,8 +3450,8 @@ export function createTools(ctx) {
3430
3450
  ...(token ? { access: token } : {}),
3431
3451
  });
3432
3452
  let proved = await prove();
3433
- // Render reports a preview's deploy successful before its edge serves that build (REN-1117): a page the push added answers 404 for a while, so it is asked for again here, within a bounded wait, the way a command's sleep would but the fence refuses.
3434
- if (!live && renderListed.has(origin) && proved.status === 404) {
3453
+ // Render reports a preview's deploy successful before its edge serves that build (REN-1117): a page the push added answers 404 for a while, so it is asked for again here, within a bounded wait, the way a command's sleep would but the fence refuses. Only a build of this run's: an earlier preview's 404 is a preview Render took down.
3454
+ if (!live && renderListed.has(origin) && renderBuilt.has(origin) && proved.status === 404) {
3435
3455
  const wait = ctx.hostWait?.serve ?? { totalMs: 90_000, everyMs: 5_000 };
3436
3456
  said('Waiting for Render to serve the new page');
3437
3457
  for (const end = Date.now() + wait.totalMs; proved.status === 404 && Date.now() < end && !signal?.aborted;) {
@@ -3496,7 +3516,7 @@ export function createTools(ctx) {
3496
3516
  return JSON.stringify({ ...proof, appKey: app.appKey });
3497
3517
  return JSON.stringify(proof);
3498
3518
  }),
3499
- 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 holds them, 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); 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) => {
3519
+ 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) => {
3500
3520
  const pull = p.pull;
3501
3521
  const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
3502
3522
  if (refused)
@@ -3511,18 +3531,19 @@ export function createTools(ctx) {
3511
3531
  if (pull !== undefined && !known)
3512
3532
  return `Refused: read impeccable_render_previews${appKey !== undefined ? ` with appKey ${appKey}` : ''} and no pull first: it tells which Render site builds ${appKey !== undefined ? 'that app' : 'this repository'}.`;
3513
3533
  // GitHub's record of the repository's deployments (with `branch`, its head's), and of those Render's previews of `site`, of `pull` alone where it is given.
3514
- const readEvidence = (branch) => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, ...(branch !== undefined ? { branch } : {}) });
3534
+ const readEvidence = (branch) => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, runGh: hostRunner('gh', pi.cwd, { signal }), ...(branch !== undefined ? { branch } : {}) });
3515
3535
  const previewsOf = (evidence, site) => evidence.deployments.flatMap((d) => {
3516
3536
  const preview = parseRenderDeployment(d.environment, d.url);
3517
3537
  return preview && preview.site === site && (pull === undefined || preview.pull === pull) ? [{ pull: preview.pull, url: d.url, state: d.state }] : [];
3518
3538
  });
3519
- // The successful previews are the ones a proof may open, each the app's.
3539
+ // The successful previews are the ones a proof may open, each the app's, except a closed pull request's (hosting.ts `closedPulls`): Render takes it down when the pull request closes, so it is evidence of the site and the pattern alone, marked `pullState: "closed"`.
3540
+ const closed = closedPulls(hostingFor(appKey));
3520
3541
  const list = (previews) => {
3521
3542
  for (const origin of renderListed)
3522
3543
  listedApps.delete(origin);
3523
3544
  renderListed.clear();
3524
3545
  for (const preview of previews) {
3525
- if (preview.state !== 'success')
3546
+ if (preview.state !== 'success' || closed.has(preview.pull))
3526
3547
  continue;
3527
3548
  renderListed.add(new URL(preview.url).origin);
3528
3549
  if (appKey !== undefined)
@@ -3540,15 +3561,17 @@ export function createTools(ctx) {
3540
3561
  const final = (e) => Boolean(e.error) || e.previews.some((preview) => preview.state === 'success' || preview.state === 'failure' || preview.state === 'error');
3541
3562
  const waited = await awaitBuild('Render', `Waiting for Render to build ${await subjectOf(pull)}`, read, final, () => known.page, signal);
3542
3563
  list(waited.previews);
3564
+ for (const origin of renderListed)
3565
+ renderBuilt.add(origin);
3543
3566
  return JSON.stringify({ ...forApp, site: known.site, ...waited });
3544
3567
  }
3545
- const render = (ctx.hostRunner ?? commandRunner)('render', pi.cwd, { signal });
3568
+ const render = hostRunner('render', pi.cwd, { signal });
3546
3569
  const read = await readRenderService(render, repository, folder);
3547
3570
  const service = 'service' in read ? read.service : undefined;
3548
- // GitHub's record as the setup state holds it, where it lists Render's deployments of this app; else read here.
3571
+ // GitHub's record as the setup state holds it, where it lists Render's deployments of this app, or none on a record Impeccable read; else read here.
3549
3572
  const record = hostingFor(appKey);
3550
3573
  const recorded = record ? deploymentsOf(record).filter((d) => parseRenderDeployment(d.environment, d.url)) : [];
3551
- const evidence = recorded.length ? { deployments: recorded } : await readEvidence();
3574
+ const evidence = recorded.length || (record && hostingRead(ctx.hosting, 'render')) ? { deployments: recorded } : await readEvidence();
3552
3575
  let site = service?.site;
3553
3576
  // Without the CLI's answer, the one site the setup state's record names for the app (its deployments, its live address); else the one the repository names: on a repository of one app, the one Render site its deployments and files name; on one of several, the one a file in the app's own folder names, else the one a line of the root README names beside that app and no other.
3554
3577
  if (!site && record) {
@@ -3574,7 +3597,7 @@ export function createTools(ctx) {
3574
3597
  const unread = 'reason' in read ? { unread: read.reason } : {};
3575
3598
  if (!site)
3576
3599
  return JSON.stringify({ ...forApp, previews: [], cli, ...unread, ...(evidence.error ? { error: evidence.error } : {}) });
3577
- const previews = previewsOf(evidence, site);
3600
+ const previews = previewsOf(evidence, site).map((p) => (closed.has(p.pull) ? { ...p, pullState: 'closed' } : p));
3578
3601
  list(previews);
3579
3602
  const production = service?.production ?? `https://${site}.onrender.com`;
3580
3603
  noteProduction(production, 'render', undefined, appKey);
@@ -3652,8 +3675,8 @@ export function createTools(ctx) {
3652
3675
  // cf is installed here: the fact this acts on is one a read through cf found.
3653
3676
  const { status, ...more } = await turnOnPreviews(host, async () => {
3654
3677
  if (host === 'netlify')
3655
- return enableNetlifyPreviews((ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }), fact.project);
3656
- const cf = cfRunner({ checkout: pi.cwd }, ctx.hostRunner);
3678
+ return enableNetlifyPreviews(hostRunner('netlify', pi.cwd, { signal }), fact.project);
3679
+ const cf = cfRunner({ checkout: pi.cwd }, hostRunner);
3657
3680
  return enableCloudflarePreviews(cf, fact.project, await cfStatus(cf, signal), signal);
3658
3681
  }, signal);
3659
3682
  return answer(status, more);
@@ -3687,8 +3710,8 @@ export function createTools(ctx) {
3687
3710
  if ((await ask({ ...screen, prompt: create ? `Create the Pages project ${project} for ${ctx.owner}/${ctx.repo}?` : `Connect ${ctx.owner}/${ctx.repo} to the Pages project ${project}?` })).skipped)
3688
3711
  return answer('skipped', { reason: `The person skipped ${create ? 'creating the Pages project' : 'connecting the repository to the Pages project'}.` });
3689
3712
  // cf signed in with pages:write, as it is or after Cloudflare's sign-in in the browser, keeping the scopes it held; a sign-in that did not complete offers a retry, twice.
3690
- const cf = cfRunner({ checkout: pi.cwd }, ctx.hostRunner);
3691
- const login = cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, ctx.hostRunner);
3713
+ const cf = cfRunner({ checkout: pi.cwd }, hostRunner);
3714
+ const login = cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, hostRunner);
3692
3715
  const writes = (status) => status.cli === 'signed-in' && (!status.scopes || status.scopes.includes('pages:write'));
3693
3716
  let status = await cfStatus(cf, signal);
3694
3717
  // cf not on this machine (#396): its install is asked first.
@@ -3718,7 +3741,7 @@ export function createTools(ctx) {
3718
3741
  }
3719
3742
  }
3720
3743
  said(create ? `Creating the Pages project ${project}` : `Connecting the repository to ${project}`);
3721
- const gh = (ctx.hostRunner ?? commandRunner)('gh', pi.cwd, { signal });
3744
+ const gh = hostRunner('gh', pi.cwd, { signal });
3722
3745
  const outcome = await connectCloudflareProject(cf, gh, status, { owner: ctx.owner, repo: ctx.repo }, { project, ...(evidence.account ? { account: evidence.account } : {}), ...(create ? { create: { command: p.command, outputDir: p.outputDir } } : {}) }, signal);
3723
3746
  if (outcome.status !== 'connected')
3724
3747
  return answer('refused', { reason: outcome.reason });
@@ -3757,7 +3780,7 @@ export function createTools(ctx) {
3757
3780
  said("Getting past Vercel's login");
3758
3781
  if (at?.app)
3759
3782
  lockPending.delete(at.app.appKey);
3760
- const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
3783
+ const vercel = hostRunner('vercel', pi.cwd, { signal });
3761
3784
  // Before the push, the app's project as impeccable_hosted_previews reads it: the one that builds the app's folder (#281).
3762
3785
  let app = at?.app;
3763
3786
  if (before && appsRepo) {
@@ -3992,7 +4015,7 @@ export function createTools(ctx) {
3992
4015
  /** Netlify took a new password the run set in this call: the end screen says so, saved or not, and on a cancel too. */
3993
4016
  let replaced = false;
3994
4017
  let answer;
3995
- const netlify = (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal });
4018
+ const netlify = hostRunner('netlify', pi.cwd, { signal });
3996
4019
  // The Netlify CLI not on this machine (#396): its install is asked first, the run's own since no agent's turn comes in between, and the command runs again.
3997
4020
  const run = async (args) => {
3998
4021
  try {
@@ -4157,8 +4180,8 @@ export function createTools(ctx) {
4157
4180
  accessConsented.add(found.id);
4158
4181
  return yes;
4159
4182
  },
4160
- cf: cfRunner({ checkout: pi.cwd }, ctx.hostRunner),
4161
- login: cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, ctx.hostRunner),
4183
+ cf: cfRunner({ checkout: pi.cwd }, hostRunner),
4184
+ login: cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, hostRunner),
4162
4185
  retry: async () => !(await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped,
4163
4186
  install: async () => void (await installCf(pi, signal)),
4164
4187
  status: said,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impeccable/reviews",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
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,