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