@impeccable/reviews 0.1.23 → 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/main.js +15 -8
- 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 +18 -3
- package/dist/preflight.js +2 -1
- package/dist/tools.js +188 -29
- package/dist/ui/look.js +8 -8
- package/package.json +1 -1
package/dist/fence.js
CHANGED
|
@@ -412,7 +412,7 @@ function namesTrackedFiles(paths, cwd) {
|
|
|
412
412
|
const named = new Set(paths.map((a) => resolve(cwd, a)));
|
|
413
413
|
return tracked !== null && tracked.split('\0').filter(Boolean).every((a) => named.has(resolve(cwd, a)));
|
|
414
414
|
}
|
|
415
|
-
function git(args, where, protectedBranches, held = false) {
|
|
415
|
+
function git(args, where, protectedBranches, held = false, ended = new Set()) {
|
|
416
416
|
const words = gitArgs(args);
|
|
417
417
|
if (!words)
|
|
418
418
|
return deny('a git option before the command: run git with the command first');
|
|
@@ -500,6 +500,9 @@ function git(args, where, protectedBranches, held = false) {
|
|
|
500
500
|
const branch = rest[2];
|
|
501
501
|
if (branch.includes(':') || ['HEAD', '@'].includes(pushedBranch(branch)) || protectedBranches.includes(pushedBranch(branch)))
|
|
502
502
|
return deny('a push to the default branch');
|
|
503
|
+
// The person's no ended the setup (#413): of a repository of one app, nothing is pushed; of an app of several, not its own test drive.
|
|
504
|
+
if (ended.has('') || [...ended].some((key) => pushedBranch(branch) === `impeccable/test-drive-${key}`))
|
|
505
|
+
return deny("a push after the person's no ended the setup: there is no preview to review it on");
|
|
503
506
|
// A connected Vercel project builds every push, and calls a new project's first deployment Production, public at the project's own address.
|
|
504
507
|
return held ? deny("a push before impeccable_vercel_connect settled the linked Vercel project's first deployment: run it first") : allow();
|
|
505
508
|
}
|
|
@@ -899,7 +902,7 @@ function simpleCommand(seg, where, opts) {
|
|
|
899
902
|
return deny('a cd that runs only sometimes: put it first, or after ;');
|
|
900
903
|
return isFolder(realPath(resolve(where.cwd, args[0]))) ? allow() : deny('a cd into a folder that is not there');
|
|
901
904
|
case 'git':
|
|
902
|
-
return git(args, where, opts.protectedBranches, opts.hosts.has('vercel') && !(opts.vercelSettled && opts.vercelSettled.projectId === (linkedVercelProject(where.root)?.projectId ?? null)));
|
|
905
|
+
return git(args, where, opts.protectedBranches, opts.hosts.has('vercel') && !(opts.vercelSettled && opts.vercelSettled.projectId === (linkedVercelProject(where.root)?.projectId ?? null)), opts.ended);
|
|
903
906
|
case 'gh':
|
|
904
907
|
// gh reads the file outside the sandbox, after the fence read it: a command still running in the sandbox can swap it in between.
|
|
905
908
|
if (args[0] === 'pr' && args[1] === 'create')
|
|
@@ -1202,7 +1205,7 @@ function unsyncedWorkflows(where, named, carried, synced) {
|
|
|
1202
1205
|
}
|
|
1203
1206
|
/** The fence's answer for one shell command run from the checkout's top folder. */
|
|
1204
1207
|
export function classifyCommand(command, root, options = {}) {
|
|
1205
|
-
const opts = { protectedBranches: options.protectedBranches ?? ['main', 'master'], scriptsChanged: options.scriptsChanged ?? false, hosts: options.hosts ?? new Set(), installs: options.installs ?? new Set(), teams: options.teams ?? new Map(), vercelSettled: options.vercelSettled ?? null, repository: options.repository };
|
|
1208
|
+
const opts = { protectedBranches: options.protectedBranches ?? ['main', 'master'], scriptsChanged: options.scriptsChanged ?? false, hosts: options.hosts ?? new Set(), installs: options.installs ?? new Set(), teams: options.teams ?? new Map(), vercelSettled: options.vercelSettled ?? null, ended: options.ended ?? new Set(), repository: options.repository };
|
|
1206
1209
|
const segments = parseCommand(command);
|
|
1207
1210
|
if (!segments) {
|
|
1208
1211
|
// A here-document is how a file or a message gets written in one command: the write tool, -m and --body do that.
|
|
@@ -1628,6 +1631,22 @@ export function previewRetryState(log) {
|
|
|
1628
1631
|
const setupBranch = (branch) => /^impeccable\/setup(?:-(?:[2-9]|[1-9][0-9]+))?$/.test(branch);
|
|
1629
1632
|
/** The test drive's branch: impeccable/test-drive, an app's impeccable/test-drive-<app key> on a repository with several, each with -2, -3 where an earlier run took it. */
|
|
1630
1633
|
const driveBranch = (branch) => /^impeccable\/test-drive(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?$/.test(branch) && !branch.includes('..') && !branch.endsWith('.') && !branch.endsWith('.lock');
|
|
1634
|
+
/** Under --signed-in-test-drive, why a test drive's branch may not be pushed or opened yet: its app has no test account saved (#410). An app's branch is impeccable/test-drive-<app key>, with -2, -3 where an earlier run took it; a run that sets up one app of several uses impeccable/test-drive. */
|
|
1635
|
+
function signedInRefusal(state, branch) {
|
|
1636
|
+
const signedIn = state.signedIn;
|
|
1637
|
+
if (!signedIn)
|
|
1638
|
+
return;
|
|
1639
|
+
const rest = branch.slice('impeccable/test-drive'.length);
|
|
1640
|
+
const picked = signedIn.picked ?? [];
|
|
1641
|
+
const named = (key) => rest === `-${key}` || (rest.startsWith(`-${key}-`) && /^\d+$/.test(rest.slice(key.length + 2)));
|
|
1642
|
+
// An app's own branch, a numeric key's too, else the unnamed branch of a run that picked one app.
|
|
1643
|
+
const saved = !signedIn.apps ? signedIn.saved.has('')
|
|
1644
|
+
: picked.some((key) => named(key)) ? picked.some((key) => named(key) && signedIn.saved.has(key))
|
|
1645
|
+
: /^(?:-\d+)?$/.test(rest) && picked.length === 1 && signedIn.saved.has(picked[0]);
|
|
1646
|
+
if (saved)
|
|
1647
|
+
return;
|
|
1648
|
+
return deny(`the run was started with --signed-in-test-drive, and no test account is saved${signedIn.apps ? ' for this app' : ''}, so its test drive cannot change a page behind the sign-in. Save the accounts first, as the sign-in case says. Where the app has no sign-in of its own, or every account was skipped, push and open no test drive: say in the report that --signed-in-test-drive could not be honoured, and why`);
|
|
1649
|
+
}
|
|
1631
1650
|
/** A GitHub repository as `<owner>/<name>`. */
|
|
1632
1651
|
const REPOSITORY = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/[A-Za-z0-9_.-]{1,100}$/;
|
|
1633
1652
|
/** `gh pr ready <the test drive's branch> [--repo <owner>/<name>]`, and nothing else (never `--undo`, a number or another branch): the branch and the repository named, or null. */
|
|
@@ -1728,6 +1747,9 @@ export function reservePreviewCommand(state, command, root, scratch) {
|
|
|
1728
1747
|
// The test drive is the run's other pull request (one an app on a repository with several): one branch, one pull request, a few pushes, none of the setup pull request's attempts.
|
|
1729
1748
|
const drivePush = pushBranch(gitWords, driveBranch);
|
|
1730
1749
|
if (drivePush) {
|
|
1750
|
+
const unsigned = signedInRefusal(state, drivePush);
|
|
1751
|
+
if (unsigned)
|
|
1752
|
+
return unsigned;
|
|
1731
1753
|
const drive = driveOf(drivePush);
|
|
1732
1754
|
if (!drive)
|
|
1733
1755
|
return deny(state.drivesAllowed > 1 ? `one test drive for each of the ${state.drivesAllowed} apps this run sets up, each on one branch` : 'keep the test drive on one branch');
|
|
@@ -1761,6 +1783,9 @@ export function reservePreviewCommand(state, command, root, scratch) {
|
|
|
1761
1783
|
|| (!parsed.repository && realPath(where.cwd) !== where.root))
|
|
1762
1784
|
return deny('name this repository explicitly when creating a pull request');
|
|
1763
1785
|
if (parsed.head && driveBranch(parsed.head)) {
|
|
1786
|
+
const unsigned = signedInRefusal(state, parsed.head);
|
|
1787
|
+
if (unsigned)
|
|
1788
|
+
return unsigned;
|
|
1764
1789
|
const drive = driveOf(parsed.head);
|
|
1765
1790
|
if (!drive)
|
|
1766
1791
|
return deny(state.drivesAllowed > 1 ? `one test drive for each of the ${state.drivesAllowed} apps this run sets up, each opened from the branch that was pushed` : 'open the test drive from the branch that was pushed');
|
|
@@ -2000,6 +2025,17 @@ export function settleVercelFirstDeployment(log, settlement) {
|
|
|
2000
2025
|
export function vercelFirstDeploymentSettled(log) {
|
|
2001
2026
|
return settledVercel.get(log) ?? null;
|
|
2002
2027
|
}
|
|
2028
|
+
/** The apps whose setup the person's no ended (#413), by the run's log: '' for a repository of one app, which then pushes nothing; an app's key, whose own test drive is not pushed. */
|
|
2029
|
+
const endedSetups = new WeakMap();
|
|
2030
|
+
function setupsEnded(log) {
|
|
2031
|
+
let keys = endedSetups.get(log);
|
|
2032
|
+
if (!keys)
|
|
2033
|
+
endedSetups.set(log, (keys = new Set()));
|
|
2034
|
+
return keys;
|
|
2035
|
+
}
|
|
2036
|
+
export function endAppSetup(log, key) {
|
|
2037
|
+
setupsEnded(log).add(key);
|
|
2038
|
+
}
|
|
2003
2039
|
/** Tool calls a run makes before the fence refuses more: a setup takes far fewer, and a run steered elsewhere stops there. */
|
|
2004
2040
|
export const MAX_TOOL_CALLS = 400;
|
|
2005
2041
|
/** What still runs past the cap, so the run can say where it got to and end. */
|
|
@@ -2125,7 +2161,7 @@ export function fenceExtension(root, log, protectedBranches, scratch, scans = sc
|
|
|
2125
2161
|
let shown;
|
|
2126
2162
|
if (event.toolName === 'bash') {
|
|
2127
2163
|
shown = String(input.command ?? '');
|
|
2128
|
-
decision = classifyCommand(shown, root, { protectedBranches, scriptsChanged, scratch, hosts: hostsAgreed(log), installs: installsAgreed(log), teams: teamsChosen(log), vercelSettled: vercelFirstDeploymentSettled(log), touched, synced, repository: retry.repository });
|
|
2164
|
+
decision = classifyCommand(shown, root, { protectedBranches, scriptsChanged, scratch, hosts: hostsAgreed(log), installs: installsAgreed(log), teams: teamsChosen(log), vercelSettled: vercelFirstDeploymentSettled(log), ended: setupsEnded(log), touched, synced, repository: retry.repository });
|
|
2129
2165
|
if (decision.verdict === 'allow')
|
|
2130
2166
|
decision = reservePreviewCommand(retry, shown, root, scratch);
|
|
2131
2167
|
if (decision.verdict === 'allow') {
|
package/dist/focus.js
CHANGED
|
@@ -32,6 +32,23 @@ const hasApps = (repo) => Boolean(record(repo.items)?.[APPS.item]) || records(re
|
|
|
32
32
|
export function severalApps(answer) {
|
|
33
33
|
return reposOf(answer).some(hasApps);
|
|
34
34
|
}
|
|
35
|
+
/** The state's sign-in item of `repo`, which it has only when the scan read a sign-in of the app's own. */
|
|
36
|
+
export const signInItem = (state, repo) => record(record(reposOf(state).find((r) => String(r.name).toLowerCase() === repo.toLowerCase())?.items)?.signin);
|
|
37
|
+
/**
|
|
38
|
+
* The apps of `repo` with a test account saved, as the sign-in item says (#410): '' on a repository of one app. On one
|
|
39
|
+
* with several, the item speaks for its first picked app with that app's roles, and the others ride along in `perApp`
|
|
40
|
+
* with their status alone, `done` once an account is saved.
|
|
41
|
+
*/
|
|
42
|
+
export function accountsSaved(state, repo) {
|
|
43
|
+
const item = signInItem(state, repo);
|
|
44
|
+
if (!item)
|
|
45
|
+
return [];
|
|
46
|
+
const saved = Array.isArray(item.rolesSaved) && item.rolesSaved.length > 0;
|
|
47
|
+
if (!severalApps(state))
|
|
48
|
+
return saved ? [''] : [];
|
|
49
|
+
const keys = records(item.perApp).filter((app) => app.status === 'done').map((app) => app.app);
|
|
50
|
+
return [...new Set([...(saved ? [item.app] : []), ...keys])].filter((key) => typeof key === 'string' && key !== '');
|
|
51
|
+
}
|
|
35
52
|
/**
|
|
36
53
|
* Whether the state or the checkup shows a card of this setup `not-selected` on `repo`, a repository with several apps.
|
|
37
54
|
* The command's token covers the apps there were when the person signed in, so this is an app the scan proposed later.
|
package/dist/main.js
CHANGED
|
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { runAgent } from './agent.js';
|
|
7
7
|
import { fenceExtension, guardedFileOperations, hostsAgreed, placeOf, previewRetryState } from './fence.js';
|
|
8
|
-
import { focusInstructions, focusState, severalApps } from './focus.js';
|
|
8
|
+
import { accountsSaved, focusInstructions, focusState, severalApps, signInItem } from './focus.js';
|
|
9
9
|
import { requestJson } from './http.js';
|
|
10
10
|
import { copyText, letTabsHear, openBrowser, progressPage, screenPrompt, setupAuth, signIn } from './login.js';
|
|
11
11
|
import { createLog, UserError } from './log.js';
|
|
@@ -110,7 +110,7 @@ export async function main(argv) {
|
|
|
110
110
|
// Printed as the command exits, after the screen has gone, so it stays in the scrollback.
|
|
111
111
|
if (log.path)
|
|
112
112
|
atExit(() => [`Debug log: ${log.path}`]);
|
|
113
|
-
log.write('start', { host: flags.host, model: flags.model, effort: flags.effort, yes: flags.yes, node: process.versions.node });
|
|
113
|
+
log.write('start', { host: flags.host, model: flags.model, effort: flags.effort, yes: flags.yes, signedInTestDrive: flags.signedInTestDrive, node: process.versions.node });
|
|
114
114
|
if (flags.frames)
|
|
115
115
|
recordFrames(flags.frames);
|
|
116
116
|
const checkout = readCheckout(flags.installDir?.path ?? process.cwd(), flags.installDir?.from);
|
|
@@ -227,6 +227,10 @@ export async function main(argv) {
|
|
|
227
227
|
if (flags.app && severalApps(state.data) && !appKeys.includes(flags.app)) {
|
|
228
228
|
throw new UserError(`--app ${flags.app} is not an app of ${slug}. Its apps are ${appKeys.join(', ')}. Run it again with one of them, or without --app.`);
|
|
229
229
|
}
|
|
230
|
+
// --signed-in-test-drive changes a page behind the app's own sign-in: with none found, it can't be honoured, and nothing is set up (#410).
|
|
231
|
+
if (flags.signedInTestDrive && !signInItem(state.data, token.repository.name)) {
|
|
232
|
+
await nothingToSetUp(liveSync, reporter, 'failed', 'No sign-in found', `The run was started with --signed-in-test-drive, but Impeccable found no sign-in of the app's own in ${slug}, so the test drive can't change a page behind one. Nothing was set up.`, [webPage]);
|
|
233
|
+
}
|
|
230
234
|
// The setup pushes as the person, so whether git may push is asked of GitHub now, before anything is created.
|
|
231
235
|
// A question asked before the agent starts. Ctrl+C there answers it as closed while stopEarly winds the run down and
|
|
232
236
|
// exits, so nothing after it runs: no update over the stop, and no agent started.
|
|
@@ -289,6 +293,8 @@ export async function main(argv) {
|
|
|
289
293
|
// On a repository with several apps, "You're all set up" means every app this run picked is: one left unproven is named, with its one step (#286). A run short of that says what is not, and exits 1 (#321).
|
|
290
294
|
const apps = appsSummary(report.apps ?? []);
|
|
291
295
|
const end = ending(report);
|
|
296
|
+
// The person's no left no app with a preview Impeccable can open: the screen says why alone, with Close, and the report follows it here (#413).
|
|
297
|
+
const ended = Boolean(report.ended);
|
|
292
298
|
// One pull request a screen, each opened in the browser and put on the clipboard as its screen appears: the test drive (each app's, on a repository with several), then the setup pull request.
|
|
293
299
|
const hand = { open: openBrowser, copy: copyText };
|
|
294
300
|
if (drives)
|
|
@@ -299,18 +305,18 @@ export async function main(argv) {
|
|
|
299
305
|
await showOutro({
|
|
300
306
|
outcome: end.outcome,
|
|
301
307
|
title: end.title,
|
|
302
|
-
result: [resultLine({ ...report, setupPr }), apps.line].filter(Boolean).join(' '),
|
|
308
|
+
result: ended ? resultLine(report) : [resultLine({ ...report, setupPr }), apps.line].filter(Boolean).join(' '),
|
|
303
309
|
// Each app's test drive had its own screen: the last one holds the setup pull request, or lists them all.
|
|
304
|
-
link: (setupPr ?? (drives ? undefined : testPr))?.url,
|
|
305
|
-
links: [
|
|
310
|
+
link: ended ? undefined : (setupPr ?? (drives ? undefined : testPr))?.url,
|
|
311
|
+
links: ended ? [] : [
|
|
306
312
|
...(drives ? drives.map((drive) => ({ label: `${drive.name}'s test drive, watch the review there`, url: drive.url })) : testPr ? [{ label: 'Test drive, watch the review there', url: testPr.url }] : []),
|
|
307
313
|
// Without a current test drive the setup pull request is not ready to merge; the report says why.
|
|
308
314
|
...(setupPr ? [{ label: testPr ? 'Setup pull request, merge this' : 'Setup pull request', url: setupPr.url }] : []),
|
|
309
315
|
{ label: 'Web page', url: page },
|
|
310
316
|
],
|
|
311
317
|
todo: end.todo,
|
|
312
|
-
...(report.hosts?.length ? { used: report.hosts } : {}),
|
|
313
|
-
warnings: [...removed, ...left, ...(await unopened(drives ? drives.length : testPr ? 1 : 0)), ...(saved && !saved.ok ? [`The web page missed the last update (${saved.error}), so it may lag behind this.`] : []), ...(prSummary === 'off' ? [PR_SUMMARY_OFF] : [])],
|
|
318
|
+
...(report.hosts?.length && !ended ? { used: report.hosts } : {}),
|
|
319
|
+
warnings: [...removed, ...left, ...(await unopened(drives ? drives.length : testPr ? 1 : 0)), ...(saved && !saved.ok ? [`The web page missed the last update (${saved.error}), so it may lag behind this.`] : []), ...(prSummary === 'off' && !ended ? [PR_SUMMARY_OFF] : [])],
|
|
314
320
|
}, hand);
|
|
315
321
|
// The web page has the report; when it missed it, or the run fell short of all set up, whose why it holds, the report stays here as well.
|
|
316
322
|
if (!saved?.ok || end.code)
|
|
@@ -349,7 +355,7 @@ export async function main(argv) {
|
|
|
349
355
|
effort: flags.effort,
|
|
350
356
|
gatewayUrl: run.gateway_url,
|
|
351
357
|
runToken,
|
|
352
|
-
systemPrompt: playbook({ owner: checkout.owner, name: token.repository.name, scratch, sandboxed: !sandbox.warning }),
|
|
358
|
+
systemPrompt: playbook({ owner: checkout.owner, name: token.repository.name, scratch, sandboxed: !sandbox.warning, signedInTestDrive: flags.signedInTestDrive }),
|
|
353
359
|
firstMessage: firstMessage({ owner: checkout.owner, name: token.repository.name }),
|
|
354
360
|
firstContext: setupContext(focused, focusState(state.data)),
|
|
355
361
|
tools: createTools({
|
|
@@ -370,6 +376,7 @@ export async function main(argv) {
|
|
|
370
376
|
severalApps: severalApps(state.data),
|
|
371
377
|
...(flags.app ? { app: flags.app } : {}),
|
|
372
378
|
start: checkout.start,
|
|
379
|
+
...(flags.signedInTestDrive ? { signedInTestDrive: { accountsSaved: accountsSaved(state.data, token.repository.name) } } : {}),
|
|
373
380
|
}),
|
|
374
381
|
extensions: [
|
|
375
382
|
fenceExtension(checkout.root, log, checkout.protectedBranches, scratch, scans),
|
|
@@ -17,7 +17,7 @@ Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_pr
|
|
|
17
17
|
2. **Not connected** (`cause: "source"`): the Pages project the checkout names (its wrangler config, or the README's pages.dev address) exists with no Git repository connected, so no push builds. impeccable_cloudflare_connect connects it to this repository.
|
|
18
18
|
3. **A Worker, not a Pages project** (`worker` in the answer, and no `pullRequestPreviews`): Cloudflare builds the repository as a Worker (Workers Builds: its check runs name the script, or the wrangler config or the README's workers.dev address does), which gives pull requests no preview, and a Worker with static assets no branch build at all. impeccable_cloudflare_connect, with the build command and output folder the checkout runs, creates a Pages project for the repository and connects it; the Worker stays as it is. The same tool creates the project where the config names a Pages project cf finds nowhere.
|
|
19
19
|
4. **Cloudflare's GitHub app cannot see the repository** (`githubApp` in the answer: the app's installation covers selected repositories, or `installed: false`): a project connected with previews on still gets no build, since the app builds only the repositories its installation names. Nothing in the account shows it, so the wait for the test drive's build tells: when none comes, the wait's own screen opens the installation page (`githubApp.page`) and waits again on the person's yes. Its `reason` says when they skipped it or no build came after the retries.
|
|
20
|
-
- **`skipped` or `refused` from any of these**: push no test drive on Cloudflare (pull-requests' "No preview, no test drive" where a preview was proven; else set the preview up as server-previews says, never without saying so) and say under "What was skipped, and why" which cause stopped it, with the `reason`, and name the `page` where there is one; the person was asked in this run, so `left` stays empty.
|
|
20
|
+
- **`skipped` or `refused` from any of these**: push no test drive on Cloudflare (pull-requests' "No preview, no test drive" where a preview was proven; else set the preview up as server-previews says, never without saying so) and say under "What was skipped, and why" which cause stopped it, with the `reason`, and name the `page` where there is one; the person was asked in this run, so `left` stays empty. `skipped`, or the GitHub app's access skipped on the wait's screen, with no method saved, is a no that ends the setup, as the playbook says: no other preview is set up.
|
|
21
21
|
- **No `pullRequestPreviews`, no `worker`** (`cli` signed out or unknown, or no Pages project built from this repository among the accounts cf reaches): the check could not be made. Say so in one line under "What is not verified", naming why, and go on as the aim says: nothing listed, as "Nothing listed" says; a preview listed, as the steps say, where the wait for the test drive's build then tells.
|
|
22
22
|
- **Nothing listed**, and neither `pullRequestPreviews` nor `worker` nor a project the checkout names: Cloudflare's previews are not on record, so set the preview up as server-previews says. An `error` means a read failed, so a preview may exist that is not listed: say so under "What is not verified".
|
|
23
23
|
- **The pull requests**: no setup pull request; the test drive branches from the default branch, and Cloudflare builds every pushed branch, so pull-requests' step 4 goes the Vercel way with impeccable_cloudflare_previews in place of impeccable_hosted_previews: with `branch: "impeccable/test-drive"` it waits up to five minutes for the check run on the pushed head, under a status line with its timer, and the test drive's page is proven on the alias it lists before the pull request opens. In place of pull-requests' step 3, build the test drive branch once with the build Cloudflare runs (the Pages project's build command, which the checkout's `package.json` `build` script usually is), so a page that does not build is never pushed.
|
|
@@ -8,9 +8,9 @@ The state's repository has an `apps` item: the scan proposed several apps, folde
|
|
|
8
8
|
2. **Ask which apps this run sets up**, on every run, with impeccable_ask: `id: "apps"`, `kind: "multi"`, and the `prompt` *"Which apps should get Impeccable previews?"*. The tool words the question and lists the state's apps (the repository's own, `main`, only where the Worker kept it as a product of its own), each picked as it is on, or every one after this run's confirm. Mark the Set up monorepo apps task in progress first. Every write and host tool for an app is refused until it is answered. It has no Skip: the person picks at least one app. Done when it answered `values` or `named` (the command was started for that one app with `--app`, from its card on the dashboard: the tool showed one confirm screen naming that app instead of the list, and the list all the same when the person asked for other apps there). Where no answer came (the input ended), the tool wrote nothing and the run ends there: call nothing more. After `named`, that app is the one this run sets up: every host read, key, proof, method and check is its own, and no other app is mentioned past that screen.
|
|
9
9
|
3. **Write the picks**, one action a call as the item's instructions give them: `enable` each chosen app that is off, then `disable` each app that is on and was not chosen. The last app that is on cannot be switched off (409), which is why the enables come first. **After `named`**, `enable` that app when it is off, and switch no other off. Done when the last answer's `apps` show `enabled` true for every chosen app and false for the rest (after `named`, true for that app).
|
|
10
10
|
4. **Read the state again** with impeccable_state. Done when the preview item's `apps` names each chosen app's `directory`, `build` and `method` (none yet for an app not set up), and `perApp` its status.
|
|
11
|
-
5. **Set each chosen app up**, one after the other, as the playbook's steps 3 and 4 say for that app alone, `appKey` on every write and check (`preview`, `test-accounts`, `latest-pull-check`, `connection-check`): its method by the case that applies to it (server-previews when no host deploys it, a host's case when the host deploys that app as a project of its own, proven on that app's own address: impeccable_hosted_previews, impeccable_netlify_previews and impeccable_cloudflare_previews take its `appKey` and list only the previews of the project or site that builds its folder (impeccable_netlify_connect takes it too, and connects that site), so another app's address, project or key never stands in for it; their `unmatched` says why none can be told, and that app goes as server-previews says, with the reason under "What is not verified"), and its own sign-in when the scan item's `accessByApp` says it has one, each account question's title naming the app (*"Order desk admin account"*). **The lock in front of each app's previews is that app's own**: when its proof answers a `gate`, preview-protection's steps run for that app, with its `appKey`, before any other app's step (the tools refuse the next app's reads and writes until its key tool was called), and the key goes to that app's own project or site; a key saved for one app says nothing about another's, and the state's `keySaved` speaks for one app at most. Where a host's setup case (vercel-setup, netlify-setup) opens and records an app's test drive here, check that app's preview right after the recording, as pull-requests' step 6 says with its `appKey`, while its test drive is still the newest open pull request, before the next app is set up. Done when every chosen app's method is saved, or its preview skipped as server-previews says, and its sign-in saved or skipped, and no other app got a write, a build or a question.
|
|
11
|
+
5. **Set each chosen app up**, one after the other, as the playbook's steps 3 and 4 say for that app alone, `appKey` on every write and check (`preview`, `test-accounts`, `latest-pull-check`, `connection-check`), and on the host question, a host's sign-in and team questions, impeccable_enable_previews and impeccable_cloudflare_connect, so a no ends the setup for that app alone: its method by the case that applies to it (server-previews when no host deploys it, a host's case when the host deploys that app as a project of its own, proven on that app's own address: impeccable_hosted_previews, impeccable_netlify_previews and impeccable_cloudflare_previews take its `appKey` and list only the previews of the project or site that builds its folder (impeccable_netlify_connect takes it too, and connects that site), so another app's address, project or key never stands in for it; their `unmatched` says why none can be told, and that app goes as server-previews says, with the reason under "What is not verified"), and its own sign-in when the scan item's `accessByApp` says it has one, each account question's title naming the app (*"Order desk admin account"*). **The lock in front of each app's previews is that app's own**: when its proof answers a `gate`, preview-protection's steps run for that app, with its `appKey`, before any other app's step (the tools refuse the next app's reads and writes until its key tool was called), and the key goes to that app's own project or site; a key saved for one app says nothing about another's, and the state's `keySaved` speaks for one app at most. Where a host's setup case (vercel-setup, netlify-setup) opens and records an app's test drive here, check that app's preview right after the recording, as pull-requests' step 6 says with its `appKey`, while its test drive is still the newest open pull request, before the next app is set up. Done when every chosen app's method is saved, or its preview skipped as server-previews says, and its sign-in saved or skipped, and no other app got a write, a build or a question.
|
|
12
12
|
6. **The pull requests** as pull-requests says: the setup commit once for the repository, taking the state's one workflow `file` once every chosen app's method and build settings are saved, or its preview skipped as server-previews says. A run that sets up one app opens its test drive as pull-requests says, on `impeccable/test-drive`, its page in that app alone. A run that sets up more opens **a test drive of its own for each chosen app with a preview**, one app after the other, so each app's review shows on its own pull request; an app whose host's setup case (vercel-setup, netlify-setup) already opened, recorded and checked its test drive in step 5 has it, and gets none again. An app's test drive is branch `impeccable/test-drive-<appKey>` from the setup commit (from the default branch when there is none), with its page in that app alone, in that app's own framework and linked from that app's own public page, and no change to any other app; that case's step 3 builds that app as its host's case says; its step 4 pushes it and proves its page on that app's own preview, with that app's key where its lock stands in front; its step 5 opens it with the app's name in the title (*"Impeccable test drive: Storefront (safe to close)"*) and records it with impeccable_test_pr and that app's `appKey` (which the tool needs only when the run sets up more than one app); its step 6 checks that app with its `appKey` while its test drive is the newest open pull request, before the next app's test drive opens. An app whose preview was skipped gets no test drive, and goes under "What was skipped, and why". Step 7, when there is a setup commit, opens the setup pull request once, last, and records it with the last app's test drive and its `appKey` again. Done when every chosen app with a preview has its test drive recorded and checked, or the reason it has none in the report, and pull-requests' step 7 is done.
|
|
13
|
-
- **A chosen app whose preview was skipped** (server-previews): it stays on, with no method, no job in the workflow and no test drive page. It holds nothing back: the other apps' setup commit, test drive and checks go on as above, and its checkup entry left in `next` counts as done for the playbook's step 5. The report says, on its line and under "What was skipped, and why", that it gets no preview until its host deploys them.
|
|
13
|
+
- **A chosen app whose preview was skipped** (server-previews), or whose setup ended on a no (the playbook's "A no that ends the setup"): it stays on, with no method, no job in the workflow and no test drive page. It holds nothing back: the other apps' setup commit, test drive and checks go on as above, and its checkup entry left in `next` counts as done for the playbook's step 5. The report says, on its line and under "What was skipped, and why", that it gets no preview until its host deploys them, or which no ended its setup. When no chosen app is left with a preview, call impeccable_report: the last screen says why the setup ended.
|
|
14
14
|
7. **Report per app**: under "What was configured, and by whom", one line per app the question offered: on or off and why (picked, unticked, named with `--app`), its method, its sign-in, what was verified; an app the question did not offer and the state shows off (`enabled` false) was turned off on the setup page, and its line says so, never that it was left off in the question; after `named`, an app still on that this run did not set up is outside this run, not turned off; an app left out is under "What was skipped, and why" as well, with the setup page as where it is turned on. impeccable_report adds a section of its own, "Each app, as this run left it", from what the run saved and proved, and the last screen says "You're all set up" only when every chosen app is: an app whose preview met a lock with no key saved, or got no method, is named there with its one step, so the report's lines say the same. Done when every app is on one line.
|
|
15
15
|
|
|
16
16
|
### Reference
|
|
@@ -7,10 +7,10 @@ The checkout is set up for Netlify: a `netlify.toml`, a `.netlify/` folder, or a
|
|
|
7
7
|
1. **Sign in**, before any other preview work, with impeccable_ask: `kind: "confirm"`, `id: "netlify-setup"`. The tool does the rest: it shows the setup screen once (a person signed in already confirms the setup with that sign-in on it, with no login to run), runs the login in the browser when the Netlify CLI is signed out and offers a retry. Done when it answered `confirmed`.
|
|
8
8
|
- **`cli: "missing"`**: the CLI was not on this machine, and the person said Install on the tool's screen for it: install it with `npm install -g netlify-cli@27.11.2`, exactly so (the run installs it with npm alone, pinned to that version, only after that Install, and refuses another package manager, version or tag); then ask again. The setup screen shows no second time; an install that did not complete brings the steps for the person's own install up instead.
|
|
9
9
|
- **`teams` in the answer** (the person has several Netlify teams): ask once which owns the site, `kind: "single"`, `id: "netlify-team"`, `options` the teams as given (the tool words the rest), and pass the pick to `sites:create` as `--account-slug`.
|
|
10
|
-
- **Skip**:
|
|
10
|
+
- **Skip**: a no that ends the setup, as the playbook says: say under "What was skipped, and why" that Netlify was found and the sign-in was skipped. The steps below do not apply.
|
|
11
11
|
2. **Link or create the site** (run each Netlify command plain, on its own: a command chained after `sites:create` leaves the site unrecorded): `netlify link --git-remote-url <the GitHub origin>` links a site Netlify already builds from this repository; when it finds none, `netlify sites:create --name <name>` creates one in the person's team and links this folder. Name it after the repository: lowercase letters, digits and hyphens, at most 40 characters; a name Netlify says is taken gets `-2`. Done when `netlify status` names the linked project.
|
|
12
12
|
- **A site already built from this repository** (impeccable_netlify_previews named it in `sites`; on a repository with several apps, read with the app's `appKey`): link nothing, since `netlify link` asks in a terminal this run does not have when several sites are built from the repository. Step 3 finds that site by itself.
|
|
13
|
-
3. **Connect the repository** with impeccable_netlify_connect (with the app's `appKey` on a repository with several apps): it connects the linked site to this repository through Netlify's GitHub app, so pull requests build deploy previews, when that site is the one this run created or one built from this repository already (a site built from another repository, or one the run did not create, is left as it is, with the reason), and asks the person for the app's access when Netlify cannot read the repository. Give `command` and `publish` only where no `netlify.toml` sets the build. Done when it answered `connected: true`; a site `netlify link` found is connected already, and it says so. **Deploy Previews off** on that site (`pullRequestPreviews` in its answer, or in impeccable_netlify_previews', `enabled: false`): the aim holds, so ask once with impeccable_enable_previews (`host: "netlify"`) before step 4, and never a second time. `enabled`: step 4. `skipped`
|
|
13
|
+
3. **Connect the repository** with impeccable_netlify_connect (with the app's `appKey` on a repository with several apps): it connects the linked site to this repository through Netlify's GitHub app, so pull requests build deploy previews, when that site is the one this run created or one built from this repository already (a site built from another repository, or one the run did not create, is left as it is, with the reason), and asks the person for the app's access when Netlify cannot read the repository. Give `command` and `publish` only where no `netlify.toml` sets the build. Done when it answered `connected: true`; a site `netlify link` found is connected already, and it says so. **Deploy Previews off** on that site (`pullRequestPreviews` in its answer, or in impeccable_netlify_previews', `enabled: false`): the aim holds, so ask once with impeccable_enable_previews (`host: "netlify"`) before step 4, and never a second time. `enabled`: step 4. `skipped`: a no that ends the setup, as the playbook says, with the `reason` and the `page` in the report. `refused`: the setup ends here, as "A step fails before the test drive is pushed" says, with the `reason` and the `page` in the report, and `left` empty, since the person was asked in this run.
|
|
14
14
|
4. **Build and push the test drive** (the Test drive task) as pull-requests' steps 2 to 4 say (on a repository with several apps, where the run sets up more than one, that app's own test drive, on `impeccable/test-drive-<appKey>` with its page in that app alone, as the monorepo case's step 6 says), with the changes netlify-previews' reference gives: from the default branch, no setup pull request, built once with the build Netlify runs. Stage the new page and the page that links to it by name: `netlify sites:create` writes `.netlify/` and may add it to `.gitignore`, and that change stays out of the pull request. Done when `git push -u origin impeccable/test-drive` (the app's own branch, where it has one) succeeded.
|
|
15
15
|
5. **Its draft, its preview proven and saved, then marked ready** as pull-requests' step 4 (Netlify) and step 5 say: the draft, whose deploy preview is the site's first, the wait for it by `pull`, the test drive's page proven on it, impeccable_write to `preview` as netlify-previews' step 2 says, `gh pr ready`, then the recording. The playbook's step 6 then has no pull request left to open: go on with the playbook's step 4. Done when impeccable_test_pr answered "Recorded".
|
|
16
16
|
|
|
@@ -18,7 +18,7 @@ The checkout is set up for Netlify: a `netlify.toml`, a `.netlify/` folder, or a
|
|
|
18
18
|
|
|
19
19
|
- **The live app**: a site created here has none yet, so the before stays unset, which the report says (live-app-address).
|
|
20
20
|
- **The aim** holds here: the CLI does the work, and the person is asked only what the playbook's aim allows.
|
|
21
|
-
- **A step fails before the test drive is pushed** (the sign-in
|
|
21
|
+
- **A step fails before the test drive is pushed** (the sign-in not completed, the site not created, the repository not connected): the Netlify setup ends there. Set the preview up as server-previews says, say under "What was skipped, and why" what stopped it, and put the person's step in `left` when there is one, such as "Give Netlify's GitHub app access to the repository", with the `access` page in the report. The sign-in or the GitHub app's access skipped is a no that ends the setup, as the playbook says.
|
|
22
22
|
- **No preview for the draft**: save no method, set up no other preview, and leave the draft as it is, unrecorded, as pull-requests' "No preview, no test drive" says; the site and the connection stay, and the report names them.
|
|
23
23
|
- **The build failed** (`state` is `error` or `failure`): say so under "What is not verified", with the command that builds this linked checkout as Netlify does and prints why, for the person to run: `netlify deploy --build --context deploy-preview`. It never runs here, since it builds with their Netlify sign-in.
|
|
24
24
|
- **No deploy preview within five minutes**: the wait's own screen opens the site's deploy settings, where Deploy Previews are turned on, and waits again on the person's yes (a site whose settings said they were off was asked about in step 3, so this wait is for a build, not a setting). When they skip it, or none came after its retries, say under "What is not verified" that Netlify built no deploy preview here, and that Deploy Previews may be off in the site's settings.
|
|
@@ -12,14 +12,15 @@ impeccable_prove_preview answered a `gate`: the host's own lock stands in front
|
|
|
12
12
|
- Any answer with `replaced` but `saved`: the run set a new Netlify site password, which replaced the one before, and it is not saved for reviews; the report says so, and that the person sets a password they know in Visitor access and in the app's Settings, whatever step comes next.
|
|
13
13
|
- `refused` with `protection: "none"` (Netlify: the site's settings show neither a password nor the team login, or the site is public now, `madePublic` saying who made it so, and the page past the login is not the app's), or on Vercel whose `reason` says the settings show no Deployment Protection: the page you proved is the app's own sign-in or another page, however it is worded, and the previews are open. Prove a page a visitor opens without signing in, as vercel-previews' or netlify-previews' step 1 says, and on `proven: true` go on with that case's step 2, the save, calling this tool no more: there is no lock to get past. No such page proven: the host's previews do not show this app to a visitor, so go on as that case's "Another app, or no answer" says (set the preview up as server-previews says, and say under "What is not verified" what the address answered), never step 3, whose lock the settings show is not there.
|
|
14
14
|
- Any other `refused` on Netlify whose `reason` says to prove or try a page a visitor opens, or that the page asks for the app's own sign-in past the lock: the site password (Netlify's own, or the one the run set) got past the lock to the app's own sign-in on the page you proved, or Netlify's form did not take it, or Netlify did not answer. Prove a page a visitor opens without signing in, as netlify-previews' step 1 says, and call the tool again; the consent is not asked a second time. Refused again, step 3.
|
|
15
|
-
-
|
|
15
|
+
- `skipped`: the person said no to giving access, a no that ends the setup, as the playbook says: nothing was saved, and the report says so under "What was skipped, and why", with the tool's `reason` and the settings page where the key is made (the report's bullet below), and `left` empty.
|
|
16
|
+
- Any other `refused`: nothing was saved; `reason` says what stopped it. Step 3.
|
|
16
17
|
- `save-failed`: the setup API did not take it all; `reason` says how far it got. Call the tool once more; when it fails again, step 3, with "Save the Vercel bypass secret on the setup page", "Type the Netlify site password in the app's Settings, under "A way past your host's lock"" or "Save the Cloudflare service token on the setup page" in `left`.
|
|
17
18
|
2. **Check it from Impeccable's side**: impeccable_write to `connection-check` with `{"appKey": "main"}` (on a repository with several apps, the app's own key: the tool saved the key for that app), and report what it answered. A check that answers `access: "auth-wall"` met the lock all the same: say so under "What is not verified", with its `detail`, and never as verified; the last screen does not call the app set up while it stands. Then go on with the case that sent you here: the proof stands, so save the method as vercel-previews', netlify-previews' or cloudflare-previews' step 2 says, Cloudflare's with the tool's `pattern`. Done when the method is saved.
|
|
18
19
|
3. **When nothing was saved**, save no method and set up no other preview, whatever the checkup's `next` says, and open no test drive (pull-requests' "No preview, no test drive"). Say under "What is not verified" that the previews are behind the host's lock, with the tool's `reason`, and name the settings page where the person makes the key, or makes the Netlify site public (the report's bullet below); put the person's one step in `left`, the settings action alone and never running the setup again (for Netlify's site password: "Type the Netlify site password in the app's Settings, under "A way past your host's lock"", and, with the draft test drive open, "Then mark draft #<n> ready", never a bypass key, which Netlify has none of; for Netlify's team login, none: the person was asked in this run, so `left` stays empty and the report names the Visitor access page); end the report's "Not part of this setup" line with the lock the tool met: "Vercel's Deployment Protection in front of the previews", "Netlify's password in front of the previews", "Netlify's team login in front of the previews", or "Cloudflare Access in front of the previews". Done when the report says so.
|
|
19
20
|
|
|
20
21
|
### Reference
|
|
21
22
|
|
|
22
|
-
- **Before the test drive's push, on Vercel** (pull-requests' step 4): impeccable_vercel_bypass with `beforePush: true`, and the app's `appKey` on a repository with several apps, reads the project's settings and, where Vercel's login will stand in front of the test drive's preview, asks the person now, on a screen naming the project and the app. `protected: false`, or `protected: true` with `consent` `given` or `not-needed`, or `protected: "unknown"`: push. Once the proof meets the gate, step 1 runs as written, and the yes is not asked again. `skipped`: push
|
|
23
|
+
- **Before the test drive's push, on Vercel** (pull-requests' step 4): impeccable_vercel_bypass with `beforePush: true`, and the app's `appKey` on a repository with several apps, reads the project's settings and, where Vercel's login will stand in front of the test drive's preview, asks the person now, on a screen naming the project and the app. `protected: false`, or `protected: true` with `consent` `given` or `not-needed`, or `protected: "unknown"`: push. Once the proof meets the gate, step 1 runs as written, and the yes is not asked again. `skipped`: push nothing: a no that ends the setup, as the playbook says, with the tool's `reason`.
|
|
23
24
|
- **The report** names, under "What was configured, and by whom", the Vercel project that holds the secret named "Impeccable reviews" (`project`), whether this run created it, found it there or the person typed it, and where it is revoked: the project's Settings → Deployment Protection → Protection Bypass for Automation; or the Netlify site (`site`) whose password is stored, whether this run read it from the site or set a new one on it, replacing the one before, and where it is changed: the site's Project configuration → General → Visitor access; or, with `opened`, the Netlify site made public, by this run on the person's yes or by the person on Netlify's page (`madePublic`), so anyone with its address opens it, and where it is made private again: the same Visitor access; or the service token named "Impeccable reviews", whether this run created it or the person typed it, the Access application whose policy allows it (`application`), when a created one runs out (`expires`), and where it is revoked: Zero Trust → Access controls → Service credentials → Service Tokens; with `apiToken`, the Cloudflare API token the person made for the run is theirs to delete (dash.cloudflare.com/profile/api-tokens). Revoking, regenerating or changing it stops reviews until a new one is saved on the setup page.
|
|
24
25
|
- **The tool does the writes**: the key as Impeccable's credential for the lock, and the setting that sends it (Vercel's `x-vercel-protection-bypass` header, Netlify's password form, or Cloudflare's `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers), both through the setup API, so impeccable_write refuses those two. The state's `preview-protection` item words the same calls, and shows `keySaved` once they are done.
|
|
25
26
|
- **A key saved before** (the item shows `keySaved` and a `check` that `passed`): the lock was handled by an earlier run or the person. Run step 2's check and go on; call the tool only when that check fails. On a repository with several apps, the item's `keySaved` speaks for one app at most: an app whose own proof answered a `gate` gets step 1 with its `appKey`, whatever another app's key did, and its step 2 check is its own.
|
|
@@ -5,10 +5,10 @@ No host's previews show the pages: no host case applied, or one ended without a
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
7
7
|
1. **Ask where the previews come from**, when the pages need a server from this repository (SSR, API routes, or a backend the front end calls; a front end whose bundle builds on its own still needs its backend here), once, with impeccable_ask: `id: "preview-host"`, `kind: "single"`, `title` *"Pull request previews"*, `prompt` *"Which host should deploy a preview of each pull request?"*, `hint` *"Impeccable reviews this app on its host's previews, and no longer runs its server itself."*, and `options` Vercel (`vercel`) and Netlify (`netlify`). Done when it answered a value, or a skip.
|
|
8
|
-
- **Several apps**: ask once for each app this case applies to, the title naming the app (*"Order desk previews"*).
|
|
9
|
-
- **Asked already for this app in this run**, or a host's setup for it ended without a preview (
|
|
8
|
+
- **Several apps**: ask once for each app this case applies to, the title naming the app (*"Order desk previews"*) and `appKey` its key, so a skip ends that app's setup alone.
|
|
9
|
+
- **Asked already for this app in this run**, or a host's setup for it ended without a preview (a step failed): ask nothing, and go on as step 3 says. A host's setup that ended on a no (the sign-in skipped) ended the setup for the app, as the playbook's "A no that ends the setup" says.
|
|
10
10
|
2. **Set the host up**: on `vercel`, as the vercel-setup case says, and on `netlify`, as the netlify-setup case says. The pick stands in for that case's "When this applies": its steps run whatever the checkout holds for the host, and even where the host already deploys previews that showed another app or did not answer, since the test drive's own preview, proven on that host, decides. Done when that case's steps are done.
|
|
11
|
-
3. **After a skip
|
|
11
|
+
3. **After a skip**, a no that ends the setup for the app, as the playbook says: under "What was skipped, and why", that the person skipped the host question, naming Vercel, Netlify and Cloudflare as the hosts whose pull request previews Impeccable reviews. **For pages that need no server**, or a host's setup that failed: no preview, no test drive. Save no method and write no workflow for these pages, and open no test drive, as pull-requests' "No preview, no test drive" says. Mark the Preview method and Test drive tasks failed, and say under "What was skipped, and why" that no host deploys previews of the pages (and that they need a server, where they do), naming Vercel, Netlify and Cloudflare as the hosts whose pull request previews Impeccable reviews. The checkup's entry for this preview then stays in `next`, which counts as done for the playbook's step 5. With several apps this holds for that app alone: the other chosen apps' previews and the test drive go on as monorepo says, and the tasks fail only where no app has a preview. Done when the report says so.
|
|
12
12
|
|
|
13
13
|
### Reference
|
|
14
14
|
|
|
@@ -4,17 +4,17 @@ The app has its own sign-in: a form that takes a username or an email and a pass
|
|
|
4
4
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
7
|
-
1. **Find the roles** in the auth code (the login
|
|
7
|
+
1. **Find the roles** in the auth code (the login routes and pages, the user model, role checks and route guards), and use the names in `rolesDetected` for the ones it lists. A role is a kind of user that sees different pages; an app without roles has one, `default`. Each role signs in at its own login page where the app has one per role (`/admin`, `/manager`), else at the app's one login page. Then look for each role's username or email in the seed (`supabase/seed.sql`, `prisma/seed.ts`, `db/seeds.rb`), the fixtures (`fixtures/`, test fixtures), `.env.example` and the README, and note the file it came from. Done when every role has a name, a label, its login page, and its username with that file or none.
|
|
8
8
|
2. **Ask for the accounts**, back to back. Done when each role has a username and a password reference, or was skipped.
|
|
9
9
|
- **Every role saved already** (`rolesMissing` empty, `rolesSaved` not): ask nothing, not even the trust screen, and go to step 4.
|
|
10
|
-
- Otherwise first, once, the trust screen: `kind: "confirm"`, `title` *"
|
|
11
|
-
- Then one question per role: `kind: "account"`, `title` the role's label (*"Admin account"*), `
|
|
12
|
-
- **Some roles saved, some missing** (neither list empty): the write replaces the saved list as a whole, and the saved passwords never reach this run, so ask for every role, the saved ones first. The first account's hint: *"Saved accounts are typed again too, as the list is replaced whole; skip to add only the missing ones on the setup page."* A skip ends the questions, and the roles not asked yet count as skipped.
|
|
13
|
-
3. **Save them** with one impeccable_write to `test-accounts`: every answered role in the body `writes.testAccounts` gives, each `password` the reference its question returned. When `rolesSaved` is not empty and the person skipped any role, write nothing: the saved accounts stay as they are. Done when the answer names the saved roles, or nothing was written.
|
|
14
|
-
4. **Report** the saved roles under "What was configured, and by whom", and the skipped ones under "What was skipped, and why". Each role still without a saved account goes into `left` ("Add the staff test account on the setup page"). Done when every role is in the report once.
|
|
10
|
+
- Otherwise first, once, the trust screen: `kind: "confirm"`, `title` *"Your app has a sign-in"*, `hint` word for word two paragraphs with a blank line (`\n\n`) between them, *"Impeccable signs in as each kind of user to review the pages they see. It found 3: Admin, Manager and Customer."* with the count and the labels of the roles found (one role: *"Impeccable signs in with a test account to review the pages people see after signing in."*), then *"Passwords are stored encrypted and used only to sign in."*, `prompt` *"Add a test account for each?"* (one role: *"Add a test account?"*), `confirmLabel` *"Add accounts"*, `skipLabel` *"Skip for now"*. A skip ends the account questions: ask for no role, and every role counts as skipped.
|
|
11
|
+
- Then one question per role, in the order found: `kind: "account"`, `title` the role's label and its place, *"Admin account · 1 of 3"* (two spaces either side of the dot; one role: *"Admin account"*, or *"Test account"* for `default`), `hint` *"Signs in at /admin. Email found in supabase/seed.sql."*, or *"Signs in at /admin. No email found in the repository."* (*"Username found in"* for a username that is not an email), `prompt` *"Add the admin test account."*, and `prefill` the username or email found, when there is one. The screen shows the title, the hint and the two fields; the prompt is the web page's line. It answers the username and a password reference, or a skip, which skips that role only.
|
|
12
|
+
- **Some roles saved, some missing** (neither list empty): the write replaces the saved list as a whole, and the saved passwords never reach this run, so ask for every role, the saved ones first. The first account's hint ends: *"Saved accounts are typed again too, as the list is replaced whole; skip to add only the missing ones on the setup page."* A skip ends the questions, and the roles not asked yet count as skipped.
|
|
13
|
+
3. **Save them** with one impeccable_write to `test-accounts`: every answered role in the body `writes.testAccounts` gives, each `password` the reference its question returned and each `loginPage` that role's own. When `rolesSaved` is not empty and the person skipped any role, write nothing: the saved accounts stay as they are. Done when the answer names the saved roles, or nothing was written.
|
|
14
|
+
4. **Report** the saved roles under "What was configured, and by whom", and the skipped ones under "What was skipped, and why", each with its login page. Each role still without a saved account goes into `left` ("Add the staff test account on the setup page"). Done when every role is in the report once.
|
|
15
15
|
|
|
16
16
|
### Reference
|
|
17
17
|
|
|
18
18
|
- impeccable_ask and impeccable_write are the terminal prompt the instructions' `signIn` describes: the person types each password hidden, and it goes straight to `test-accounts`.
|
|
19
|
-
- **A password is only the reference impeccable_ask returned**; impeccable_write refuses any other, so a password in the seed, a README or an env file stays where it is. When the
|
|
20
|
-
- `loginPage` is the
|
|
19
|
+
- **A password is only the reference impeccable_ask returned**; impeccable_write refuses any other, so a password in the seed, the fixtures, a README or an env file stays where it is: never put one in a question, a prefill, a write or the report. When the repo sets one, end the account question's hint with *"Its password is in <file>."* and let the person type it.
|
|
20
|
+
- `loginPage` is the path of the page whose form signs that role in, such as `/admin`; every account carries its own, and reviews sign each role in there.
|
|
@@ -8,7 +8,7 @@ The checkout is set up for Vercel (`vercel.json`, `.vercel/project.json`, a `ver
|
|
|
8
8
|
- **`cli: "missing"`**: the CLI was not on this machine, and the person said Install on the tool's screen for it: install it with `npm install -g vercel@62.7.0`, exactly so (the run installs it with npm alone, pinned to that version, only after that Install, and refuses another package manager, version or tag); then ask again. The setup screen shows no second time; an install that did not complete brings the steps for the person's own install up instead.
|
|
9
9
|
- **`teams` in the answer instead of `team`** (several teams, none current): ask once which owns the project, `kind: "single"`, `id: "vercel-team"`, `options` the teams as given (the tool words the rest). The pick is the team.
|
|
10
10
|
- **Neither `team` nor `teams`**: the account has no team, and `vercel link --yes` without `--team` links in the person's own scope.
|
|
11
|
-
- **Skip**:
|
|
11
|
+
- **Skip**: a no that ends the setup, as the playbook says: say under "What was skipped, and why" that Vercel was found and the sign-in was skipped. The steps below do not apply.
|
|
12
12
|
2. **Create the project, then link it** (run each Vercel command plain, on its own: its answer is short, a pipe to `tail` is refused, and a command chained after it leaves the project unrecorded): `vercel project add <the folder's name> --team <the team>` creates the project in that team, under the name `vercel link` gives a project (the checkout folder's name, lowercase); then `vercel link --yes --team <the team>`, in the checkout's root (never with `--cwd` or after a `cd`), links the folder to the project of that name, which is now the run's. The CLI names no team on its own without a terminal, so the option is given whenever there is a team. Done when the link prints the linked project.
|
|
13
13
|
- **`vercel project add` fails** (the name is in use: the project is the person's, not this run's): run no link; the Vercel setup ends there, and with the checkout linked to no project its CLI stops and the hold on pushes goes. Go on as "A step fails before the test drive is pushed" says, naming the project under "What was skipped, and why". When `.vercel/project.json` was in the checkout before the run, that link is not this run's, and the hold on pushes stays, by design: no test drive, as "No preview for the pushed test drive" says, and the report says under "What was skipped, and why" that the person removes `.vercel/project.json` or links the project themselves and runs the setup again.
|
|
14
14
|
3. **Connect the repository, and lock the project before its first deployment**, with impeccable_vercel_connect: it runs the connection on the linked project, when that project is the one step 2 created or one connected to this repository already, asks the person for Vercel's GitHub app's access when the app cannot reach the repository, and then, on a project with no production deployment, puts Vercel's login in front of every address of the project, its own included. Vercel makes a new project's first deployment Production whatever branch it builds and serves it at the project's own address, and the test drive's push is that first deployment. When Vercel refuses the lock, the tool asks the person on one screen to turn it on in the project's settings and waits for it. Done when it answered `connected: true` with `production: "exists"` or `locked: true`; a project connected by the link already is, and it says so.
|
|
@@ -19,11 +19,11 @@ The checkout is set up for Vercel (`vercel.json`, `.vercel/project.json`, a `ver
|
|
|
19
19
|
|
|
20
20
|
- **The live app**: a project created here has none yet, which the report says; one that deploys production already (`production: "exists"`) answers its address as `productionUrl`, and live-app-address says how it becomes the before.
|
|
21
21
|
- **The aim** holds here: the CLI does the work, and the person is asked only what the playbook's aim allows.
|
|
22
|
-
- **A step fails before the test drive is pushed** (the sign-in
|
|
22
|
+
- **A step fails before the test drive is pushed** (the sign-in not completed, the project not created, the repository not connected, or step 3 answering that the linked project is not this run's: `vercel link` linked one of the person's projects, named like the folder, which is left as it is): the Vercel setup ends there. Set the preview up as server-previews says, say under "What was skipped, and why" what stopped it, and put the person's step in `left` when there is one, such as "Give Vercel's GitHub app access to the repository", with the `access` page in the report. The sign-in, the team or the GitHub app's access skipped is a no that ends the setup, as the playbook says.
|
|
23
23
|
- **`pullRequestPreviews` in impeccable_hosted_previews' answer**: what vercel.json and the linked project say of the test drive's branch, read with the evidence and never changed by the run, since the setting is the repository's own or has no command (impeccable_enable_previews takes no Vercel). `enabled: false` (`git.deploymentEnabled` in vercel.json turns the branch off, or Vercel records no deployments on GitHub, where Impeccable finds its previews): the Vercel setup ends before step 4, as "A step fails before the test drive is pushed" says, and say in one line under "What was skipped, and why" that Vercel builds no preview of the pull requests, with the `reason`, and the exact change for the person: the vercel.json rule to lift, or Vercel GitHub deployments under the project's Settings → Git. `enabled: 'unknown'` (an Ignored Build Step decides commit by commit, or the CLI could not read the project): go on, and say so in one line under "What is not verified".
|
|
24
24
|
- **A push before step 3** is refused, since a connected project builds every push: run impeccable_vercel_connect first.
|
|
25
25
|
- **On a repository with several apps**, the project step 2 creates builds the repository's root: impeccable_vercel_connect connects and locks it only where an app this run sets up is the root's, and otherwise answers so, and the Vercel setup ends there for that app, as "A step fails before the test drive is pushed" says.
|
|
26
|
-
- **`locked: false`** from step 3, with `production` not "exists" (the person skipped the tool's screen, or the lock was still off after its retries): push nothing, and set up no other preview either, since the connected project would build that push as its first deployment, public at its own address. Mark the Test drive task failed, and say under "What was skipped, and why" what stopped the lock (the answer's `reason`).
|
|
26
|
+
- **`locked: false`** from step 3, with `production` not "exists" (the person skipped the tool's screen, or the lock was still off after its retries): push nothing, and set up no other preview either, since the connected project would build that push as its first deployment, public at its own address. Mark the Test drive task failed, and say under "What was skipped, and why" what stopped the lock (the answer's `reason`). The screen skipped is a no that ends the setup, as the playbook says.
|
|
27
27
|
- **No preview for the pushed test drive**: save no method, set up no other preview and open no pull request, as pull-requests' "No preview, no test drive" says; the project and the connection stay, and the report names them.
|
|
28
28
|
- **The build failed** (`state` is `failure` or `error`): `vercel deploy --yes` builds this checkout on Vercel and prints why it failed. When the cause is in the test drive's own change, correct it, commit, push once more and wait for that build; else the report names the cause.
|
|
29
29
|
- **No deployment within five minutes**: the wait's own screen opens the project's Git settings and waits again on the person's yes. When they skip it, or none came after its retries, say under "What is not verified" that Vercel built no push here, and that the project's Git connection is the thing to check.
|
package/dist/playbook.js
CHANGED
|
@@ -10,6 +10,18 @@ export function loadCases(directory = new URL('./playbook/cases/', import.meta.u
|
|
|
10
10
|
.sort()
|
|
11
11
|
.map((name) => ({ slug: name.slice(0, -3), content: readFileSync(join(path, name), 'utf8') }));
|
|
12
12
|
}
|
|
13
|
+
/** The test drive under --signed-in-test-drive, an eval's flag (#410): a page behind the app's own sign-in, which the review signs in to see. The fence and impeccable_test_pr hold the test drive to a saved account. */
|
|
14
|
+
const SIGNED_IN_TEST_DRIVE = `
|
|
15
|
+
|
|
16
|
+
# The signed-in test drive
|
|
17
|
+
|
|
18
|
+
This run was started with --signed-in-test-drive: the test drive changes a page only a signed-in visitor reaches, so Impeccable's review signs in with the saved test accounts to see it. It changes the pull-requests case as below, for each app's test drive on a repository with several.
|
|
19
|
+
|
|
20
|
+
- **Accounts first**: set up the sign-in as the sign-in case says before the test drive's push, even where a host's case goes to pull-requests' step 4 earlier. The fence pushes and opens a test drive only for an app with a test account saved.
|
|
21
|
+
- **No account saved** (the app has no sign-in of its own, or every account was skipped): the flag cannot be honoured. Push and open no test drive, on a public page or any other: mark the Test drive task failed and say why under "What was skipped, and why"; impeccable_report opens the report with the flag's line itself.
|
|
22
|
+
- **The page** (step 2): the new page sits behind the sign-in, guarded as the app guards its other signed-in pages, and is linked from a page every role reaches once signed in, such as the page they land on; a login page or a page a visitor opens signed out holds no link. Where no page reaches every role, link it from one role's page and name that role in the test drive's body.
|
|
23
|
+
- **The proof** (step 4): prove the preview on a page it shows a visitor signed out, the login page where the test drive's page sends that visitor to the sign-in.
|
|
24
|
+
- **The body** (step 5): it says the page is behind the sign-in, and which roles reach it.`;
|
|
13
25
|
/** The system prompt: the setup, its steps, then the reference and the cases. The instructions JSON carries every write shape and the fence decides what runs; this restates neither. */
|
|
14
26
|
export function playbook(repo) {
|
|
15
27
|
return `You are Impeccable's setup agent, in a checkout of ${repo.owner}/${repo.name} on the person's own machine. This setup covers one situation: Impeccable previews the repository's web front end, taking the previews its host deploys for pull requests (vercel-previews, netlify-previews, cloudflare-previews, or vercel-setup and netlify-setup, which turn them on) and signs in with the app's own username-or-email-and-password accounts when it has a sign-in. You read files and run commands here, save Impeccable's settings with the impeccable_* tools, and open pull requests as the person. The person watches this terminal, and a web page mirrors your tasks and the report.
|
|
@@ -41,8 +53,11 @@ The state names the setup page's other cards in \`notPartOfThisSetup\`. They are
|
|
|
41
53
|
- Ask through impeccable_ask, which shows a dialog and returns the answer; a question written in your message reaches nobody.
|
|
42
54
|
- Ask only for what the repository cannot tell you. Running this command is the person's go-ahead for every step, so ask no approval questions.
|
|
43
55
|
- Ask the questions that belong together back to back, one call each.
|
|
44
|
-
- Every question offers Skip but the apps question on a repository with several apps, which needs at least one app (the monorepo case): after a skip, carry on without the answer, and say in the report what it left undone.
|
|
45
|
-
- **No run ends by telling the person to run the setup again**: what a step needs from them is asked in this run, on the tool's screen, and the run carries on. After a skip, or retries that ran out, the report says what stopped it, and no \`left\` line or question asks for a rerun.
|
|
56
|
+
- Every question offers Skip but the apps question on a repository with several apps, which needs at least one app (the monorepo case): after a skip, carry on without the answer, and say in the report what it left undone, unless the skip is a no that ends the setup (below).
|
|
57
|
+
- **No run ends by telling the person to run the setup again**: what a step needs from them is asked in this run, on the tool's screen, and the run carries on. After a skip, or retries that ran out, the report says what stopped it, and no \`left\` line or question asks for a rerun. Only the command's own last screen, after a no that ended the setup, says when to run it again.
|
|
58
|
+
|
|
59
|
+
## A no that ends the setup
|
|
60
|
+
Impeccable reviews each pull request by opening its preview, so a no that leaves an app with no preview Impeccable can open **ends the setup for that app**. A no is the person's skip of a screen that sets previews up (a host's sign-in or its team, the access of a host's GitHub app to the repository, the lock on a new Vercel project, turning pull-request previews on, creating or connecting a project, the host question) or that gives access to protected ones (impeccable_vercel_bypass, impeccable_netlify_password or impeccable_cloudflare_access answering \`skipped\`). Then, for that app: no other preview, no push, no test drive, no method, and no more questions, its test accounts included (the playbook's steps 4 to 6 do not apply to it), which the tools refuse; mark its tasks left skipped, and say under "What was skipped, and why" which no ended it, with the tool's \`reason\`, and \`left\` empty. On a repository with several apps, the other apps carry on as usual. Once no app this run set up has a preview, call impeccable_report: the last screen says why the setup ended. A sign-in that did not complete, a build that never came or a refusal is no no, and goes as its case says; a skipped test account ends nothing, since the public pages can be reviewed.
|
|
46
61
|
|
|
47
62
|
## Commands
|
|
48
63
|
The fence runs the ordinary commands of a setup, never runs what could destroy data, and refuses everything else with the reason, without asking anyone. **Never ask the person to run a command**, in a question or anywhere else: after a refusal, take another way with what runs (a package script, a config change, another tool), and when there is none, leave the step undone and put it in the report under "What was skipped, and why", with the exact command for the person.
|
|
@@ -82,7 +97,7 @@ impeccable_report ends the run: no tool or model call works after it, so call it
|
|
|
82
97
|
|
|
83
98
|
Each case says when it applies; follow every case that does.
|
|
84
99
|
|
|
85
|
-
${loadCases().map(({ slug, content }) => `## Case: ${slug}\n\n${content}`).join('\n\n')}`;
|
|
100
|
+
${loadCases().map(({ slug, content }) => `## Case: ${slug}\n\n${content}`).join('\n\n')}${repo.signedInTestDrive ? SIGNED_IN_TEST_DRIVE : ''}`;
|
|
86
101
|
}
|
|
87
102
|
/** The first message the person sees in the terminal. */
|
|
88
103
|
export function firstMessage(repo) {
|
package/dist/preflight.js
CHANGED
|
@@ -62,6 +62,7 @@ export function parseFlags(argv, env = process.env, cwd = process.cwd()) {
|
|
|
62
62
|
yes: { type: 'boolean', default: false },
|
|
63
63
|
'no-sandbox': { type: 'boolean', default: false },
|
|
64
64
|
'no-telemetry': { type: 'boolean', default: false },
|
|
65
|
+
'signed-in-test-drive': { type: 'boolean', default: false },
|
|
65
66
|
help: { type: 'boolean', short: 'h', default: false },
|
|
66
67
|
},
|
|
67
68
|
}));
|
|
@@ -99,7 +100,7 @@ export function parseFlags(argv, env = process.env, cwd = process.cwd()) {
|
|
|
99
100
|
const installDir = named?.trim() ? { path: resolve(cwd, named), from: values['install-dir'] !== undefined ? '--install-dir' : INSTALL_DIR_ENV } : undefined;
|
|
100
101
|
// Paths from the folder the command is run in too: it goes on from the checkout's top.
|
|
101
102
|
const path = (value) => (value === undefined ? undefined : resolve(cwd, value.trim()));
|
|
102
|
-
return { host: host.origin, installDir, ...(values.app !== undefined ? { app: values.app.trim() } : {}), debug: values.debug, transcript: path(values.transcript), scanReport: values['scan-report'], frames: path(values.frames), model: values.model?.trim(), effort: values.effort, yes: values.yes, noSandbox: values['no-sandbox'], noTelemetry: values['no-telemetry'], help: values.help };
|
|
103
|
+
return { host: host.origin, installDir, ...(values.app !== undefined ? { app: values.app.trim() } : {}), debug: values.debug, transcript: path(values.transcript), scanReport: values['scan-report'], frames: path(values.frames), model: values.model?.trim(), effort: values.effort, yes: values.yes, noSandbox: values['no-sandbox'], noTelemetry: values['no-telemetry'], signedInTestDrive: values['signed-in-test-drive'], help: values.help };
|
|
103
104
|
}
|
|
104
105
|
const NOT_A_CHECKOUT = { event: 'setup_command_check_failed', outcome: 'not_a_checkout' };
|
|
105
106
|
/** owner/repo from a GitHub remote address, in any of the forms git accepts. */
|
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'],
|
|
@@ -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,6 +2260,9 @@ 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
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
|
|
@@ -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')
|
|
@@ -2525,7 +2606,7 @@ export function createTools(ctx) {
|
|
|
2525
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.
|
|
2526
2607
|
const forApp = /\/(preview|test-accounts|connection-check|latest-pull-check)$/.test(target.url);
|
|
2527
2608
|
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);
|
|
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);
|
|
2529
2610
|
if (refused)
|
|
2530
2611
|
return refused;
|
|
2531
2612
|
}
|
|
@@ -2581,6 +2662,8 @@ export function createTools(ctx) {
|
|
|
2581
2662
|
break;
|
|
2582
2663
|
response = await call(pi, target.url, { method: target.method, body });
|
|
2583
2664
|
}
|
|
2665
|
+
if (testAccounts && response.ok)
|
|
2666
|
+
retry.signedIn?.saved.add(appsRepo ? String(object(p.body)?.appKey) : '');
|
|
2584
2667
|
// A check that answered, a preview not running yet included, ran for its app; one still turned away did not.
|
|
2585
2668
|
if (check && (response.ok || response.status === 409))
|
|
2586
2669
|
checkedApps.add(typeof object(p.body)?.appKey === 'string' ? String(object(p.body)?.appKey) : '');
|
|
@@ -2634,9 +2717,19 @@ export function createTools(ctx) {
|
|
|
2634
2717
|
}),
|
|
2635
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) => {
|
|
2636
2719
|
// 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;
|
|
2720
|
+
const { appKey: forApp, ...asked } = p;
|
|
2638
2721
|
if (asked.id === APPS_QUESTION.id && appsRepo)
|
|
2639
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;
|
|
2640
2733
|
const host = /^(netlify|vercel)-(setup|team)$/.exec(asked.id);
|
|
2641
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.
|
|
2642
2735
|
const deployed = appsRepo ? pickedApps !== undefined && pickedApps.size > 0 && [...pickedApps].every((key) => vercelDeployed.has(key)) : vercelDeployed.has('');
|
|
@@ -2649,8 +2742,16 @@ export function createTools(ctx) {
|
|
|
2649
2742
|
if (asked.id === 'netlify-setup' && previewing && !hostSignIns.has('netlify')) {
|
|
2650
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.`;
|
|
2651
2744
|
}
|
|
2652
|
-
if (host?.[2] === 'setup')
|
|
2653
|
-
|
|
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
|
+
}
|
|
2654
2755
|
if (host && (teamChoices.get(host[1])?.length ?? 0) < 2)
|
|
2655
2756
|
return `Invalid input: ${asked.id} is asked only when the ${HOST_NAMES[host[1]]} sign-in answered teams to choose from.`;
|
|
2656
2757
|
const fixed = Object.hasOwn(HOST_QUESTIONS, asked.id) ? HOST_QUESTIONS[asked.id] : undefined;
|
|
@@ -2676,6 +2777,11 @@ export function createTools(ctx) {
|
|
|
2676
2777
|
// Without a terminal the Vercel CLI names no team on its own, so a skipped team ends the Vercel setup.
|
|
2677
2778
|
if (host?.[1] === 'vercel')
|
|
2678
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);
|
|
2679
2785
|
return JSON.stringify({ skipped: true });
|
|
2680
2786
|
}
|
|
2681
2787
|
if (q.kind === 'multi')
|
|
@@ -2735,6 +2841,11 @@ export function createTools(ctx) {
|
|
|
2735
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.`;
|
|
2736
2842
|
}
|
|
2737
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.`;
|
|
2738
2849
|
const found = await runPullRequest(ctx, drive, 'impeccable/test-drive', signal);
|
|
2739
2850
|
if ('problem' in found)
|
|
2740
2851
|
return `Refused: ${found.problem}.`;
|
|
@@ -2800,6 +2911,11 @@ export function createTools(ctx) {
|
|
|
2800
2911
|
if (open.length)
|
|
2801
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.`;
|
|
2802
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}`;
|
|
2803
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.
|
|
2804
2920
|
if (recordedBranches?.setup) {
|
|
2805
2921
|
const changes = recordedBranches.setupBase ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, recordedBranches.setupBase, recordedBranches.setup) : null;
|
|
@@ -2826,6 +2942,12 @@ export function createTools(ctx) {
|
|
|
2826
2942
|
const apps = appsRepo && pickedApps ? await appOutcomes(pi, new Set([...appDrives.keys()].filter((key) => !kept.some(([k]) => k === key)))) : undefined;
|
|
2827
2943
|
if (apps)
|
|
2828
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}`;
|
|
2829
2951
|
if (previews.active && retry.pr) {
|
|
2830
2952
|
await observe(signal);
|
|
2831
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(', ')}.` : ''}`);
|
|
@@ -2838,6 +2960,10 @@ export function createTools(ctx) {
|
|
|
2838
2960
|
const { phase, status, question, report_markdown, test_pr, test_prs, left: leftBefore } = ctx.sync.payload;
|
|
2839
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.
|
|
2840
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}.` });
|
|
2841
2967
|
for (const { appKey, reason } of p.apps ?? []) {
|
|
2842
2968
|
const plain = reason.trim();
|
|
2843
2969
|
const address = /[a-z][a-z0-9+.-]*:\/\/|[a-z0-9-]+(?:\.[a-z0-9-]+)+[/?#]/i.test(plain);
|
|
@@ -2845,7 +2971,7 @@ export function createTools(ctx) {
|
|
|
2845
2971
|
noteApp(appKey, { reason: plain });
|
|
2846
2972
|
}
|
|
2847
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).
|
|
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) });
|
|
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) });
|
|
2849
2975
|
const res = await ctx.sync.flush();
|
|
2850
2976
|
if (res && res.status >= 400 && res.status < 500) {
|
|
2851
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.
|
|
@@ -2860,7 +2986,7 @@ export function createTools(ctx) {
|
|
|
2860
2986
|
}
|
|
2861
2987
|
// Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
|
|
2862
2988
|
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 } : {}) });
|
|
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 } : {}) });
|
|
2864
2990
|
pi.shutdown();
|
|
2865
2991
|
const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
|
|
2866
2992
|
return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
|
|
@@ -3006,6 +3132,8 @@ export function createTools(ctx) {
|
|
|
3006
3132
|
}
|
|
3007
3133
|
if (evidence.pullRequestPreviews)
|
|
3008
3134
|
previewFacts.set('netlify', evidence.pullRequestPreviews);
|
|
3135
|
+
if (appKey !== undefined && evidence.pullRequestPreviews)
|
|
3136
|
+
appProjects.set(`netlify/${appKey}`, evidence.pullRequestPreviews.project);
|
|
3009
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).
|
|
3010
3138
|
if (pull === undefined) {
|
|
3011
3139
|
if (!evidence.unmatched && evidence.pullRequestPreviews?.enabled === true)
|
|
@@ -3049,7 +3177,7 @@ export function createTools(ctx) {
|
|
|
3049
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),
|
|
3050
3178
|
// unless it covers them all, or Cloudflare built a preview of the repository already, which it could not without the app.
|
|
3051
3179
|
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;
|
|
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;
|
|
3053
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();
|
|
3054
3182
|
// cf not on this machine (#396): its install is asked before anything installs it, and the evidence read again with it.
|
|
3055
3183
|
if (!subject && waited.cli === 'missing') {
|
|
@@ -3083,6 +3211,9 @@ export function createTools(ctx) {
|
|
|
3083
3211
|
}
|
|
3084
3212
|
if (evidence.pullRequestPreviews)
|
|
3085
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);
|
|
3086
3217
|
noteProduction(evidence.unmatched ? undefined : evidence.production, 'cloudflare', undefined, appKey);
|
|
3087
3218
|
return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
|
|
3088
3219
|
}),
|
|
@@ -3176,7 +3307,7 @@ export function createTools(ctx) {
|
|
|
3176
3307
|
const directory = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
|
|
3177
3308
|
if (appKey !== undefined && directory === undefined)
|
|
3178
3309
|
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 ?? ''));
|
|
3310
|
+
const result = await netlifyConnect(pi, { command, publish }, signal, directory, netlifyPreviewing.has(appKey ?? ''), appKey);
|
|
3180
3311
|
if (result.how && result.site)
|
|
3181
3312
|
noteApp(appKey, { host_project: result.site.name });
|
|
3182
3313
|
return JSON.stringify(appKey !== undefined ? { appKey, ...result } : result);
|
|
@@ -3184,6 +3315,9 @@ export function createTools(ctx) {
|
|
|
3184
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) => {
|
|
3185
3316
|
const host = p.host;
|
|
3186
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);
|
|
3187
3321
|
if (standing)
|
|
3188
3322
|
return JSON.stringify(standing);
|
|
3189
3323
|
const fact = previewFacts.get(host);
|
|
@@ -3195,6 +3329,8 @@ export function createTools(ctx) {
|
|
|
3195
3329
|
const answer = (status, more) => {
|
|
3196
3330
|
const told = { status, project: fact.project, ...more };
|
|
3197
3331
|
enableOutcomes.set(host, told);
|
|
3332
|
+
if (status === 'skipped')
|
|
3333
|
+
decline('no-previews', p.appKey);
|
|
3198
3334
|
return JSON.stringify(told);
|
|
3199
3335
|
};
|
|
3200
3336
|
const screen = HOST_SCREENS[`${host}-previews`];
|
|
@@ -3227,6 +3363,9 @@ export function createTools(ctx) {
|
|
|
3227
3363
|
}
|
|
3228
3364
|
}),
|
|
3229
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);
|
|
3230
3369
|
if (connectOutcome)
|
|
3231
3370
|
return JSON.stringify(connectOutcome);
|
|
3232
3371
|
const evidence = cloudflareEvidence;
|
|
@@ -3243,6 +3382,8 @@ export function createTools(ctx) {
|
|
|
3243
3382
|
const project = fact?.project || evidence.project || pagesProjectName(ctx.repo);
|
|
3244
3383
|
const answer = (status, more) => {
|
|
3245
3384
|
connectOutcome = { status, project, created: create, ...more };
|
|
3385
|
+
if (status === 'skipped')
|
|
3386
|
+
decline('no-previews', p.appKey);
|
|
3246
3387
|
return JSON.stringify(connectOutcome);
|
|
3247
3388
|
};
|
|
3248
3389
|
// The one screen: the connection of the project named, or the new project for the repository.
|
|
@@ -3334,6 +3475,12 @@ export function createTools(ctx) {
|
|
|
3334
3475
|
const asked = bypassOutcomes.get(store);
|
|
3335
3476
|
if (asked?.status === 'skipped')
|
|
3336
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
|
+
};
|
|
3337
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. */
|
|
3338
3485
|
const nothing = (why) => (at ? `Refused: nothing was sent to ${at.url}. ${why}` : JSON.stringify({ ...(app ? { appKey: app.appKey } : {}), protected: 'unknown', reason: why }));
|
|
3339
3486
|
/** The consent screen, naming the project and the app once they are known (#358). */
|
|
@@ -3350,9 +3497,9 @@ export function createTools(ctx) {
|
|
|
3350
3497
|
*/
|
|
3351
3498
|
const signIn = async () => {
|
|
3352
3499
|
/** 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) => {
|
|
3500
|
+
const skip = (reason, declines = true) => {
|
|
3354
3501
|
const outcome = { status: 'skipped', reason };
|
|
3355
|
-
bypassOutcomes.set(store, outcome);
|
|
3502
|
+
bypassOutcomes.set(store, declines ? noAccess(outcome) : outcome);
|
|
3356
3503
|
return JSON.stringify(outcome);
|
|
3357
3504
|
};
|
|
3358
3505
|
if ((await ask(consentScreen())).skipped)
|
|
@@ -3373,7 +3520,8 @@ export function createTools(ctx) {
|
|
|
3373
3520
|
hostSignIns.delete('vercel');
|
|
3374
3521
|
if (signedIn.signedIn === true)
|
|
3375
3522
|
return null;
|
|
3376
|
-
|
|
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');
|
|
3377
3525
|
};
|
|
3378
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,
|
|
3379
3527
|
// with the CLI signed in as the person.
|
|
@@ -3419,7 +3567,7 @@ export function createTools(ctx) {
|
|
|
3419
3567
|
bypassConsented.add(`${store}/${link.projectId}`);
|
|
3420
3568
|
return JSON.stringify({ ...of, protected: true, consent: 'given' });
|
|
3421
3569
|
}
|
|
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.` };
|
|
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.` });
|
|
3423
3571
|
bypassOutcomes.set(store, outcome);
|
|
3424
3572
|
return JSON.stringify(outcome);
|
|
3425
3573
|
}
|
|
@@ -3474,7 +3622,7 @@ export function createTools(ctx) {
|
|
|
3474
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).
|
|
3475
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.`),
|
|
3476
3624
|
});
|
|
3477
|
-
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 } : {}) });
|
|
3478
3626
|
bypassOutcomes.set(store, outcome);
|
|
3479
3627
|
if (value) {
|
|
3480
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.
|
|
@@ -3523,8 +3671,11 @@ export function createTools(ctx) {
|
|
|
3523
3671
|
}
|
|
3524
3672
|
passwordStands.delete(site);
|
|
3525
3673
|
}
|
|
3526
|
-
else if (stands)
|
|
3674
|
+
else if (stands) {
|
|
3675
|
+
if (stands.status === 'skipped')
|
|
3676
|
+
decline('no-access', at.app?.appKey);
|
|
3527
3677
|
return JSON.stringify(stands);
|
|
3678
|
+
}
|
|
3528
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.
|
|
3529
3680
|
const held = sitePasswords.get(site);
|
|
3530
3681
|
if (held && (await prove(held.value)).proven) {
|
|
@@ -3623,6 +3774,9 @@ export function createTools(ctx) {
|
|
|
3623
3774
|
lastTeamLogin = told.protection === 'team-login';
|
|
3624
3775
|
if (told.status === 'skipped' || told.status === 'login' || told.status === 'opened')
|
|
3625
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);
|
|
3626
3780
|
if (told.status === 'opened') {
|
|
3627
3781
|
// The site is public now: its previews open with nothing sent.
|
|
3628
3782
|
hostedPreviewProven = true;
|
|
@@ -3664,8 +3818,10 @@ export function createTools(ctx) {
|
|
|
3664
3818
|
// The call for this app is made, whatever it answers: the skip asked once stands for the next app's lock too.
|
|
3665
3819
|
if (at.app)
|
|
3666
3820
|
lockPending.delete(at.app.appKey);
|
|
3667
|
-
if (accessOutcome?.status === 'skipped')
|
|
3821
|
+
if (accessOutcome?.status === 'skipped') {
|
|
3822
|
+
decline('no-access', at.app?.appKey);
|
|
3668
3823
|
return JSON.stringify(accessOutcome);
|
|
3824
|
+
}
|
|
3669
3825
|
let proof;
|
|
3670
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 })));
|
|
3671
3827
|
const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
|
|
@@ -3740,6 +3896,9 @@ export function createTools(ctx) {
|
|
|
3740
3896
|
}
|
|
3741
3897
|
const pattern = told.status === 'saved' ? proof?.pattern : undefined;
|
|
3742
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);
|
|
3743
3902
|
if (told.status === 'saved') {
|
|
3744
3903
|
hostedPreviewProven = true;
|
|
3745
3904
|
provenHost = 'Cloudflare Pages';
|
package/dist/ui/look.js
CHANGED
|
@@ -9,7 +9,6 @@ import { fitsTask, taskInProgress, workStarted } from '../stages.js';
|
|
|
9
9
|
* the box on the right. Only the middle changes from screen to screen.
|
|
10
10
|
*/
|
|
11
11
|
export const PRODUCT = 'Impeccable for PRs';
|
|
12
|
-
export const FEEDBACK = 'support@renaissance-geek.ai';
|
|
13
12
|
/** How long the outro waits for a key before it closes itself (screens.ts), which its fine print says. */
|
|
14
13
|
export const CLOSES_AFTER_MINUTES = 20;
|
|
15
14
|
/** The box fills the terminal up to this size, then sits in the middle of it. */
|
|
@@ -195,7 +194,7 @@ function questionPage(qv, inner, st) {
|
|
|
195
194
|
ways: [[q.confirmLabel ?? 'Continue', 'Enter'], [q.skipLabel ?? 'Skip', 'Esc']],
|
|
196
195
|
};
|
|
197
196
|
}
|
|
198
|
-
// One
|
|
197
|
+
// One account: its prompt under the title, then the hint and both fields under the divider, each field in a box with its label in the top edge, the second as dots. A test account of the app's sign-in (no fields of its own) leaves its prompt to the web page and its prompt's row blank, so its title stands alone where a one-line prompt would put the divider (#409).
|
|
199
198
|
if (q.kind === 'account') {
|
|
200
199
|
const width = Math.min(FIELD_WIDTH, inner);
|
|
201
200
|
const label = (text) => ` ${st.soft(text)}`;
|
|
@@ -204,7 +203,7 @@ function questionPage(qv, inner, st) {
|
|
|
204
203
|
const fields = [...fieldBox(first, qv.field(width - 2), width, qv.focus !== 1, st), ...fieldBox(second, qv.password?.() ?? '', width, qv.focus === 1, st)];
|
|
205
204
|
return {
|
|
206
205
|
heading: q.title ?? 'A test account',
|
|
207
|
-
text: said(q.prompt, inner, st),
|
|
206
|
+
text: q.fields ? said(q.prompt, inner, st) : [''],
|
|
208
207
|
rule: divider(inner, st),
|
|
209
208
|
action: [...block([...hint, ...fields], inner, width), ...note],
|
|
210
209
|
at: hint.length + (qv.focus === 1 ? 4 : 1),
|
|
@@ -316,10 +315,11 @@ function outroPage(scene, inner, st) {
|
|
|
316
315
|
rule: divider(inner, st),
|
|
317
316
|
action: [...block(notes, inner), ...(notes.length && links.length ? [''] : []), ...links, ...(outro.links.length === 1 ? [scene.copied ? center(st.soft('Copied to clipboard!'), inner) : copyWay(inner, st)] : [])],
|
|
318
317
|
ways: scene.leaving ? undefined : [['Close', 'Enter']],
|
|
319
|
-
|
|
318
|
+
// A stopped run's foot is empty: no line to write to us in the closed beta (#413).
|
|
319
|
+
foot: outro.outcome === 'stopped' ? [] : closesItself(scene, inner, st),
|
|
320
320
|
};
|
|
321
321
|
}
|
|
322
|
-
/** The outro's fine print that it closes on its own, until it is closing; a stopped run's
|
|
322
|
+
/** The outro's fine print that it closes on its own, until it is closing; a stopped run's has none. */
|
|
323
323
|
const closesItself = (scene, inner, st) => (scene.leaving ? [] : [center(st.faint(`Closes on its own after ${CLOSES_AFTER_MINUTES} minutes.`), inner)]);
|
|
324
324
|
function pageOf(f, inner) {
|
|
325
325
|
const st = f.style;
|
|
@@ -391,9 +391,9 @@ function stack(page, inner, rows, st, scroll) {
|
|
|
391
391
|
mark = [];
|
|
392
392
|
if (over() > 0)
|
|
393
393
|
rule = [];
|
|
394
|
-
// Past this point a question's prompt scrolls with its field or choices, a blank row under it.
|
|
395
|
-
const lead = over() > 0 && page.asks ? [...text, ''] : [];
|
|
396
|
-
if (
|
|
394
|
+
// Past this point a question's prompt scrolls with its field or choices, a blank row under it; an account's blank prompt row goes.
|
|
395
|
+
const lead = over() > 0 && page.asks && text.some(Boolean) ? [...text, ''] : [];
|
|
396
|
+
if (over() > 0)
|
|
397
397
|
text = [];
|
|
398
398
|
const action = windowed([...lead, ...page.action], page.action.length - over(), lead.length + (page.at ?? 0), scroll, inner, st);
|
|
399
399
|
const parts = [mark, heading, text, rule, action, ways, foot].filter((part) => part.length);
|