@impeccable/reviews 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/bin.js +3 -1
- package/dist/cloudflare.js +49 -8
- package/dist/fence.js +35 -2
- package/dist/host-questions.js +21 -0
- package/dist/hosted.js +46 -13
- package/dist/log.js +2 -1
- package/dist/main.js +10 -0
- package/dist/playbook/cases/cloudflare-previews.md +3 -3
- package/dist/playbook/cases/live-app-address.md +1 -1
- package/dist/playbook/cases/monorepo.md +1 -1
- package/dist/playbook/cases/pull-requests.md +4 -4
- package/dist/playbook/cases/render-previews.md +21 -0
- package/dist/playbook/cases/server-previews.md +1 -1
- package/dist/playbook.js +4 -4
- package/dist/render.js +105 -0
- package/dist/report.js +3 -1
- package/dist/skill/impeccable-screenshots/SKILL.md +73 -0
- package/dist/skill.js +37 -0
- package/dist/tools.js +284 -52
- package/dist/ui/run-state.js +19 -9
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -10,9 +10,10 @@ 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, 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 } from './host-questions.js';
|
|
14
14
|
import { enableNetlifyPreviews, folderOf } from './host-previews.js';
|
|
15
|
-
import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews, readmeNamesApp, vercelDeploymentOf } from './hosted.js';
|
|
15
|
+
import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews, readmeNamesApp, renderSiteOf, vercelDeploymentOf } from './hosted.js';
|
|
16
|
+
import { enableRenderPreviews, parseRenderDeployment, readRenderService, RENDER_DASHBOARD, renderSitesNamed } from './render.js';
|
|
16
17
|
import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
|
|
17
18
|
import { passCloudflareAccess } from './cloudflare-access.js';
|
|
18
19
|
import { commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
|
|
@@ -27,6 +28,7 @@ import { openBrowser } from './login.js';
|
|
|
27
28
|
import { STATUS_MAX, statusVoiceError, TASK_STATUSES } from './sync.js';
|
|
28
29
|
import { askPerson, reportDone, reportStatus, reportTasks, reportWait } from './ui/bridge.js';
|
|
29
30
|
import { SEE_THE_REVIEW } from './ui/look.js';
|
|
31
|
+
import { TaskOrder } from './ui/run-state.js';
|
|
30
32
|
import { noteSensitiveAnswer, plainText } from './ui/sensitive.js';
|
|
31
33
|
export { STATUS_MAX, statusVoiceError } from './sync.js';
|
|
32
34
|
/** The first way `value` breaks `schema`, or null when it fits. */
|
|
@@ -128,6 +130,8 @@ export const SETUP_ENDS = {
|
|
|
128
130
|
};
|
|
129
131
|
/** How many times a preview check the setup API turned away for its rate (one a repository every 30 seconds) is tried again (#338). */
|
|
130
132
|
const CHECK_RETRIES = 3;
|
|
133
|
+
/** The setup API signs a repository's saved accounts in once a minute: a save sooner is stored but not tried (#249). */
|
|
134
|
+
const SIGN_IN_CHECK_MS = 60_000;
|
|
131
135
|
/** The last screen's words for a run on several apps: the picked apps not set up, by name, the line naming which are and which are not, and a line for each that is not. */
|
|
132
136
|
export function appsSummary(apps) {
|
|
133
137
|
const picked = apps.filter((a) => a.state !== 'unpicked');
|
|
@@ -160,10 +164,12 @@ function stageError(status, tasks) {
|
|
|
160
164
|
}
|
|
161
165
|
/** 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). */
|
|
162
166
|
export function resultLine(report) {
|
|
167
|
+
// The run's own words for why no method was saved, where it gave them (REN-1108).
|
|
168
|
+
const why = report.reason && !report.methodSaved ? ` Why: ${report.reason.replace(/[.\s]+$/, '')}.` : '';
|
|
163
169
|
if (report.ended)
|
|
164
|
-
return SETUP_ENDS[report.ended].line
|
|
170
|
+
return `${SETUP_ENDS[report.ended].line}${why}`;
|
|
165
171
|
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.`
|
|
166
|
-
: !report.testPr ? `${report.unhonoured ?? noTestDrive(report)} The report, printed here when this screen closes, says what the run found.` : report.setupPr ? 'The setup pull request adds the workflow that lets Impeccable run on your previews. Merge it when you are ready.' : SEE_THE_REVIEW;
|
|
172
|
+
: !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;
|
|
167
173
|
// --signed-in-test-drive left an app without a test drive: said beside another app's drive or a wall too (#410).
|
|
168
174
|
const said = report.unhonoured && (report.walled || report.testPr) ? `${lead} ${report.unhonoured}` : lead;
|
|
169
175
|
return report.setupPr || !report.hostedBy ? said : `${said} ${nothingToMerge(report.hostedBy)}`;
|
|
@@ -197,7 +203,13 @@ export function ending(report) {
|
|
|
197
203
|
return { outcome, title, todo, code: 1 };
|
|
198
204
|
}
|
|
199
205
|
/** Said once, here and in the report, when the host builds the previews and the repository needed no change. */
|
|
200
|
-
export const nothingToMerge = (host) => `Nothing to merge: ${host} hosts your previews, so the repository needed no change.`;
|
|
206
|
+
export const nothingToMerge = (host) => `Nothing to merge: ${host} ${host.includes(' and ') ? 'host' : 'hosts'} your previews, so the repository needed no change.`;
|
|
207
|
+
/** The agent's words for why an app was not set up, kept only as plain words of at most 200 characters, with no address (a scheme, or a host with a path or query) and nothing shaped like a secret. */
|
|
208
|
+
function plainReason(text) {
|
|
209
|
+
const plain = text.trim();
|
|
210
|
+
const address = /[a-z][a-z0-9+.-]*:\/\/|[a-z0-9-]+(?:\.[a-z0-9-]+)+[/?#]/i.test(plain);
|
|
211
|
+
return plain && plain.length <= 200 && plainText(plain) === plain && !address && !scan('output', plain).length ? plain : undefined;
|
|
212
|
+
}
|
|
201
213
|
/** Names as a reader lists them: "a", "a and b", "a, b and c". */
|
|
202
214
|
const andList = (names) => (names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names.join(''));
|
|
203
215
|
/** The end screen's one line for the Netlify sites whose password the run set and saved, set but did not save, or saved as it was: every site in one line (#419). */
|
|
@@ -219,7 +231,7 @@ export function signedInLine(saved, app) {
|
|
|
219
231
|
const signed = accounts.filter((a) => a.outcome === 'signed-in' && typeof a.role === 'string').map(role);
|
|
220
232
|
const not = accounts.filter((a) => a.outcome !== 'signed-in' && typeof a.role === 'string').map(role);
|
|
221
233
|
if (!signed.length)
|
|
222
|
-
return undefined;
|
|
234
|
+
return not.length ? `${app ? `${app}: ` : ''}${andList(not)} did not sign in` : undefined;
|
|
223
235
|
return `${app ? `${app}: signed` : 'Signed'} in as ${andList(signed)}${not.length ? `; ${andList(not)} did not` : ''}`;
|
|
224
236
|
}
|
|
225
237
|
/** The last screen's line for each host, in the order first met: "Cloudflare: Acme's Account · project crewline", "Netlify: team Acme · site crewline", "Vercel: team acme · project crewline" (#360). */
|
|
@@ -233,7 +245,7 @@ export function hostLines(used) {
|
|
|
233
245
|
byHost.set(u.host, entry);
|
|
234
246
|
}
|
|
235
247
|
return [...byHost].map(([host, { accounts, projects }]) => {
|
|
236
|
-
const what = host === 'Netlify' ? 'site' : 'project';
|
|
248
|
+
const what = host === 'Netlify' || host === 'Render' ? 'site' : 'project';
|
|
237
249
|
const account = accounts.size ? `${host === 'Cloudflare' ? '' : 'team '}${[...accounts].join(', ')} · ` : '';
|
|
238
250
|
return `${host}: ${account}${projects.size > 1 ? `${what}s` : what} ${[...projects].join(', ')}`;
|
|
239
251
|
});
|
|
@@ -267,9 +279,10 @@ const REPORT = {
|
|
|
267
279
|
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.`,
|
|
268
280
|
items: { type: 'string', minLength: 1 },
|
|
269
281
|
},
|
|
282
|
+
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.' },
|
|
270
283
|
apps: {
|
|
271
284
|
type: 'array',
|
|
272
|
-
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". Names only, never an address or a key.',
|
|
285
|
+
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.',
|
|
273
286
|
items: { type: 'object', additionalProperties: false, required: ['appKey', 'reason'], properties: { appKey: { type: 'string', minLength: 1 }, reason: { type: 'string', minLength: 1 } } },
|
|
274
287
|
},
|
|
275
288
|
},
|
|
@@ -328,6 +341,14 @@ const NETLIFY_PREVIEWS = {
|
|
|
328
341
|
pull: { type: 'integer', minimum: 1, description: 'A pull request of this run, once pushed: reads its previews only, and waits up to five minutes for one to finish building.' },
|
|
329
342
|
},
|
|
330
343
|
};
|
|
344
|
+
const RENDER_PREVIEWS = {
|
|
345
|
+
type: 'object',
|
|
346
|
+
additionalProperties: false,
|
|
347
|
+
properties: {
|
|
348
|
+
appKey: APP_KEY,
|
|
349
|
+
pull: { type: 'integer', minimum: 1, description: 'A pull request of this run, a draft included, once pushed: reads its Render preview only, and waits up to five minutes for it to finish building.' },
|
|
350
|
+
},
|
|
351
|
+
};
|
|
331
352
|
const CLOUDFLARE_PREVIEWS = {
|
|
332
353
|
type: 'object',
|
|
333
354
|
additionalProperties: false,
|
|
@@ -342,7 +363,7 @@ const PROVE_PREVIEW = {
|
|
|
342
363
|
additionalProperties: false,
|
|
343
364
|
required: ['url', 'expect'],
|
|
344
365
|
properties: {
|
|
345
|
-
url: { type: 'string', minLength: 1, description: 'An address impeccable_hosted_previews, impeccable_netlify_previews or
|
|
366
|
+
url: { type: 'string', minLength: 1, description: 'An address impeccable_hosted_previews, impeccable_netlify_previews, impeccable_cloudflare_previews or impeccable_render_previews listed, or the live app\'s address one of them answered as production or a file of this checkout names, or a page on it.' },
|
|
346
367
|
expect: { type: 'string', minLength: 1, description: 'Words the page shows that a file of this checkout holds, such as the app\'s name in its title.' },
|
|
347
368
|
},
|
|
348
369
|
};
|
|
@@ -410,6 +431,8 @@ const NETLIFY_CONNECT = {
|
|
|
410
431
|
publish: { type: 'string', minLength: 1, description: 'The folder the build writes the pages to, relative to the checkout, only where no netlify.toml sets one.' },
|
|
411
432
|
},
|
|
412
433
|
};
|
|
434
|
+
/** A question that asks for an address: a URL, a domain, or the site's, the live app's or a preview's address or link. */
|
|
435
|
+
const ASKS_ADDRESS = /\b(urls?|domains?|(web|site|live|production|preview|app's) (address|link)(es|s)?)\b|https?:\/\//i;
|
|
413
436
|
/** 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. */
|
|
414
437
|
function askError(q) {
|
|
415
438
|
const values = (q.options ?? []).map((o) => o.value);
|
|
@@ -1621,6 +1644,8 @@ export function createTools(ctx) {
|
|
|
1621
1644
|
const budget = () => ({ used: retry.used, pushReservations: retry.pushReservations, remaining: Math.max(0, 3 - retry.used), historyKnown: retry.historyKnown });
|
|
1622
1645
|
const attemptTasks = () => attempts().map((a, i) => ({ id: `preview-attempt-${a.runId}-${a.attempt}`,
|
|
1623
1646
|
title: `Preview job attempt ${i + 1}`, status: a.status === 'success' ? 'completed' : a.status === 'running' ? 'in_progress' : 'failed' }));
|
|
1647
|
+
/** The task list's order, the terminal's and the web page's alike (REN-1119). */
|
|
1648
|
+
const taskOrder = new TaskOrder();
|
|
1624
1649
|
const withAttempts = (tasks) => [...tasks.filter((t) => !t.id.startsWith('preview-attempt-')), ...attemptTasks()];
|
|
1625
1650
|
async function observe(signal) {
|
|
1626
1651
|
const pr = retry.pr;
|
|
@@ -1652,6 +1677,10 @@ export function createTools(ctx) {
|
|
|
1652
1677
|
/** The origins of Production deployments Vercel made of a pushed branch that no locked project of this folder's made: served at a project's own address without a login, so no proof opens them. */
|
|
1653
1678
|
const publicProduction = new Set();
|
|
1654
1679
|
const cloudflareListed = new Set();
|
|
1680
|
+
/** The address pattern of each Worker preview's origin Cloudflare's evidence listed, which the address alone cannot tell (#265), and of the Worker's own address it answered as production, the token scope they share: kept past a later read, for the last screen. */
|
|
1681
|
+
const workerPatterns = new Map();
|
|
1682
|
+
/** The origins of the successful Render previews impeccable_render_previews listed, of the app's own site. */
|
|
1683
|
+
const renderListed = new Set();
|
|
1655
1684
|
let hostedPreviewProven = false;
|
|
1656
1685
|
/**
|
|
1657
1686
|
* The address pattern each proof answered, by the app it was proven for, and the last one under '' for a repository
|
|
@@ -1692,9 +1721,15 @@ export function createTools(ctx) {
|
|
|
1692
1721
|
const label = pagesProject(origin);
|
|
1693
1722
|
if (label)
|
|
1694
1723
|
return [{ host: 'Cloudflare', project: label, ...(accountOf.has(`cloudflare ${label}`) ? { account: accountOf.get(`cloudflare ${label}`) } : {}) }];
|
|
1724
|
+
const worker = /^https:\/\/\{branch\}-([^.]+)\./.exec(workerPatterns.get(origin) ?? '')?.[1];
|
|
1725
|
+
if (worker)
|
|
1726
|
+
return [{ host: 'Cloudflare', project: worker }];
|
|
1695
1727
|
const site = netlifySiteOf(url);
|
|
1696
1728
|
if (site)
|
|
1697
1729
|
return [{ host: 'Netlify', project: site, ...(accountOf.has(`netlify ${site}`) ? { account: accountOf.get(`netlify ${site}`) } : {}) }];
|
|
1730
|
+
const render = renderSiteOf(url);
|
|
1731
|
+
if (render)
|
|
1732
|
+
return [{ host: 'Render', project: render.site }];
|
|
1698
1733
|
const read = vercelNamed.get(origin);
|
|
1699
1734
|
const named = read?.project ?? listedApps.get(origin)?.name;
|
|
1700
1735
|
const vercel = vercelDeploymentOf(url, named);
|
|
@@ -1796,6 +1831,9 @@ export function createTools(ctx) {
|
|
|
1796
1831
|
const cloudflareApps = new Map();
|
|
1797
1832
|
/** What impeccable_cloudflare_connect answered: asked once, so that answer stands. */
|
|
1798
1833
|
let connectOutcome;
|
|
1834
|
+
/** Each app's Render site, by key ('' for a repository of one app), as impeccable_render_previews read it, and the service's page; and what the person answered on its Render screen, asked once. */
|
|
1835
|
+
const renderApps = new Map();
|
|
1836
|
+
const renderAnswers = new Map();
|
|
1799
1837
|
// Keep ownership of files for this one run; never recreate it per call.
|
|
1800
1838
|
const workflowSynchronizer = ctx.workflowSynchronizer ?? createWorkflowSynchronizer({ status: (line) => said(line) });
|
|
1801
1839
|
const workflowRefusal = (reason) => ({ ok: false, reason, actions: [] });
|
|
@@ -1856,6 +1894,29 @@ export function createTools(ctx) {
|
|
|
1856
1894
|
ctx.sync.update({ phase: 'running', question: null });
|
|
1857
1895
|
return answer;
|
|
1858
1896
|
};
|
|
1897
|
+
/**
|
|
1898
|
+
* After the person's yes to turning a host's pull-request previews on: the host's CLI changes that one setting and reads
|
|
1899
|
+
* 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.
|
|
1900
|
+
*/
|
|
1901
|
+
const turnOnPreviews = async (host, turnOn, signal) => {
|
|
1902
|
+
said(`Turning on pull-request previews on ${{ netlify: 'Netlify', cloudflare: 'Cloudflare Pages', render: 'Render' }[host]}`);
|
|
1903
|
+
for (let tries = 0;; tries++) {
|
|
1904
|
+
const outcome = await turnOn();
|
|
1905
|
+
if (outcome.fact.enabled === true) {
|
|
1906
|
+
previewFacts.set(host, outcome.fact);
|
|
1907
|
+
return { status: 'enabled', by: outcome.changed ? 'run' : 'person' };
|
|
1908
|
+
}
|
|
1909
|
+
const why = outcome.reason ?? outcome.fact.reason;
|
|
1910
|
+
if (tries === 2 || signal?.aborted)
|
|
1911
|
+
return { status: 'refused', reason: why, page: outcome.page };
|
|
1912
|
+
const retry = HOST_SCREENS[`${host}-previews-retry`];
|
|
1913
|
+
// The host's page opens once, before the first retry screen, which speaks of it; the browser may not have opened (BROWSER=none, no opener), so the address is on the screen as well.
|
|
1914
|
+
if (tries === 0)
|
|
1915
|
+
(ctx.openBrowser ?? openBrowser)(outcome.page);
|
|
1916
|
+
if ((await ask({ ...retry, hint: `${retry.hint} If it did not open, open this link: ${outcome.page}` })).skipped)
|
|
1917
|
+
return { status: 'skipped', reason: why, page: outcome.page };
|
|
1918
|
+
}
|
|
1919
|
+
};
|
|
1859
1920
|
/** Each host's sign-in this run: confirmed on its screen, so the screen shows once; skipped, so nothing more is asked for that host. */
|
|
1860
1921
|
const hostSignIns = new Map();
|
|
1861
1922
|
/** The teams a host's sign-in offered, each by name and slug: the choices a `<host>-team` question shows, whatever the model lists, so the pick the fence takes is a slug. */
|
|
@@ -2329,8 +2390,22 @@ export function createTools(ctx) {
|
|
|
2329
2390
|
return proof;
|
|
2330
2391
|
};
|
|
2331
2392
|
/** A proof opened the app's preview at `url`, so its method may be saved. */
|
|
2393
|
+
/** On a repository with several apps, a Preview method task done while a picked app has no method saved names whose it is, so its tick does not read as every app's (REN-1108). */
|
|
2394
|
+
const labelled = (tasks) => {
|
|
2395
|
+
if (!appsRepo)
|
|
2396
|
+
return tasks;
|
|
2397
|
+
const saved = [...(pickedApps ?? [])].filter((key) => appMethods.has(key));
|
|
2398
|
+
const some = saved.length > 0 && saved.length < pickedApps.size;
|
|
2399
|
+
// A label of an earlier list goes once every picked app has its method.
|
|
2400
|
+
return tasks.map((t) => (t.status !== 'completed' || !stagesOf(t.title).includes('Preview method') ? t : { ...t, title: some ? `Preview method: ${andList(saved.map(appName))} only` : t.title.replace(/^Preview method: .* only$/, 'Preview method') }));
|
|
2401
|
+
};
|
|
2402
|
+
/** On a repository with several apps, the host whose preview a proof opened for each app: the last screen names them all, or none where an app is not set up (REN-1108). */
|
|
2403
|
+
const appHosts = new Map();
|
|
2332
2404
|
const proveApp = (key, url) => {
|
|
2333
2405
|
provenApps.add(key);
|
|
2406
|
+
const host = previewHostName(url);
|
|
2407
|
+
if (host)
|
|
2408
|
+
appHosts.set(key, host);
|
|
2334
2409
|
noteApp(key, { preview_proven: true, preview_host: new URL(url).hostname });
|
|
2335
2410
|
};
|
|
2336
2411
|
/** Each app's folder, by key, as the state names it: what its host project or site must build. */
|
|
@@ -2629,7 +2704,7 @@ export function createTools(ctx) {
|
|
|
2629
2704
|
}
|
|
2630
2705
|
if (recordedChanges)
|
|
2631
2706
|
previews.changes.clear();
|
|
2632
|
-
const tasks = withAttempts(ctx.sync.payload.tasks);
|
|
2707
|
+
const tasks = taskOrder.sort(withAttempts(ctx.sync.payload.tasks));
|
|
2633
2708
|
ctx.sync.update({ tasks });
|
|
2634
2709
|
reportTasks(tasks);
|
|
2635
2710
|
return JSON.stringify({ ...observation, attempts: attempts(), budget: budget() });
|
|
@@ -2657,7 +2732,7 @@ export function createTools(ctx) {
|
|
|
2657
2732
|
}
|
|
2658
2733
|
const saved = object(p.body);
|
|
2659
2734
|
if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !== provenPatterns.get(appsRepo ? String(saved.appKey) : ''))) {
|
|
2660
|
-
return `Refused: the only address pattern saved is the one impeccable_prove_preview answered for ${appsRepo ? `this app's (${String(saved.appKey)})` : 'a'} Netlify or
|
|
2735
|
+
return `Refused: the only address pattern saved is the one impeccable_prove_preview answered for ${appsRepo ? `this app's (${String(saved.appKey)})` : 'a'} Netlify, Cloudflare or Render preview. Nothing was saved.`;
|
|
2661
2736
|
}
|
|
2662
2737
|
// The before is the live app's address, and only one a proof opened with this app: an address no key opens is not set, and one the person saved is never cleared from here.
|
|
2663
2738
|
if (target.url.endsWith('/preview') && saved && 'beforeUrl' in saved && (typeof saved.beforeUrl !== 'string' || !URL.canParse(saved.beforeUrl) || !provenLive.has(new URL(saved.beforeUrl).origin))) {
|
|
@@ -2692,14 +2767,48 @@ export function createTools(ctx) {
|
|
|
2692
2767
|
let response = await call(pi, target.url, { method: target.method, body });
|
|
2693
2768
|
// The setup API checks a repository's preview once per 30 seconds, whichever app asks: the next app's check waits its turn here, a bounded wait in place of the sleep the fence refuses (#338).
|
|
2694
2769
|
const check = /\/(connection-check|latest-pull-check)$/.test(target.url);
|
|
2770
|
+
const pause = ctx.checkWait ?? ((ms, stop) => sleep(ms, undefined, { signal: stop }));
|
|
2695
2771
|
for (let tries = 0; check && response.status === 429 && tries < CHECK_RETRIES && !signal?.aborted; tries++) {
|
|
2696
2772
|
const seconds = Math.min(Math.max(Math.ceil(response.retryAfter ?? 30), 1), 60);
|
|
2697
2773
|
said(`Waiting ${seconds} s for the next preview check`);
|
|
2698
|
-
await (
|
|
2774
|
+
await pause(seconds * 1000, signal).catch(() => undefined);
|
|
2699
2775
|
if (signal?.aborted)
|
|
2700
2776
|
break;
|
|
2701
2777
|
response = await call(pi, target.url, { method: target.method, body });
|
|
2702
2778
|
}
|
|
2779
|
+
// A password the save's sign-in turned away is asked for once more, on the same account screen, and the list saved again once the setup API can sign in again (#249).
|
|
2780
|
+
const tried = object(object(response.data)?.signInCheck)?.accounts;
|
|
2781
|
+
const turnedAway = new Set(testAccounts && response.ok && Array.isArray(tried) ? tried.filter((a) => object(a)?.outcome === 'wrong-credentials').map((a) => String(object(a)?.role)) : []);
|
|
2782
|
+
if (turnedAway.size) {
|
|
2783
|
+
const checkedAt = Date.now();
|
|
2784
|
+
let retyped = false;
|
|
2785
|
+
for (const account of object(body)?.accounts) {
|
|
2786
|
+
const role = String(account.role ?? 'default');
|
|
2787
|
+
if (!turnedAway.has(role))
|
|
2788
|
+
continue;
|
|
2789
|
+
const answer = await ask({ id: `${role}-account`, kind: 'account', title: role === 'default' ? 'Test account' : `${role.charAt(0).toUpperCase()}${role.slice(1)} account`, prompt: `Add the ${role === 'default' ? '' : `${role} `}test account.`, hint: `That password did not sign in${typeof account.loginPage === 'string' ? ` at ${account.loginPage}` : ''}. Type it again, or skip.`, prefill: String(account.username ?? '') });
|
|
2790
|
+
// A stop closes the screen: no other account is asked for, and nothing is saved again.
|
|
2791
|
+
if (answer.closed || signal?.aborted) {
|
|
2792
|
+
retyped = false;
|
|
2793
|
+
break;
|
|
2794
|
+
}
|
|
2795
|
+
if (answer.skipped)
|
|
2796
|
+
continue;
|
|
2797
|
+
// Kept with what the person typed, so the answer never carries it back to the model.
|
|
2798
|
+
typed.set(`secret:${randomBytes(6).toString('hex')}`, answer.password ?? '');
|
|
2799
|
+
Object.assign(account, { username: answer.username, password: answer.password });
|
|
2800
|
+
retyped = true;
|
|
2801
|
+
}
|
|
2802
|
+
const wait = SIGN_IN_CHECK_MS - (Date.now() - checkedAt);
|
|
2803
|
+
if (retyped && wait > 0) {
|
|
2804
|
+
said(`Waiting ${Math.ceil(wait / 1000)} s for the next sign-in check`);
|
|
2805
|
+
await pause(wait, signal).catch(() => undefined);
|
|
2806
|
+
}
|
|
2807
|
+
// A second save that fails leaves the first one's answer: those accounts are saved.
|
|
2808
|
+
const again = retyped && !signal?.aborted ? await call(pi, target.url, { method: target.method, body }) : undefined;
|
|
2809
|
+
if (again?.ok)
|
|
2810
|
+
response = again;
|
|
2811
|
+
}
|
|
2703
2812
|
if (testAccounts && response.ok)
|
|
2704
2813
|
retry.signedIn?.saved.add(appsRepo ? String(object(p.body)?.appKey) : '');
|
|
2705
2814
|
// The accounts the save signed in with, said on screen: the tasks show no status line (#417).
|
|
@@ -2809,6 +2918,9 @@ export function createTools(ctx) {
|
|
|
2809
2918
|
return `Invalid input: ${error}.`;
|
|
2810
2919
|
if (hidden)
|
|
2811
2920
|
ctx.log.write('ask hidden (names a credential)', q.id);
|
|
2921
|
+
// An address is the host tools' to find, never the person's to type (REN-1104): a preview's and the live app's alike.
|
|
2922
|
+
if (q.kind === 'text' && !q.sensitive && ASKS_ADDRESS.test(`${q.title ?? ''} ${q.prompt} ${q.hint ?? ''}`))
|
|
2923
|
+
return "Refused: the person is never asked to type an address. The host tools find the previews and the live app (impeccable_hosted_previews, impeccable_netlify_previews, impeccable_cloudflare_previews, impeccable_render_previews); where none does, the preview goes as server-previews says, and the report says what was not found.";
|
|
2812
2924
|
if (q.kind === 'text' && !q.sensitive)
|
|
2813
2925
|
q.caution = NEVER_A_CREDENTIAL;
|
|
2814
2926
|
// The apps question's screen is the tool's to show (TOOL_SHOWN), so the web page hears of it here; it has no Skip (#405).
|
|
@@ -2843,7 +2955,6 @@ export function createTools(ctx) {
|
|
|
2843
2955
|
}),
|
|
2844
2956
|
tool('impeccable_progress', 'Progress', 'Show the person where things stand, here and on the web page: the [STATUS] line and the whole task list. It refuses, saving nothing, a status line out of the playbook\'s voice, more than one task in progress, a status line about another task than the one in progress, a task once completed that is no longer, Preview method completed before a method is saved, and a Preview workflow or Setup pull request task once a host\'s previews are the method.', PROGRESS, async (p) => {
|
|
2845
2957
|
const status = p.status.trim();
|
|
2846
|
-
const tasks = withAttempts(p.tasks);
|
|
2847
2958
|
const own = p.tasks.filter((t) => !t.id.startsWith('preview-attempt-'));
|
|
2848
2959
|
const voice = statusVoiceError(status) ?? stageError(status, own);
|
|
2849
2960
|
if (voice)
|
|
@@ -2858,6 +2969,8 @@ export function createTools(ctx) {
|
|
|
2858
2969
|
const unneeded = hostMethod && !perApp && !ctx.sync.payload.setup_pr ? own.filter((t) => /\bsetup pull request\b/i.test(t.title) || stagesOf(t.title).includes('Preview workflow')) : [];
|
|
2859
2970
|
if (unneeded.length)
|
|
2860
2971
|
return `Refused: ${savedHost ?? 'the host'} builds this run's previews, so the repository needs no workflow and no setup pull request. Leave ${unneeded.map((t) => `"${t.title}"`).join(' and ')} off the list: a task the run never does is not shown, not even as skipped. Nothing was saved: call impeccable_progress again.`;
|
|
2972
|
+
// Ordered only once taken, so a refused list leaves the order as it was.
|
|
2973
|
+
const tasks = taskOrder.sort(labelled(withAttempts(p.tasks)));
|
|
2861
2974
|
ctx.sync.update({ phase: 'running', status, tasks });
|
|
2862
2975
|
reportStatus(status);
|
|
2863
2976
|
reportTasks(tasks);
|
|
@@ -3007,13 +3120,24 @@ export function createTools(ctx) {
|
|
|
3007
3120
|
if (a.ended)
|
|
3008
3121
|
noteApp(a.key, { reason: `The setup ended for it: the person said no to ${SETUP_ENDS[a.ended].said}.` });
|
|
3009
3122
|
for (const { appKey, reason } of p.apps ?? []) {
|
|
3010
|
-
const plain = reason
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3123
|
+
const plain = plainReason(reason);
|
|
3124
|
+
if (!plain)
|
|
3125
|
+
continue;
|
|
3126
|
+
noteApp(appKey, { reason: plain });
|
|
3127
|
+
// The last screen says why on the app's own line, rather than sending the person to the report (REN-1108).
|
|
3128
|
+
const app = apps?.find((a) => a.key === appKey && a.state === 'unset' && !a.ended);
|
|
3129
|
+
if (app)
|
|
3130
|
+
app.left = `${app.name}: ${plain.replace(/[.\s]+$/, '')}.`;
|
|
3014
3131
|
}
|
|
3132
|
+
const reason = !appsRepo && typeof p.reason === 'string' ? plainReason(p.reason) : undefined;
|
|
3133
|
+
// The hosts build every preview only where every picked app is set up with a host's previews: on a repository where one app is not, no host speaks for it (REN-1108).
|
|
3134
|
+
const hostsOf = picked?.every((a) => a.state === 'ready' && appMethods.get(a.key) === 'preview-deploy' && appHosts.has(a.key)) ? [...new Set(picked.map((a) => appHosts.get(a.key)))] : [];
|
|
3135
|
+
const hostedBy = apps ? (hostsOf.length ? andList(hostsOf) : undefined) : savedHost;
|
|
3015
3136
|
// The web page lists what is left too, within the Worker's 20 lines (REPORT and the check above keep each line non-empty and short).
|
|
3016
|
-
|
|
3137
|
+
// The list as the run left the apps: a Preview method done for some apps only says whose (REN-1108).
|
|
3138
|
+
const tasks = taskOrder.sort(labelled(ctx.sync.payload.tasks));
|
|
3139
|
+
reportTasks(tasks);
|
|
3140
|
+
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) });
|
|
3017
3141
|
const res = await ctx.sync.flush();
|
|
3018
3142
|
if (res && res.status >= 400 && res.status < 500) {
|
|
3019
3143
|
// 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.
|
|
@@ -3028,7 +3152,7 @@ export function createTools(ctx) {
|
|
|
3028
3152
|
}
|
|
3029
3153
|
// Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
|
|
3030
3154
|
const hosts = hostLines(hostsUsed());
|
|
3031
|
-
ctx.onReport({ markdown, left, testPr: drive, ...(several ? { testPrs: kept.map(([key, pr]) => ({ appKey: key, name: appName(key), ...pr })) } : {}), setupPr: ctx.sync.payload.setup_pr, ...(unhonoured ? { unhonoured } : {}), ...(
|
|
3155
|
+
ctx.onReport({ markdown, left, testPr: drive, ...(several ? { testPrs: kept.map(([key, pr]) => ({ appKey: key, name: appName(key), ...pr })) } : {}), setupPr: ctx.sync.payload.setup_pr, ...(unhonoured ? { unhonoured } : {}), ...(hostedBy ? { hostedBy } : {}), ...(reason ? { reason } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock === 'netlify-protection' && lastTeamLogin ? 'netlify-login' : lastLock } : {}), ...(!apps && checkWalls.size ? { walled: [...checkWalls.values()].at(-1) } : {}), ...(hosts.length ? { hosts } : {}), ...(ended ? { ended } : {}) });
|
|
3032
3156
|
pi.shutdown();
|
|
3033
3157
|
const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
|
|
3034
3158
|
return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
|
|
@@ -3189,7 +3313,7 @@ export function createTools(ctx) {
|
|
|
3189
3313
|
noteApp(appKey, { host_project: site });
|
|
3190
3314
|
return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
|
|
3191
3315
|
}),
|
|
3192
|
-
tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in; where cf is not installed here, the person is asked to install it first, on its own screen). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds),
|
|
3316
|
+
tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in; where cf is not installed here, the person is asked to install it first, on its own screen). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds); 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) => {
|
|
3193
3317
|
const pull = p.pull;
|
|
3194
3318
|
const branch = p.branch;
|
|
3195
3319
|
if (pull !== undefined && branch !== undefined)
|
|
@@ -3207,7 +3331,9 @@ export function createTools(ctx) {
|
|
|
3207
3331
|
return `Refused: read impeccable_cloudflare_previews with appKey ${appKey} and no pull or branch first: it tells which Pages project builds that app.`;
|
|
3208
3332
|
// The name of the account the project was found in is for the last screen alone.
|
|
3209
3333
|
let accountName;
|
|
3210
|
-
|
|
3334
|
+
// A Worker Preview's build names no address: its alias is made from the pattern the plain read's Worker preview stood for.
|
|
3335
|
+
const pattern = cloudflareEvidence?.previews.find((preview) => preview.pattern)?.pattern;
|
|
3336
|
+
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 } : {}) });
|
|
3211
3337
|
const read = async () => {
|
|
3212
3338
|
const evidence = await readAll();
|
|
3213
3339
|
return appProject ? { ...evidence, previews: evidence.previews.filter((preview) => pagesProject(preview.url) === appProject.label) } : evidence;
|
|
@@ -3220,7 +3346,7 @@ export function createTools(ctx) {
|
|
|
3220
3346
|
// unless it covers them all, or Cloudflare built a preview of the repository already, which it could not without the app.
|
|
3221
3347
|
const app = cloudflareEvidence?.githubApp;
|
|
3222
3348
|
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;
|
|
3223
|
-
let waited = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal, access) : await read();
|
|
3349
|
+
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();
|
|
3224
3350
|
// cf not on this machine (#396): its install is asked before anything installs it, and the evidence read again with it.
|
|
3225
3351
|
if (!subject && waited.cli === 'missing') {
|
|
3226
3352
|
await installCf(pi, signal);
|
|
@@ -3247,10 +3373,16 @@ export function createTools(ctx) {
|
|
|
3247
3373
|
continue;
|
|
3248
3374
|
for (const address of [preview.url, ...(preview.alias ? [preview.alias] : [])]) {
|
|
3249
3375
|
cloudflareListed.add(new URL(address).origin);
|
|
3376
|
+
if (preview.pattern)
|
|
3377
|
+
workerPatterns.set(new URL(address).origin, preview.pattern);
|
|
3250
3378
|
if (appKey !== undefined)
|
|
3251
3379
|
listedApps.set(new URL(address).origin, { appKey });
|
|
3252
3380
|
}
|
|
3253
3381
|
}
|
|
3382
|
+
// The Worker's own address, the live app with no Pages project, is in its previews' scope: Access's token goes there as it goes to a Pages project's own address.
|
|
3383
|
+
const workerPattern = evidence.previews.find((preview) => preview.pattern)?.pattern;
|
|
3384
|
+
if (workerPattern && !evidence.project && evidence.production)
|
|
3385
|
+
workerPatterns.set(new URL(evidence.production).origin, workerPattern);
|
|
3254
3386
|
if (evidence.pullRequestPreviews)
|
|
3255
3387
|
previewFacts.set('cloudflare', evidence.pullRequestPreviews);
|
|
3256
3388
|
const named = evidence.pullRequestPreviews?.project || evidence.project;
|
|
@@ -3259,29 +3391,44 @@ export function createTools(ctx) {
|
|
|
3259
3391
|
noteProduction(evidence.unmatched ? undefined : evidence.production, 'cloudflare', undefined, appKey);
|
|
3260
3392
|
return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
|
|
3261
3393
|
}),
|
|
3262
|
-
tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews, impeccable_netlify_previews or
|
|
3394
|
+
tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews, impeccable_netlify_previews, impeccable_cloudflare_previews or impeccable_render_previews listed for a pull request or a pushed branch (a successful Preview deployment, a deploy preview that is ready, a Pages build that succeeded, or a Render preview), or a page on it, as a visitor signed in nowhere, and prove it: it answers, and the page shows words a file of this checkout holds. proven true lets impeccable_write save intendedMethod "preview-deploy"; for Netlify, Cloudflare (Pages, or a Worker\'s previews) and Render it answers the address pattern to save beside it. gate "vercel-protection", "netlify-protection" or "cloudflare-access" means the host\'s protection stands in front of it. Or open the live app\'s address, the production one of those tools answered or one a file of this checkout names (the README\'s live-site line), or a page on it, the same way: proven true lets impeccable_write save it as beforeUrl, and nothing else. A key the run got in place for the same project or site goes with it. Nothing of the page comes back.', PROVE_PREVIEW, async (p, pi, signal) => {
|
|
3263
3395
|
const url = p.url;
|
|
3264
3396
|
const origin = URL.canParse(url) ? new URL(url).origin : null;
|
|
3265
3397
|
if (origin && publicProduction.has(origin))
|
|
3266
3398
|
return "Refused: Vercel called this deployment Production, as it calls a project's first whatever branch it builds, and no project of this folder's that is locked at every address made it, so it is served at a project's own address without a login: it is no preview, and nothing was opened. No test drive, as the vercel-setup case says.";
|
|
3267
|
-
const listed = origin !== null && (listedPreviews.has(origin) || netlifyListed.has(origin) || cloudflareListed.has(origin));
|
|
3399
|
+
const listed = origin !== null && (listedPreviews.has(origin) || netlifyListed.has(origin) || cloudflareListed.has(origin) || renderListed.has(origin));
|
|
3268
3400
|
// The live app's address, for the before: one a host tool answered as production, or one a file the checkout tracks names, such as the README's live-site line, when it is the site's or project's own address on one of the hosts: a deploy preview or branch alias a file links stays a preview, listed or not, and an address elsewhere, which could be on the person's own network, is only a host tool's to name.
|
|
3269
3401
|
const known = origin !== null ? productionListed.get(origin) : undefined;
|
|
3270
3402
|
const live = origin !== null && !listed && (known !== undefined || (liveShaped(url) && (await checkoutNames(pi.cwd, origin))));
|
|
3271
3403
|
if (!origin || (!listed && !live))
|
|
3272
|
-
return "Refused: open the address of a preview that impeccable_hosted_previews, impeccable_netlify_previews or
|
|
3404
|
+
return "Refused: open the address of a preview that impeccable_hosted_previews, impeccable_netlify_previews, impeccable_cloudflare_previews or impeccable_render_previews listed for a pull request or a pushed branch, or a page on it; or, for the before, the live app's address one of them answered as production, or the site's or project's own address on one of the hosts that a file of this checkout names.";
|
|
3273
3405
|
// The Netlify site, by the address's shape or, for the site's custom domain, as its tool named it: its password goes to the site's own addresses.
|
|
3274
3406
|
const site = netlifySiteOf(url) ?? known?.site;
|
|
3275
|
-
// A saved service token opens its Pages project's previews: Access admits it wherever the application's policy covers.
|
|
3276
|
-
const project = pagesProject(origin);
|
|
3407
|
+
// A saved service token opens its Pages project's previews, or its Worker's: Access admits it wherever the application's policy covers.
|
|
3408
|
+
const project = pagesProject(origin) ?? workerPatterns.get(origin);
|
|
3277
3409
|
const token = accessToken && accessStored && project && accessProjects.has(project) ? accessToken : undefined;
|
|
3278
|
-
const
|
|
3410
|
+
const prove = () => provePreview(url, p.expect, pi.cwd, {
|
|
3279
3411
|
fetch: ctx.previewFetch, signal, live: live ? (known?.host ?? true) : false,
|
|
3280
3412
|
// The project's bypass secret opens the project's own addresses: the previews it proved, and the production address read from the same project.
|
|
3281
3413
|
...(bypassFor(origin, live) ? { bypass: bypassFor(origin, live) } : {}),
|
|
3282
3414
|
...(site && sitePasswords.has(site) ? { password: sitePasswords.get(site).value } : {}),
|
|
3283
3415
|
...(token ? { access: token } : {}),
|
|
3284
3416
|
});
|
|
3417
|
+
let proved = await prove();
|
|
3418
|
+
// 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.
|
|
3419
|
+
if (!live && renderListed.has(origin) && proved.status === 404) {
|
|
3420
|
+
const wait = ctx.hostWait?.serve ?? { totalMs: 90_000, everyMs: 5_000 };
|
|
3421
|
+
said('Waiting for Render to serve the new page');
|
|
3422
|
+
for (const end = Date.now() + wait.totalMs; proved.status === 404 && Date.now() < end && !signal?.aborted;) {
|
|
3423
|
+
await sleep(wait.everyMs, undefined, { signal }).catch(() => undefined);
|
|
3424
|
+
if (!signal?.aborted)
|
|
3425
|
+
proved = await prove();
|
|
3426
|
+
}
|
|
3427
|
+
if (proved.status === 404 && !signal?.aborted)
|
|
3428
|
+
proved = { ...proved, reason: `The address still answered 404 ${Math.round(wait.totalMs / 1000)} s after Render reported the preview built: the page is not in the preview's build.` };
|
|
3429
|
+
}
|
|
3430
|
+
// A Worker's preview proven answers the pattern its evidence listed.
|
|
3431
|
+
const proof = proved.proven && !live && workerPatterns.has(origin) ? { ...proved, pattern: workerPatterns.get(origin) } : proved;
|
|
3285
3432
|
// A live address proven is the before, and nothing else: no method, no pattern and no lock to get past follow from it.
|
|
3286
3433
|
if (live) {
|
|
3287
3434
|
if (proof.proven)
|
|
@@ -3334,6 +3481,109 @@ export function createTools(ctx) {
|
|
|
3334
3481
|
return JSON.stringify({ ...proof, appKey: app.appKey });
|
|
3335
3482
|
return JSON.stringify(proof);
|
|
3336
3483
|
}),
|
|
3484
|
+
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 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, 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) => {
|
|
3485
|
+
const pull = p.pull;
|
|
3486
|
+
const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
|
|
3487
|
+
if (refused)
|
|
3488
|
+
return refused;
|
|
3489
|
+
const appKey = appsRepo ? p.appKey : undefined;
|
|
3490
|
+
const key = appKey ?? '';
|
|
3491
|
+
const forApp = appKey !== undefined ? { appKey } : {};
|
|
3492
|
+
const folder = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
|
|
3493
|
+
if (appKey !== undefined && folder === undefined)
|
|
3494
|
+
return JSON.stringify({ appKey, previews: [], unmatched: `The setup state names no folder for the app ${appKey}.` });
|
|
3495
|
+
const known = renderApps.get(key);
|
|
3496
|
+
if (pull !== undefined && !known)
|
|
3497
|
+
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'}.`;
|
|
3498
|
+
// 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.
|
|
3499
|
+
const readEvidence = (branch) => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, ...(branch !== undefined ? { branch } : {}) });
|
|
3500
|
+
const previewsOf = (evidence, site) => evidence.deployments.flatMap((d) => {
|
|
3501
|
+
const preview = parseRenderDeployment(d.environment, d.url);
|
|
3502
|
+
return preview && preview.site === site && (pull === undefined || preview.pull === pull) ? [{ pull: preview.pull, url: d.url, state: d.state }] : [];
|
|
3503
|
+
});
|
|
3504
|
+
// The successful previews are the ones a proof may open, each the app's.
|
|
3505
|
+
const list = (previews) => {
|
|
3506
|
+
for (const origin of renderListed)
|
|
3507
|
+
listedApps.delete(origin);
|
|
3508
|
+
renderListed.clear();
|
|
3509
|
+
for (const preview of previews) {
|
|
3510
|
+
if (preview.state !== 'success')
|
|
3511
|
+
continue;
|
|
3512
|
+
renderListed.add(new URL(preview.url).origin);
|
|
3513
|
+
if (appKey !== undefined)
|
|
3514
|
+
listedApps.set(new URL(preview.url).origin, { appKey });
|
|
3515
|
+
}
|
|
3516
|
+
};
|
|
3517
|
+
if (pull !== undefined) {
|
|
3518
|
+
const found = await ctx.viewPullRequest(pull);
|
|
3519
|
+
if (!('headRefName' in found))
|
|
3520
|
+
return `Refused: #${pull} could not be read: ${found.error}`;
|
|
3521
|
+
const read = async () => {
|
|
3522
|
+
const evidence = await readEvidence(found.headRefName);
|
|
3523
|
+
return { previews: previewsOf(evidence, known.site), ...(evidence.error ? { error: evidence.error } : {}) };
|
|
3524
|
+
};
|
|
3525
|
+
const final = (e) => Boolean(e.error) || e.previews.some((preview) => preview.state === 'success' || preview.state === 'failure' || preview.state === 'error');
|
|
3526
|
+
const waited = await awaitBuild('Render', `Waiting for Render to build ${await subjectOf(pull)}`, read, final, () => known.page, signal);
|
|
3527
|
+
list(waited.previews);
|
|
3528
|
+
return JSON.stringify({ ...forApp, site: known.site, ...waited });
|
|
3529
|
+
}
|
|
3530
|
+
const render = (ctx.hostRunner ?? commandRunner)('render', pi.cwd, { signal });
|
|
3531
|
+
const read = await readRenderService(render, repository, folder);
|
|
3532
|
+
const service = 'service' in read ? read.service : undefined;
|
|
3533
|
+
const evidence = await readEvidence();
|
|
3534
|
+
let site = service?.site;
|
|
3535
|
+
// Without the CLI's answer, the site 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.
|
|
3536
|
+
if (!site) {
|
|
3537
|
+
const recorded = evidence.deployments.flatMap((d) => parseRenderDeployment(d.environment, d.url)?.site ?? []);
|
|
3538
|
+
if (appKey === undefined) {
|
|
3539
|
+
const sites = new Set([...recorded, ...(await renderSitesNamed(pi.cwd))]);
|
|
3540
|
+
site = sites.size === 1 ? [...sites][0] : undefined;
|
|
3541
|
+
}
|
|
3542
|
+
else {
|
|
3543
|
+
const inFolder = await renderSitesNamed(pi.cwd, folder);
|
|
3544
|
+
const app = knownApps?.find((a) => a.key === appKey);
|
|
3545
|
+
const others = (knownApps ?? []).filter((a) => a.key !== appKey);
|
|
3546
|
+
const beside = app ? await Promise.all([...new Set([...recorded, ...(await renderSitesNamed(pi.cwd))])].map(async (s) => ((await readmeNamesApp(pi.cwd, `https://${s}.onrender.com`, app, others)) ? s : null))) : [];
|
|
3547
|
+
const named = inFolder.length ? inFolder : beside.filter((s) => s !== null);
|
|
3548
|
+
site = named.length === 1 ? named[0] : undefined;
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
const cli = 'service' in read ? 'signed-in' : read.cli;
|
|
3552
|
+
const unread = 'reason' in read ? { unread: read.reason } : {};
|
|
3553
|
+
if (!site)
|
|
3554
|
+
return JSON.stringify({ ...forApp, previews: [], cli, ...unread, ...(evidence.error ? { error: evidence.error } : {}) });
|
|
3555
|
+
const previews = previewsOf(evidence, site);
|
|
3556
|
+
list(previews);
|
|
3557
|
+
const production = service?.production ?? `https://${site}.onrender.com`;
|
|
3558
|
+
noteProduction(production, 'render', undefined, appKey);
|
|
3559
|
+
let fact = service?.fact ?? { project: site, enabled: 'unknown', reason: 'reason' in read ? read.reason : undefined };
|
|
3560
|
+
noteApp(appKey, { host_project: site });
|
|
3561
|
+
renderApps.set(key, { site, page: service?.page ?? RENDER_DASHBOARD });
|
|
3562
|
+
// The one screen, asked once for each app: the site's address to click, and the consent to turning its previews on where the CLI says they are off. Its skip is a no that ends the app's setup.
|
|
3563
|
+
let answered = renderAnswers.get(key);
|
|
3564
|
+
if (answered?.confirmed === false)
|
|
3565
|
+
decline('no-previews', appKey);
|
|
3566
|
+
if (!answered) {
|
|
3567
|
+
const off = service !== undefined && fact.enabled === false && fact.cause === 'previews';
|
|
3568
|
+
if ((await ask(renderConfirm({ site, address: production, previewsOff: off, ...(appKey !== undefined ? { app: appName(appKey) } : {}) }))).skipped) {
|
|
3569
|
+
answered = { confirmed: false, reason: 'The person skipped the Render screen.' };
|
|
3570
|
+
decline('no-previews', appKey);
|
|
3571
|
+
}
|
|
3572
|
+
else if (off && service) {
|
|
3573
|
+
const turned = await turnOnPreviews('render', () => enableRenderPreviews(render, repository, service.id), signal);
|
|
3574
|
+
if (turned.status === 'skipped')
|
|
3575
|
+
decline('no-previews', appKey);
|
|
3576
|
+
if (turned.status === 'enabled')
|
|
3577
|
+
fact = previewFacts.get('render') ?? fact;
|
|
3578
|
+
answered = { confirmed: true, turnedOn: turned };
|
|
3579
|
+
}
|
|
3580
|
+
else {
|
|
3581
|
+
answered = { confirmed: true };
|
|
3582
|
+
}
|
|
3583
|
+
renderAnswers.set(key, answered);
|
|
3584
|
+
}
|
|
3585
|
+
return JSON.stringify({ ...forApp, site, ...(service ? { service: service.id } : {}), production, pattern: `https://${site}-pr-{pr}.onrender.com`, previews, pullRequestPreviews: fact, cli, ...unread, ...(evidence.error ? { error: evidence.error } : {}), ...answered });
|
|
3586
|
+
}),
|
|
3337
3587
|
tool('impeccable_netlify_connect', 'Connect the repository to Netlify', "Connect the Netlify site this folder is linked to with this repository, through the site's settings and Netlify's GitHub app, so that pull requests build deploy previews: a site this run created with netlify sites:create, or one built from this repository already. With the folder linked to no site, the one site built from this repository already stands for it, with appKey the one whose base directory is that app's folder: no netlify link needed. A site built from another repository, or not created by this run, is left as it is, with the reason. The run does it; when Netlify's app cannot read the repository, this asks the person once for its access on GitHub and waits. connected true means Netlify read the repository; how \"already\" means the site was built from it before. A connection left undone answers why, and the GitHub page for the person in access.", NETLIFY_CONNECT, async (p, pi, signal) => {
|
|
3338
3588
|
const command = p.command;
|
|
3339
3589
|
const publish = p.publish;
|
|
@@ -3367,7 +3617,6 @@ export function createTools(ctx) {
|
|
|
3367
3617
|
return `Refused: this is for a host whose pullRequestPreviews said enabled false in this run. Read ${host === 'netlify' ? 'impeccable_netlify_previews or impeccable_netlify_connect' : 'impeccable_cloudflare_previews'} first.`;
|
|
3368
3618
|
if (host === 'cloudflare' && fact.cause === 'source')
|
|
3369
3619
|
return `Refused: the Pages project ${fact.project} is connected to no repository, so there is no setting to turn on. impeccable_cloudflare_connect connects it.`;
|
|
3370
|
-
const name = host === 'netlify' ? 'Netlify' : 'Cloudflare Pages';
|
|
3371
3620
|
const answer = (status, more) => {
|
|
3372
3621
|
const told = { status, project: fact.project, ...more };
|
|
3373
3622
|
enableOutcomes.set(host, told);
|
|
@@ -3378,31 +3627,14 @@ export function createTools(ctx) {
|
|
|
3378
3627
|
const screen = HOST_SCREENS[`${host}-previews`];
|
|
3379
3628
|
if ((await ask({ ...screen, prompt: `Turn on pull-request previews for ${fact.project}?` })).skipped)
|
|
3380
3629
|
return answer('skipped', { reason: 'The person skipped turning pull-request previews on.' });
|
|
3381
|
-
said(`Turning on pull-request previews on ${name}`);
|
|
3382
3630
|
// cf is installed here: the fact this acts on is one a read through cf found.
|
|
3383
|
-
const
|
|
3631
|
+
const { status, ...more } = await turnOnPreviews(host, async () => {
|
|
3384
3632
|
if (host === 'netlify')
|
|
3385
3633
|
return enableNetlifyPreviews((ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }), fact.project);
|
|
3386
3634
|
const cf = cfRunner({ checkout: pi.cwd }, ctx.hostRunner);
|
|
3387
3635
|
return enableCloudflarePreviews(cf, fact.project, await cfStatus(cf, signal), signal);
|
|
3388
|
-
};
|
|
3389
|
-
|
|
3390
|
-
for (let tries = 0;; tries++) {
|
|
3391
|
-
const outcome = await turnOn();
|
|
3392
|
-
if (outcome.fact.enabled === true) {
|
|
3393
|
-
previewFacts.set(host, outcome.fact);
|
|
3394
|
-
return answer('enabled', { by: outcome.changed ? 'run' : 'person' });
|
|
3395
|
-
}
|
|
3396
|
-
const why = outcome.reason ?? outcome.fact.reason;
|
|
3397
|
-
if (tries === 2 || signal?.aborted)
|
|
3398
|
-
return answer('refused', { reason: why, page: outcome.page });
|
|
3399
|
-
const retry = HOST_SCREENS[`${host}-previews-retry`];
|
|
3400
|
-
// The host's page opens once, before the first retry screen, which speaks of it; the browser may not have opened (BROWSER=none, no opener), so the address is on the screen as well.
|
|
3401
|
-
if (tries === 0)
|
|
3402
|
-
(ctx.openBrowser ?? openBrowser)(outcome.page);
|
|
3403
|
-
if ((await ask({ ...retry, hint: `${retry.hint} If it did not open, open this link: ${outcome.page}` })).skipped)
|
|
3404
|
-
return answer('skipped', { reason: why, page: outcome.page });
|
|
3405
|
-
}
|
|
3636
|
+
}, signal);
|
|
3637
|
+
return answer(status, more);
|
|
3406
3638
|
}),
|
|
3407
3639
|
tool('impeccable_cloudflare_connect', 'Connect the repository to Cloudflare Pages', "Connect this repository to a Cloudflare Pages project, so that Cloudflare builds every pull request as a preview, where impeccable_cloudflare_previews found no Pages project building them: the project the checkout names when it exists with no Git source (pullRequestPreviews with cause source), else a new one named after the repository where Cloudflare builds a Worker from it (worker in the answer) or the checkout is set up for a Pages project that does not exist yet. It does the rest as the person: one screen asking to connect or to create (Cloudflare's sign-in in the browser first, with a retry, where cf is not signed in here with pages:write), then cf creates the project with the build the checkout runs (command and outputDir, for a new project) and connects the repository as GitHub identifies it, every branch but the default a preview, and reads it back. Answers {\"status\": …}: connected (project names the Pages project, created whether the run made it, subdomain its own address under pages.dev, productionBranch the branch Cloudflare builds as production; pullRequestPreviews is on now), skipped (the person skipped a screen; reason says which) or refused (cf could not, or Cloudflare refused; reason says why). Cloudflare's GitHub app then has to see the repository for a push to build: the wait for the test drive's build asks for its access when none comes.", CLOUDFLARE_CONNECT, async (p, pi, signal) => {
|
|
3408
3640
|
// The answer stands for the run, as before; its no ends the setup only of an app on the project it was for.
|
|
@@ -3882,7 +4114,7 @@ export function createTools(ctx) {
|
|
|
3882
4114
|
return null;
|
|
3883
4115
|
};
|
|
3884
4116
|
let answer;
|
|
3885
|
-
const project = pagesProject(new URL(at.url).origin);
|
|
4117
|
+
const project = pagesProject(new URL(at.url).origin) ?? workerPatterns.get(new URL(at.url).origin);
|
|
3886
4118
|
// The token in hand goes only to a project it opened already, as the proofs send it: any other address is read for an application of the person's first.
|
|
3887
4119
|
if (accessToken && project && accessProjects.has(project) && (await prove(accessToken)).proven) {
|
|
3888
4120
|
// The token in hand opens this address too: stored already, or stored now.
|
|
@@ -3935,14 +4167,14 @@ export function createTools(ctx) {
|
|
|
3935
4167
|
if (project)
|
|
3936
4168
|
accessProjects.add(project);
|
|
3937
4169
|
}
|
|
3938
|
-
const pattern = told.status === 'saved' ? proof?.pattern : undefined;
|
|
4170
|
+
const pattern = told.status === 'saved' ? (proof?.pattern ?? workerPatterns.get(new URL(at.url).origin)) : undefined;
|
|
3939
4171
|
accessOutcome = { ...told, ...(told.status === 'saved' ? { proven: true, ...(pattern ? { pattern } : {}) } : {}) };
|
|
3940
4172
|
// The person's no to the service token leaves the previews behind Cloudflare Access: the setup ends there for the app (#413).
|
|
3941
4173
|
if (told.status === 'skipped')
|
|
3942
4174
|
decline('no-access', at.app?.appKey);
|
|
3943
4175
|
if (told.status === 'saved') {
|
|
3944
4176
|
hostedPreviewProven = true;
|
|
3945
|
-
provenHost = 'Cloudflare Pages';
|
|
4177
|
+
provenHost = previewHostName(at.url) ?? 'Cloudflare Pages';
|
|
3946
4178
|
usedPreviews.add(at.url);
|
|
3947
4179
|
if (at.app)
|
|
3948
4180
|
proveApp(at.app.appKey, at.url);
|