@impeccable/reviews 0.1.22 → 0.1.24
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 +40 -4
- package/dist/focus.js +17 -0
- package/dist/host-questions.js +2 -2
- package/dist/main.js +27 -10
- package/dist/playbook/cases/cloudflare-previews.md +1 -1
- package/dist/playbook/cases/monorepo.md +5 -5
- 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 +18 -3
- package/dist/pr-summary.js +45 -0
- package/dist/preflight.js +2 -1
- package/dist/tools.js +233 -54
- package/dist/ui/look.js +10 -9
- 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';
|
|
@@ -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)
|
|
@@ -302,10 +327,11 @@ const ASK = {
|
|
|
302
327
|
additionalProperties: false,
|
|
303
328
|
required: ['id', 'prompt', 'kind'],
|
|
304
329
|
properties: {
|
|
330
|
+
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
331
|
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.' },
|
|
332
|
+
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.' },
|
|
333
|
+
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."' },
|
|
334
|
+
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
335
|
kind: {
|
|
310
336
|
type: 'string',
|
|
311
337
|
enum: ['text', 'single', 'multi', 'confirm', 'account'],
|
|
@@ -319,7 +345,7 @@ const ASK = {
|
|
|
319
345
|
prefill: { type: 'string', minLength: 1, description: 'text, not with sensitive, and account: what the (username) field holds when the dialog opens, such as the seed\'s username. The person can change it.' },
|
|
320
346
|
confirmLabel: { type: 'string', minLength: 1, description: 'confirm only: the way on, such as "Share test accounts".' },
|
|
321
347
|
skipLabel: { type: 'string', minLength: 1, description: 'confirm only: the other way, such as "Skip for now".' },
|
|
322
|
-
skippable: { type: 'boolean', description: 'Every question offers Skip, whatever this says.' },
|
|
348
|
+
skippable: { type: 'boolean', description: 'Every question offers Skip, whatever this says, but the apps question.' },
|
|
323
349
|
sensitive: { type: 'boolean', description: 'text only: a password. The person types it hidden, and the answer\'s value is a reference ("secret:…") that impeccable_write turns back into what they typed. A text question whose title, prompt or hint names a token, a password, a secret, a key or a credential is asked this way whatever this says.' },
|
|
324
350
|
},
|
|
325
351
|
};
|
|
@@ -334,16 +360,19 @@ const namesCredential = (q) => {
|
|
|
334
360
|
};
|
|
335
361
|
/** Under the field of every text question typed in the open, where the model reads the answer. */
|
|
336
362
|
const NEVER_A_CREDENTIAL = 'Never enter a password, a token or a key here.';
|
|
363
|
+
/** 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). */
|
|
364
|
+
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
365
|
const ENABLE_PREVIEWS = {
|
|
338
366
|
type: 'object',
|
|
339
367
|
additionalProperties: false,
|
|
340
368
|
required: ['host'],
|
|
341
|
-
properties: { host: { type: 'string', enum: ['netlify', 'cloudflare'], description: "The host whose pullRequestPreviews said enabled false in this run." } },
|
|
369
|
+
properties: { host: { type: 'string', enum: ['netlify', 'cloudflare'], description: "The host whose pullRequestPreviews said enabled false in this run." }, appKey: FOR_APP },
|
|
342
370
|
};
|
|
343
371
|
const CLOUDFLARE_CONNECT = {
|
|
344
372
|
type: 'object',
|
|
345
373
|
additionalProperties: false,
|
|
346
374
|
properties: {
|
|
375
|
+
appKey: FOR_APP,
|
|
347
376
|
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
377
|
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
378
|
},
|
|
@@ -1622,6 +1651,10 @@ export function createTools(ctx) {
|
|
|
1622
1651
|
let lastLock;
|
|
1623
1652
|
/** Netlify's gate, as last met, was its team login rather than the site password: the last screen names that one. */
|
|
1624
1653
|
let lastTeamLogin = false;
|
|
1654
|
+
/** 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). */
|
|
1655
|
+
const declined = new Map();
|
|
1656
|
+
/** The hosts whose sign-in or team the person said no to: asked again for the next app, the answer is the same no. */
|
|
1657
|
+
const hostsDeclined = new Set();
|
|
1625
1658
|
/** 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
1659
|
const usedPreviews = new Set();
|
|
1627
1660
|
const accountOf = new Map();
|
|
@@ -1670,6 +1703,7 @@ export function createTools(ctx) {
|
|
|
1670
1703
|
if (access) {
|
|
1671
1704
|
return onHostPage(`${host.toLowerCase()}-app-access`, () => access.page, (e) => !built(e), access.now ? first : await waited(first), async () => waited(await read()), {
|
|
1672
1705
|
skipped: `The person skipped giving ${host}'s GitHub app access to the repository.`,
|
|
1706
|
+
declines: { app: access.app },
|
|
1673
1707
|
still: `${host}'s GitHub app still cannot see the repository: no build came.`,
|
|
1674
1708
|
stopped: `The run stopped while ${host}'s build was waited for.`,
|
|
1675
1709
|
}, signal);
|
|
@@ -1726,6 +1760,8 @@ export function createTools(ctx) {
|
|
|
1726
1760
|
const accessConsented = new Set();
|
|
1727
1761
|
/** 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
1762
|
const previewFacts = new Map();
|
|
1763
|
+
/** 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). */
|
|
1764
|
+
const appProjects = new Map();
|
|
1729
1765
|
/** What impeccable_enable_previews answered for a host: asked once, so that answer stands. */
|
|
1730
1766
|
const enableOutcomes = new Map();
|
|
1731
1767
|
/** 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 +2071,12 @@ export function createTools(ctx) {
|
|
|
2035
2071
|
*/
|
|
2036
2072
|
async function onHostPage(screen, page, pending, result, again, words, signal) {
|
|
2037
2073
|
for (let retries = 0; pending(result) && !signal?.aborted; retries++) {
|
|
2038
|
-
if ((await ask(HOST_SCREENS[retries ? `${screen}-retry` : screen])).skipped)
|
|
2074
|
+
if ((await ask(HOST_SCREENS[retries ? `${screen}-retry` : screen])).skipped) {
|
|
2075
|
+
// A no to the host's GitHub app or to the lock before the first push leaves no preview: the setup ends there (#413).
|
|
2076
|
+
if (words.declines)
|
|
2077
|
+
decline('no-previews', words.declines.app);
|
|
2039
2078
|
return { ...result, reason: words.skipped };
|
|
2079
|
+
}
|
|
2040
2080
|
const at = page(result);
|
|
2041
2081
|
if (at)
|
|
2042
2082
|
(ctx.openBrowser ?? openBrowser)(at);
|
|
@@ -2051,17 +2091,18 @@ export function createTools(ctx) {
|
|
|
2051
2091
|
return result;
|
|
2052
2092
|
}
|
|
2053
2093
|
/** 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) {
|
|
2094
|
+
function hostAccess(host, result, reaches, signal, app) {
|
|
2055
2095
|
const name = HOST_NAMES[host];
|
|
2056
2096
|
return onHostPage(`${host}-access`, (r) => r.access, (r) => Boolean(r.access), result, reaches, {
|
|
2057
2097
|
waiting: `Waiting for ${name} to reach the repository`,
|
|
2058
2098
|
skipped: `The person skipped giving ${name}'s GitHub app access to the repository.`,
|
|
2099
|
+
declines: { app },
|
|
2059
2100
|
still: `${name}'s GitHub app still cannot reach the repository.`,
|
|
2060
2101
|
stopped: `The run stopped while ${name} was waited for.`,
|
|
2061
2102
|
}, signal);
|
|
2062
2103
|
}
|
|
2063
2104
|
/** 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) {
|
|
2105
|
+
async function netlifyConnect(pi, settings, signal, directory, previewing = false, app) {
|
|
2065
2106
|
// 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
2107
|
if (!hostSetupAgreed(ctx.log, 'netlify') && !previewing)
|
|
2067
2108
|
return { connected: false, reason: 'The Netlify sign-in comes first: impeccable_ask with the id netlify-setup.' };
|
|
@@ -2072,9 +2113,11 @@ export function createTools(ctx) {
|
|
|
2072
2113
|
netlifySite = result.site?.name ?? netlifySite;
|
|
2073
2114
|
if (result.pullRequestPreviews)
|
|
2074
2115
|
previewFacts.set('netlify', result.pullRequestPreviews);
|
|
2116
|
+
if (app !== undefined && result.pullRequestPreviews)
|
|
2117
|
+
appProjects.set(`netlify/${app}`, result.pullRequestPreviews.project);
|
|
2075
2118
|
// A build every 30 s until one reads the repository: the person is giving the app access meanwhile.
|
|
2076
2119
|
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);
|
|
2120
|
+
? { connected: true, site: result.site, how: 'github-app', ...(result.pullRequestPreviews ? { pullRequestPreviews: result.pullRequestPreviews } : {}) } : result), signal, app);
|
|
2078
2121
|
}
|
|
2079
2122
|
/**
|
|
2080
2123
|
* The connection of the linked Vercel project with this repository, `vercel git connect`, for a project this run's own
|
|
@@ -2128,7 +2171,7 @@ export function createTools(ctx) {
|
|
|
2128
2171
|
}
|
|
2129
2172
|
}
|
|
2130
2173
|
// 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);
|
|
2174
|
+
const result = await hostAccess('vercel', await connectVercelProject(vercel), () => waitForVercelAccess(vercel, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 20_000 }, signal }), signal, connectedApp);
|
|
2132
2175
|
// 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
2176
|
if (!result.connected) {
|
|
2134
2177
|
if (result.access !== undefined)
|
|
@@ -2143,6 +2186,7 @@ export function createTools(ctx) {
|
|
|
2143
2186
|
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
2187
|
waiting: "Waiting for Vercel's login on the project",
|
|
2145
2188
|
skipped: 'The person skipped locking the Vercel project.',
|
|
2189
|
+
declines: { app: connectedApp },
|
|
2146
2190
|
still: "Vercel's login is still not in front of the project's addresses.",
|
|
2147
2191
|
stopped: 'The run stopped while the lock was waited for.',
|
|
2148
2192
|
}, signal);
|
|
@@ -2216,8 +2260,11 @@ export function createTools(ctx) {
|
|
|
2216
2260
|
let renewedForApps = false;
|
|
2217
2261
|
/** Whether the repository has several apps (the state's apps item), as the first state or a later read showed it. */
|
|
2218
2262
|
let appsRepo = ctx.severalApps === true;
|
|
2263
|
+
// --signed-in-test-drive: the fence pushes and opens a test drive only for an app with a test account saved (#410).
|
|
2264
|
+
if (ctx.signedInTestDrive)
|
|
2265
|
+
retry.signedIn ??= { apps: appsRepo, saved: new Set(ctx.signedInTestDrive.accountsSaved) };
|
|
2219
2266
|
/**
|
|
2220
|
-
* The apps this run sets up, as the apps question answered
|
|
2267
|
+
* The apps this run sets up, as the apps question answered: undefined until it was answered.
|
|
2221
2268
|
* On a repository with several apps every run asks it, however the setup page answered before (#280), and every write
|
|
2222
2269
|
* and host read for an app names one of these.
|
|
2223
2270
|
*/
|
|
@@ -2233,6 +2280,9 @@ export function createTools(ctx) {
|
|
|
2233
2280
|
pickedApps = keys;
|
|
2234
2281
|
// Each app this run sets up gets a test drive of its own (impeccable-reviews#397).
|
|
2235
2282
|
retry.drivesAllowed = Math.max(1, keys.size);
|
|
2283
|
+
// 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).
|
|
2284
|
+
if (retry.signedIn)
|
|
2285
|
+
retry.signedIn.picked = [...keys];
|
|
2236
2286
|
const before = new Map(appSummaries);
|
|
2237
2287
|
appSummaries.clear();
|
|
2238
2288
|
for (const key of keys)
|
|
@@ -2277,6 +2327,8 @@ export function createTools(ctx) {
|
|
|
2277
2327
|
}
|
|
2278
2328
|
if (res.ok && severalApps(res.data))
|
|
2279
2329
|
appsRepo = true;
|
|
2330
|
+
if (retry.signedIn)
|
|
2331
|
+
retry.signedIn.apps = appsRepo;
|
|
2280
2332
|
if (res.ok && ctx.allInstructions !== undefined && severalApps(res.data))
|
|
2281
2333
|
instructions = focusInstructions(ctx.allInstructions, { apps: true });
|
|
2282
2334
|
return answerText(res.ok ? { ...res, data: focus(res.data) } : res);
|
|
@@ -2328,6 +2380,28 @@ export function createTools(ctx) {
|
|
|
2328
2380
|
}
|
|
2329
2381
|
return null;
|
|
2330
2382
|
};
|
|
2383
|
+
/** 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. */
|
|
2384
|
+
const endedFor = (key) => ((appsRepo ? appMethods.has(key) : methodSaved) ? undefined : declined.get(key));
|
|
2385
|
+
/** 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. */
|
|
2386
|
+
const decline = (kind, appKey) => {
|
|
2387
|
+
const key = appsRepo ? (typeof appKey === 'string' ? appKey : undefined) : '';
|
|
2388
|
+
if (key === undefined)
|
|
2389
|
+
return;
|
|
2390
|
+
declined.set(key, kind);
|
|
2391
|
+
if (endedFor(key))
|
|
2392
|
+
endAppSetup(ctx.log, key);
|
|
2393
|
+
};
|
|
2394
|
+
/** 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. */
|
|
2395
|
+
const onProject = (host, appKey, project) => !appsRepo || (typeof appKey === 'string' && appProjects.get(`${host}/${appKey}`) === project);
|
|
2396
|
+
/** 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. */
|
|
2397
|
+
const endRefusal = (appKey, what) => {
|
|
2398
|
+
const key = appsRepo ? appKey : '';
|
|
2399
|
+
const why = typeof key === 'string' ? endedFor(key) : undefined;
|
|
2400
|
+
if (!why)
|
|
2401
|
+
return null;
|
|
2402
|
+
const app = appsRepo ? appName(key) : undefined;
|
|
2403
|
+
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}.`;
|
|
2404
|
+
};
|
|
2331
2405
|
/**
|
|
2332
2406
|
* 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
2407
|
* after it would go by on "Understanding your app" (#390). Null once that task is done.
|
|
@@ -2354,9 +2428,14 @@ export function createTools(ctx) {
|
|
|
2354
2428
|
? { 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
2429
|
: { key, name, folder, state: 'ready' };
|
|
2356
2430
|
}
|
|
2431
|
+
const state = gate && !proven ? 'locked' : 'unset';
|
|
2432
|
+
// The person's no left it with no preview Impeccable can open: the setup ended for it, and its line says so (#413).
|
|
2433
|
+
const ended = endedFor(key);
|
|
2434
|
+
if (ended)
|
|
2435
|
+
return { key, name, folder, state, ended, left: `${name}: the setup ended for it, as you said no to ${SETUP_ENDS[ended].said}.` };
|
|
2357
2436
|
if (gate && !proven)
|
|
2358
|
-
return { key, name, folder, state
|
|
2359
|
-
return { key, name, folder, state
|
|
2437
|
+
return { key, name, folder, state, left: `${name}: its previews are behind ${LOCKS[gate].lock}, and no key was saved. ${LOCKS[gate].left}.` };
|
|
2438
|
+
return { key, name, folder, state, left: `${name}: no preview method was saved; the report says why.` };
|
|
2360
2439
|
});
|
|
2361
2440
|
};
|
|
2362
2441
|
/** 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 +2444,8 @@ export function createTools(ctx) {
|
|
|
2365
2444
|
const lock = lockMet.has(a.key) ? LOCKS[lockMet.get(a.key)].lock : undefined;
|
|
2366
2445
|
if (a.state === 'ready')
|
|
2367
2446
|
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." : ''}`;
|
|
2447
|
+
if (a.ended)
|
|
2448
|
+
return `- ${where}: not set up. The setup ended for it: the person said no to ${SETUP_ENDS[a.ended].said}.`;
|
|
2368
2449
|
if (a.state === 'locked')
|
|
2369
2450
|
return `- ${where}: not set up. Its previews are behind ${lock}, and no key was saved for it.`;
|
|
2370
2451
|
if (a.state === 'unset')
|
|
@@ -2396,35 +2477,53 @@ export function createTools(ctx) {
|
|
|
2396
2477
|
}
|
|
2397
2478
|
/**
|
|
2398
2479
|
* The apps question (#280), worded here and listing the apps the state has, each picked as it is on (every one, where
|
|
2399
|
-
* this run confirmed the scan's proposal); the command started for one app (--app) answers it
|
|
2400
|
-
* answer is the apps this run sets up
|
|
2480
|
+
* this run confirmed the scan's proposal); the command started for one app (--app) answers it on one confirm screen.
|
|
2481
|
+
* The answer is the apps this run sets up, at least one: the question has no Skip (#405).
|
|
2401
2482
|
*/
|
|
2402
|
-
async function appsQuestion(pi) {
|
|
2483
|
+
async function appsQuestion(pi, signal) {
|
|
2403
2484
|
const apps = await stateApps(pi);
|
|
2404
2485
|
if (!apps || apps.length < 2)
|
|
2405
2486
|
return 'Refused: the setup state could not be read, or lists fewer than two apps: read it again with impeccable_state, then ask again.';
|
|
2406
|
-
// Started for one app (--app, from its card): one confirm screen naming it instead of the list, whose
|
|
2487
|
+
// Started for one app (--app, from its card): one confirm screen naming it instead of the list, whose Pick other apps opens the list for someone who wants another app (#286).
|
|
2407
2488
|
const named = ctx.app !== undefined ? apps.find((a) => a.key === ctx.app) : undefined;
|
|
2408
|
-
if (named
|
|
2409
|
-
|
|
2410
|
-
|
|
2489
|
+
if (named) {
|
|
2490
|
+
const screen = namedAppQuestion(named);
|
|
2491
|
+
const answer = await ask(screen);
|
|
2492
|
+
if (answer.closed)
|
|
2493
|
+
return unanswered(pi, screen, signal);
|
|
2494
|
+
if (!answer.skipped) {
|
|
2495
|
+
pick(new Set([named.key]));
|
|
2496
|
+
return JSON.stringify({ values: [named.key], named: true });
|
|
2497
|
+
}
|
|
2411
2498
|
}
|
|
2412
2499
|
// Started below the top: the app whose folder holds that folder (the deepest, never the root's) is ticked too, and the hint says so.
|
|
2413
2500
|
const start = ctx.start ?? '';
|
|
2414
2501
|
const here = start ? apps.filter((a) => a.folder && (start === a.folder || start.startsWith(`${a.folder}/`))).sort((a, b) => b.folder.length - a.folder.length)[0] : undefined;
|
|
2415
2502
|
const hint = here ? startedIn(`${here.name}'s folder`) : start ? startedIn(start) : APPS_QUESTION.hint;
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2503
|
+
const list = { ...APPS_QUESTION, hint, options: apps.map((a) => ({ label: `${a.name} · ${a.folder || '/'}`, value: a.key, selected: confirmedApps || a.enabled || a === here || a === named })) };
|
|
2504
|
+
// No Skip: Continue is the only way on, with at least one app picked (#405). A blank line in a piped run picks none, so the list goes again; on the list the named app's screen opened, the named app stands.
|
|
2505
|
+
for (;;) {
|
|
2506
|
+
const answer = await ask(list, false);
|
|
2507
|
+
if (answer.skipped)
|
|
2508
|
+
return unanswered(pi, list, signal);
|
|
2509
|
+
const values = (answer.values ?? []).filter((value) => apps.some((a) => a.key === value));
|
|
2510
|
+
if (named && !values.length)
|
|
2511
|
+
values.push(named.key);
|
|
2512
|
+
if (!values.length)
|
|
2513
|
+
continue;
|
|
2514
|
+
pick(new Set(values));
|
|
2515
|
+
return JSON.stringify({ values });
|
|
2421
2516
|
}
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2517
|
+
}
|
|
2518
|
+
/**
|
|
2519
|
+
* No answer came on the apps question (the input ended, or the run stopped with it up), which has no Skip: nothing is
|
|
2520
|
+
* written, and the run ends here as stopped while waiting for that answer, which the end screen and the web page name.
|
|
2521
|
+
*/
|
|
2522
|
+
function unanswered(pi, screen, signal) {
|
|
2523
|
+
signal?.throwIfAborted();
|
|
2524
|
+
ctx.sync.update({ phase: 'waiting', question: { id: screen.id, prompt: screen.prompt } });
|
|
2525
|
+
pi.shutdown();
|
|
2526
|
+
return { text: 'No answer came on the apps question, so no app was picked and nothing was written. The run ends here.', terminate: true };
|
|
2428
2527
|
}
|
|
2429
2528
|
return [
|
|
2430
2529
|
tool('impeccable_workflow_access', 'Check workflow access', 'Check that the setup pull request can carry the workflow: reads the active GitHub CLI account and its scopes, the origin remote and the default branch, and refreshes origin/<defaultBranch>. Call it before branching. needsWorkflowScope true means the account lacks workflow access over HTTPS; base.status "refreshed" means origin/<defaultBranch> is current. push is whether git may push to origin, as a dry run at the start found it.', NO_INPUT, (_p, pi, signal) => workflowAccessTool(pi, signal)),
|
|
@@ -2507,7 +2606,7 @@ export function createTools(ctx) {
|
|
|
2507
2606
|
// 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.
|
|
2508
2607
|
const forApp = /\/(preview|test-accounts|connection-check|latest-pull-check)$/.test(target.url);
|
|
2509
2608
|
if (!target.url.endsWith('/apps')) {
|
|
2510
|
-
const refused = appRefusal(object(p.body)?.appKey, 'saved', forApp) ?? (target.url.endsWith('/preview') ? lockRefusal(object(p.body)?.appKey, 'saved') : null);
|
|
2609
|
+
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);
|
|
2511
2610
|
if (refused)
|
|
2512
2611
|
return refused;
|
|
2513
2612
|
}
|
|
@@ -2563,6 +2662,8 @@ export function createTools(ctx) {
|
|
|
2563
2662
|
break;
|
|
2564
2663
|
response = await call(pi, target.url, { method: target.method, body });
|
|
2565
2664
|
}
|
|
2665
|
+
if (testAccounts && response.ok)
|
|
2666
|
+
retry.signedIn?.saved.add(appsRepo ? String(object(p.body)?.appKey) : '');
|
|
2566
2667
|
// A check that answered, a preview not running yet included, ran for its app; one still turned away did not.
|
|
2567
2668
|
if (check && (response.ok || response.status === 409))
|
|
2568
2669
|
checkedApps.add(typeof object(p.body)?.appKey === 'string' ? String(object(p.body)?.appKey) : '');
|
|
@@ -2614,11 +2715,21 @@ export function createTools(ctx) {
|
|
|
2614
2715
|
// A test account never comes back to the model, even if the API echoed it.
|
|
2615
2716
|
return testAccounts ? redact(answerText(response), typed.values()) : answerText(response);
|
|
2616
2717
|
}),
|
|
2617
|
-
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 and answers {"values": […]}
|
|
2618
|
-
// A skip never stops the run, so every question offers one.
|
|
2619
|
-
const asked = p;
|
|
2718
|
+
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) => {
|
|
2719
|
+
// A skip never stops the run, so every question offers one, but the apps question, which needs at least one app (#405).
|
|
2720
|
+
const { appKey: forApp, ...asked } = p;
|
|
2620
2721
|
if (asked.id === APPS_QUESTION.id && appsRepo)
|
|
2621
|
-
return appsQuestion(pi);
|
|
2722
|
+
return appsQuestion(pi, signal);
|
|
2723
|
+
// On several apps, the host question is one app's, so its skip ends that app's setup alone (#413).
|
|
2724
|
+
if (asked.id === 'preview-host' && appsRepo) {
|
|
2725
|
+
const refused = appRefusal(forApp, 'asked');
|
|
2726
|
+
if (refused)
|
|
2727
|
+
return refused;
|
|
2728
|
+
}
|
|
2729
|
+
// On a repository of one app, the person's no ended the setup: nothing more is asked, the test accounts neither (#413).
|
|
2730
|
+
const ended = appsRepo ? null : endRefusal('', 'asked');
|
|
2731
|
+
if (ended)
|
|
2732
|
+
return ended;
|
|
2622
2733
|
const host = /^(netlify|vercel)-(setup|team)$/.exec(asked.id);
|
|
2623
2734
|
// 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.
|
|
2624
2735
|
const deployed = appsRepo ? pickedApps !== undefined && pickedApps.size > 0 && [...pickedApps].every((key) => vercelDeployed.has(key)) : vercelDeployed.has('');
|
|
@@ -2631,8 +2742,16 @@ export function createTools(ctx) {
|
|
|
2631
2742
|
if (asked.id === 'netlify-setup' && previewing && !hostSignIns.has('netlify')) {
|
|
2632
2743
|
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.`;
|
|
2633
2744
|
}
|
|
2634
|
-
if (host?.[2] === 'setup')
|
|
2635
|
-
|
|
2745
|
+
if (host?.[2] === 'setup') {
|
|
2746
|
+
const was = hostSignIns.get(host[1]);
|
|
2747
|
+
const signedIn = await hostSignIn(host[1], pi, signal);
|
|
2748
|
+
// 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.
|
|
2749
|
+
if (signedIn.skipped && !signedIn.reason && (was !== 'skipped' || hostsDeclined.has(host[1]))) {
|
|
2750
|
+
hostsDeclined.add(host[1]);
|
|
2751
|
+
decline('no-previews', forApp);
|
|
2752
|
+
}
|
|
2753
|
+
return JSON.stringify(signedIn);
|
|
2754
|
+
}
|
|
2636
2755
|
if (host && (teamChoices.get(host[1])?.length ?? 0) < 2)
|
|
2637
2756
|
return `Invalid input: ${asked.id} is asked only when the ${HOST_NAMES[host[1]]} sign-in answered teams to choose from.`;
|
|
2638
2757
|
const fixed = Object.hasOwn(HOST_QUESTIONS, asked.id) ? HOST_QUESTIONS[asked.id] : undefined;
|
|
@@ -2650,12 +2769,19 @@ export function createTools(ctx) {
|
|
|
2650
2769
|
ctx.log.write('ask hidden (names a credential)', q.id);
|
|
2651
2770
|
if (q.kind === 'text' && !q.sensitive)
|
|
2652
2771
|
q.caution = NEVER_A_CREDENTIAL;
|
|
2653
|
-
// The apps question's screen is the tool's to show (TOOL_SHOWN), so the web page hears of it here.
|
|
2654
|
-
const answer = q.id === APPS_QUESTION.id ? await ask(q) : await askPerson(q);
|
|
2772
|
+
// 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).
|
|
2773
|
+
const answer = q.id === APPS_QUESTION.id ? await ask(q, false) : await askPerson(q);
|
|
2774
|
+
if (q.id === APPS_QUESTION.id && answer.skipped)
|
|
2775
|
+
return unanswered(pi, q, signal);
|
|
2655
2776
|
if (answer.skipped) {
|
|
2656
2777
|
// Without a terminal the Vercel CLI names no team on its own, so a skipped team ends the Vercel setup.
|
|
2657
2778
|
if (host?.[1] === 'vercel')
|
|
2658
2779
|
endSetup('vercel');
|
|
2780
|
+
// No to the Vercel team, or to every host on the host question, leaves no preview: the setup ends there (#413).
|
|
2781
|
+
if (host?.[1] === 'vercel')
|
|
2782
|
+
hostsDeclined.add('vercel');
|
|
2783
|
+
if (host?.[1] === 'vercel' || q.id === 'preview-host')
|
|
2784
|
+
decline('no-previews', forApp);
|
|
2659
2785
|
return JSON.stringify({ skipped: true });
|
|
2660
2786
|
}
|
|
2661
2787
|
if (q.kind === 'multi')
|
|
@@ -2715,6 +2841,11 @@ export function createTools(ctx) {
|
|
|
2715
2841
|
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.`;
|
|
2716
2842
|
}
|
|
2717
2843
|
const key = typeof appKey === 'string' ? appKey : '';
|
|
2844
|
+
const ended = endRefusal(key, 'recorded');
|
|
2845
|
+
if (ended)
|
|
2846
|
+
return ended;
|
|
2847
|
+
if (retry.signedIn && !retry.signedIn.saved.has(key))
|
|
2848
|
+
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.`;
|
|
2718
2849
|
const found = await runPullRequest(ctx, drive, 'impeccable/test-drive', signal);
|
|
2719
2850
|
if ('problem' in found)
|
|
2720
2851
|
return `Refused: ${found.problem}.`;
|
|
@@ -2780,6 +2911,11 @@ export function createTools(ctx) {
|
|
|
2780
2911
|
if (open.length)
|
|
2781
2912
|
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.`;
|
|
2782
2913
|
let markdown = p.markdown;
|
|
2914
|
+
// --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).
|
|
2915
|
+
const unsigned = retry.signedIn ? (appsRepo ? [...(pickedApps ?? [])] : ['']).filter((key) => !retry.signedIn.saved.has(key)) : [];
|
|
2916
|
+
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;
|
|
2917
|
+
if (unhonoured)
|
|
2918
|
+
markdown = `**${unhonoured}**\n\n${markdown}`;
|
|
2783
2919
|
// 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.
|
|
2784
2920
|
if (recordedBranches?.setup) {
|
|
2785
2921
|
const changes = recordedBranches.setupBase ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, recordedBranches.setupBase, recordedBranches.setup) : null;
|
|
@@ -2806,6 +2942,12 @@ export function createTools(ctx) {
|
|
|
2806
2942
|
const apps = appsRepo && pickedApps ? await appOutcomes(pi, new Set([...appDrives.keys()].filter((key) => !kept.some(([k]) => k === key)))) : undefined;
|
|
2807
2943
|
if (apps)
|
|
2808
2944
|
markdown += appsSection(apps);
|
|
2945
|
+
// 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).
|
|
2946
|
+
const picked = apps?.filter((a) => a.state !== 'unpicked');
|
|
2947
|
+
const endedBy = picked ? (picked.length && picked.every((a) => a.ended) ? picked.map((a) => a.ended) : []) : drive ? [] : [endedFor('')].filter((why) => why !== undefined);
|
|
2948
|
+
const ended = endedBy.length ? (endedBy.includes('no-previews') ? 'no-previews' : 'no-access') : undefined;
|
|
2949
|
+
if (ended)
|
|
2950
|
+
markdown = `**${SETUP_ENDS[ended].title}.** ${SETUP_ENDS[ended].line}\n\n${markdown}`;
|
|
2809
2951
|
if (previews.active && retry.pr) {
|
|
2810
2952
|
await observe(signal);
|
|
2811
2953
|
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(', ')}.` : ''}`);
|
|
@@ -2818,6 +2960,10 @@ export function createTools(ctx) {
|
|
|
2818
2960
|
const { phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore } = ctx.sync.payload;
|
|
2819
2961
|
// 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.
|
|
2820
2962
|
const appsBefore = new Map(appSummaries);
|
|
2963
|
+
// Each app whose setup ended on the person's no says so on the web page, unless the report gives its own words below.
|
|
2964
|
+
for (const a of apps ?? [])
|
|
2965
|
+
if (a.ended)
|
|
2966
|
+
noteApp(a.key, { reason: `The setup ended for it: the person said no to ${SETUP_ENDS[a.ended].said}.` });
|
|
2821
2967
|
for (const { appKey, reason } of p.apps ?? []) {
|
|
2822
2968
|
const plain = reason.trim();
|
|
2823
2969
|
const address = /[a-z][a-z0-9+.-]*:\/\/|[a-z0-9-]+(?:\.[a-z0-9-]+)+[/?#]/i.test(plain);
|
|
@@ -2825,7 +2971,7 @@ export function createTools(ctx) {
|
|
|
2825
2971
|
noteApp(appKey, { reason: plain });
|
|
2826
2972
|
}
|
|
2827
2973
|
// 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).
|
|
2828
|
-
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) });
|
|
2974
|
+
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) });
|
|
2829
2975
|
const res = await ctx.sync.flush();
|
|
2830
2976
|
if (res && res.status >= 400 && res.status < 500) {
|
|
2831
2977
|
// 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.
|
|
@@ -2840,7 +2986,7 @@ export function createTools(ctx) {
|
|
|
2840
2986
|
}
|
|
2841
2987
|
// Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
|
|
2842
2988
|
const hosts = hostLines(hostsUsed());
|
|
2843
|
-
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 } : {}) });
|
|
2989
|
+
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 } : {}) });
|
|
2844
2990
|
pi.shutdown();
|
|
2845
2991
|
const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
|
|
2846
2992
|
return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
|
|
@@ -2986,6 +3132,8 @@ export function createTools(ctx) {
|
|
|
2986
3132
|
}
|
|
2987
3133
|
if (evidence.pullRequestPreviews)
|
|
2988
3134
|
previewFacts.set('netlify', evidence.pullRequestPreviews);
|
|
3135
|
+
if (appKey !== undefined && evidence.pullRequestPreviews)
|
|
3136
|
+
appProjects.set(`netlify/${appKey}`, evidence.pullRequestPreviews.project);
|
|
2989
3137
|
// 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).
|
|
2990
3138
|
if (pull === undefined) {
|
|
2991
3139
|
if (!evidence.unmatched && evidence.pullRequestPreviews?.enabled === true)
|
|
@@ -3029,7 +3177,7 @@ export function createTools(ctx) {
|
|
|
3029
3177
|
// 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),
|
|
3030
3178
|
// unless it covers them all, or Cloudflare built a preview of the repository already, which it could not without the app.
|
|
3031
3179
|
const app = cloudflareEvidence?.githubApp;
|
|
3032
|
-
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;
|
|
3180
|
+
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;
|
|
3033
3181
|
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();
|
|
3034
3182
|
// cf not on this machine (#396): its install is asked before anything installs it, and the evidence read again with it.
|
|
3035
3183
|
if (!subject && waited.cli === 'missing') {
|
|
@@ -3063,6 +3211,9 @@ export function createTools(ctx) {
|
|
|
3063
3211
|
}
|
|
3064
3212
|
if (evidence.pullRequestPreviews)
|
|
3065
3213
|
previewFacts.set('cloudflare', evidence.pullRequestPreviews);
|
|
3214
|
+
const named = evidence.pullRequestPreviews?.project || evidence.project;
|
|
3215
|
+
if (appKey !== undefined && named)
|
|
3216
|
+
appProjects.set(`cloudflare/${appKey}`, named);
|
|
3066
3217
|
noteProduction(evidence.unmatched ? undefined : evidence.production, 'cloudflare', undefined, appKey);
|
|
3067
3218
|
return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
|
|
3068
3219
|
}),
|
|
@@ -3156,7 +3307,7 @@ export function createTools(ctx) {
|
|
|
3156
3307
|
const directory = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
|
|
3157
3308
|
if (appKey !== undefined && directory === undefined)
|
|
3158
3309
|
return JSON.stringify({ appKey, connected: false, reason: `The setup state names no folder for the app ${appKey}.` });
|
|
3159
|
-
const result = await netlifyConnect(pi, { command, publish }, signal, directory, netlifyPreviewing.has(appKey ?? ''));
|
|
3310
|
+
const result = await netlifyConnect(pi, { command, publish }, signal, directory, netlifyPreviewing.has(appKey ?? ''), appKey);
|
|
3160
3311
|
if (result.how && result.site)
|
|
3161
3312
|
noteApp(appKey, { host_project: result.site.name });
|
|
3162
3313
|
return JSON.stringify(appKey !== undefined ? { appKey, ...result } : result);
|
|
@@ -3164,6 +3315,9 @@ export function createTools(ctx) {
|
|
|
3164
3315
|
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) => {
|
|
3165
3316
|
const host = p.host;
|
|
3166
3317
|
const standing = enableOutcomes.get(host);
|
|
3318
|
+
// The answer stands for the run, as before; its no ends the setup only of an app on the project it was for.
|
|
3319
|
+
if (standing?.status === 'skipped' && onProject(host, p.appKey, standing.project))
|
|
3320
|
+
decline('no-previews', p.appKey);
|
|
3167
3321
|
if (standing)
|
|
3168
3322
|
return JSON.stringify(standing);
|
|
3169
3323
|
const fact = previewFacts.get(host);
|
|
@@ -3175,6 +3329,8 @@ export function createTools(ctx) {
|
|
|
3175
3329
|
const answer = (status, more) => {
|
|
3176
3330
|
const told = { status, project: fact.project, ...more };
|
|
3177
3331
|
enableOutcomes.set(host, told);
|
|
3332
|
+
if (status === 'skipped')
|
|
3333
|
+
decline('no-previews', p.appKey);
|
|
3178
3334
|
return JSON.stringify(told);
|
|
3179
3335
|
};
|
|
3180
3336
|
const screen = HOST_SCREENS[`${host}-previews`];
|
|
@@ -3207,6 +3363,9 @@ export function createTools(ctx) {
|
|
|
3207
3363
|
}
|
|
3208
3364
|
}),
|
|
3209
3365
|
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) => {
|
|
3366
|
+
// The answer stands for the run, as before; its no ends the setup only of an app on the project it was for.
|
|
3367
|
+
if (connectOutcome?.status === 'skipped' && onProject('cloudflare', p.appKey, connectOutcome.project))
|
|
3368
|
+
decline('no-previews', p.appKey);
|
|
3210
3369
|
if (connectOutcome)
|
|
3211
3370
|
return JSON.stringify(connectOutcome);
|
|
3212
3371
|
const evidence = cloudflareEvidence;
|
|
@@ -3223,6 +3382,8 @@ export function createTools(ctx) {
|
|
|
3223
3382
|
const project = fact?.project || evidence.project || pagesProjectName(ctx.repo);
|
|
3224
3383
|
const answer = (status, more) => {
|
|
3225
3384
|
connectOutcome = { status, project, created: create, ...more };
|
|
3385
|
+
if (status === 'skipped')
|
|
3386
|
+
decline('no-previews', p.appKey);
|
|
3226
3387
|
return JSON.stringify(connectOutcome);
|
|
3227
3388
|
};
|
|
3228
3389
|
// The one screen: the connection of the project named, or the new project for the repository.
|
|
@@ -3314,6 +3475,12 @@ export function createTools(ctx) {
|
|
|
3314
3475
|
const asked = bypassOutcomes.get(store);
|
|
3315
3476
|
if (asked?.status === 'skipped')
|
|
3316
3477
|
return JSON.stringify(asked);
|
|
3478
|
+
/** The person's no to the secret leaves the previews behind Vercel's login: the setup ends there for the app (#413). */
|
|
3479
|
+
const noAccess = (outcome) => {
|
|
3480
|
+
if (outcome.status === 'skipped')
|
|
3481
|
+
decline('no-access', store);
|
|
3482
|
+
return outcome;
|
|
3483
|
+
};
|
|
3317
3484
|
/** 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. */
|
|
3318
3485
|
const nothing = (why) => (at ? `Refused: nothing was sent to ${at.url}. ${why}` : JSON.stringify({ ...(app ? { appKey: app.appKey } : {}), protected: 'unknown', reason: why }));
|
|
3319
3486
|
/** The consent screen, naming the project and the app once they are known (#358). */
|
|
@@ -3330,9 +3497,9 @@ export function createTools(ctx) {
|
|
|
3330
3497
|
*/
|
|
3331
3498
|
const signIn = async () => {
|
|
3332
3499
|
/** A skip, of the consent or of the sign-in, stands for the run: it is asked once, as the consent alone is. */
|
|
3333
|
-
const skip = (reason) => {
|
|
3500
|
+
const skip = (reason, declines = true) => {
|
|
3334
3501
|
const outcome = { status: 'skipped', reason };
|
|
3335
|
-
bypassOutcomes.set(store, outcome);
|
|
3502
|
+
bypassOutcomes.set(store, declines ? noAccess(outcome) : outcome);
|
|
3336
3503
|
return JSON.stringify(outcome);
|
|
3337
3504
|
};
|
|
3338
3505
|
if ((await ask(consentScreen())).skipped)
|
|
@@ -3353,7 +3520,8 @@ export function createTools(ctx) {
|
|
|
3353
3520
|
hostSignIns.delete('vercel');
|
|
3354
3521
|
if (signedIn.signedIn === true)
|
|
3355
3522
|
return null;
|
|
3356
|
-
|
|
3523
|
+
// A sign-in that did not complete is not a no.
|
|
3524
|
+
return skip(typeof signedIn.reason === 'string' ? signedIn.reason : "The person skipped Vercel's sign-in, which the bypass secret needs.", typeof signedIn.reason !== 'string');
|
|
3357
3525
|
};
|
|
3358
3526
|
// 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,
|
|
3359
3527
|
// with the CLI signed in as the person.
|
|
@@ -3399,7 +3567,7 @@ export function createTools(ctx) {
|
|
|
3399
3567
|
bypassConsented.add(`${store}/${link.projectId}`);
|
|
3400
3568
|
return JSON.stringify({ ...of, protected: true, consent: 'given' });
|
|
3401
3569
|
}
|
|
3402
|
-
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.` };
|
|
3570
|
+
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.` });
|
|
3403
3571
|
bypassOutcomes.set(store, outcome);
|
|
3404
3572
|
return JSON.stringify(outcome);
|
|
3405
3573
|
}
|
|
@@ -3454,7 +3622,7 @@ export function createTools(ctx) {
|
|
|
3454
3622
|
// 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).
|
|
3455
3623
|
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.`),
|
|
3456
3624
|
});
|
|
3457
|
-
const outcome = { ...told, ...(at.app ? { appKey: at.app.appKey } : {}), ...(told.status === 'saved' ? { proven: true } : {}) };
|
|
3625
|
+
const outcome = noAccess({ ...told, ...(at.app ? { appKey: at.app.appKey } : {}), ...(told.status === 'saved' ? { proven: true } : {}) });
|
|
3458
3626
|
bypassOutcomes.set(store, outcome);
|
|
3459
3627
|
if (value) {
|
|
3460
3628
|
// 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.
|
|
@@ -3503,8 +3671,11 @@ export function createTools(ctx) {
|
|
|
3503
3671
|
}
|
|
3504
3672
|
passwordStands.delete(site);
|
|
3505
3673
|
}
|
|
3506
|
-
else if (stands)
|
|
3674
|
+
else if (stands) {
|
|
3675
|
+
if (stands.status === 'skipped')
|
|
3676
|
+
decline('no-access', at.app?.appKey);
|
|
3507
3677
|
return JSON.stringify(stands);
|
|
3678
|
+
}
|
|
3508
3679
|
// 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.
|
|
3509
3680
|
const held = sitePasswords.get(site);
|
|
3510
3681
|
if (held && (await prove(held.value)).proven) {
|
|
@@ -3603,6 +3774,9 @@ export function createTools(ctx) {
|
|
|
3603
3774
|
lastTeamLogin = told.protection === 'team-login';
|
|
3604
3775
|
if (told.status === 'skipped' || told.status === 'login' || told.status === 'opened')
|
|
3605
3776
|
passwordStands.set(site, passwordOutcome);
|
|
3777
|
+
// 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).
|
|
3778
|
+
if (told.status === 'skipped')
|
|
3779
|
+
decline('no-access', at.app?.appKey);
|
|
3606
3780
|
if (told.status === 'opened') {
|
|
3607
3781
|
// The site is public now: its previews open with nothing sent.
|
|
3608
3782
|
hostedPreviewProven = true;
|
|
@@ -3644,8 +3818,10 @@ export function createTools(ctx) {
|
|
|
3644
3818
|
// The call for this app is made, whatever it answers: the skip asked once stands for the next app's lock too.
|
|
3645
3819
|
if (at.app)
|
|
3646
3820
|
lockPending.delete(at.app.appKey);
|
|
3647
|
-
if (accessOutcome?.status === 'skipped')
|
|
3821
|
+
if (accessOutcome?.status === 'skipped') {
|
|
3822
|
+
decline('no-access', at.app?.appKey);
|
|
3648
3823
|
return JSON.stringify(accessOutcome);
|
|
3824
|
+
}
|
|
3649
3825
|
let proof;
|
|
3650
3826
|
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 })));
|
|
3651
3827
|
const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
|
|
@@ -3720,6 +3896,9 @@ export function createTools(ctx) {
|
|
|
3720
3896
|
}
|
|
3721
3897
|
const pattern = told.status === 'saved' ? proof?.pattern : undefined;
|
|
3722
3898
|
accessOutcome = { ...told, ...(told.status === 'saved' ? { proven: true, ...(pattern ? { pattern } : {}) } : {}) };
|
|
3899
|
+
// The person's no to the service token leaves the previews behind Cloudflare Access: the setup ends there for the app (#413).
|
|
3900
|
+
if (told.status === 'skipped')
|
|
3901
|
+
decline('no-access', at.app?.appKey);
|
|
3723
3902
|
if (told.status === 'saved') {
|
|
3724
3903
|
hostedPreviewProven = true;
|
|
3725
3904
|
provenHost = 'Cloudflare Pages';
|