@impeccable/reviews 0.1.15 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cloudflare-access.js +43 -12
- package/dist/host-questions.js +17 -0
- package/dist/http.js +2 -1
- package/dist/netlify.js +3 -2
- package/dist/playbook/cases/netlify-previews.md +3 -1
- package/dist/playbook/cases/netlify-setup.md +1 -1
- package/dist/playbook/cases/pull-requests.md +1 -1
- package/dist/playbook/cases/workflow-as-person.md +1 -1
- package/dist/playbook.js +1 -1
- package/dist/tools.js +81 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -92,4 +92,4 @@ Vercel's bypass secret, which lets reviews open previews behind Vercel's login,
|
|
|
92
92
|
|
|
93
93
|
Netlify's site password (Password Protection on Pro and Enterprise plans; "Project visibility: Password" on a credit-based Pro plan) is handled the same way. When a Netlify deploy preview asks for it, the command reads the site's settings through the Netlify CLI, signed in as you, and that read is what shows the site to be yours: a site the CLI cannot read as you (it is signed out, or the site is in none of your teams) gets no password, and the command refuses, saying so, since the lock at a listed address may be anyone's. For a password Netlify gives back, it asks you once, on one screen that names the preview's address, whether to store the site password for Impeccable, and on your yes reads it from the site as you. When Netlify does not give the password back, a screen names the address and takes the password you type, hidden (the run never sets one itself: the Netlify CLI takes a new password only on its command line, where any process could read it). It opens the preview with the password first, as basic credentials in the `Authorization` header (Netlify's password page asks for a password alone, so the credentials carry no username), and saves it, with the setting that sends that header, only once the page shows your app; a password that opens the preview on Netlify's own form but not in the header is reported, not saved, since reviews can send only a header. A site behind Netlify's team login (a private site) has no way in for tools, so the command asks you once, on one screen, whether to make the site public, and on your yes makes it public as you through the Netlify CLI (`netlify api updateSite` with `sso_login` off); anyone with its address can then open it, the live site included, until you make it private again in Project configuration, General, Visitor access. Where Netlify refuses (your team's default keeps every project private, or your role cannot change it), the command opens that page in your browser and asks you to try again once you changed it there; a password you set there instead is stored as above. The agent never holds the password: it goes from the command's own process to Netlify and to Impeccable's setting, and the debug log and the transcript remove it.
|
|
94
94
|
|
|
95
|
-
A Cloudflare Access service token, which lets reviews open Pages previews behind Access, is handled the same way. When Access stands in front of a preview, the command asks you once, on one screen that names the preview's address, whether to create a service token for Impeccable. On your yes it creates the token as you through Cloudflare's CLI, cf (run through npx), named "Impeccable reviews", and allows it in a Service Auth policy of the Access application in front of the preview (a reusable policy added to the application's list). The token goes only to a preview an Access application in your accounts covers, which that read shows; where none covers it, the command refuses, saying so, and asks for no token. When cf here is not signed in with Access, your browser opens Cloudflare's sign-in first, for five scopes (`account:read`, `user:read`, `pages:read`, `access.write`, `access-service-token.write`), with a retry when it did not complete.
|
|
95
|
+
A Cloudflare Access service token, which lets reviews open Pages previews behind Access, is handled the same way. When Access stands in front of a preview, the command asks you once, on one screen that names the preview's address, whether to create a service token for Impeccable. On your yes it creates the token as you through Cloudflare's CLI, cf (run through npx), named "Impeccable reviews", and allows it in a Service Auth policy of the Access application in front of the preview (a reusable policy added to the application's list). The token goes only to a preview an Access application in your accounts covers, which that read shows; where none covers it, the command refuses, saying so, and asks for no token. When cf here is not signed in with Access, your browser opens Cloudflare's sign-in first, for five scopes (`account:read`, `user:read`, `pages:read`, `access.write`, `access-service-token.write`), with a retry when it did not complete. An application whose policies are its own, which cf has no command to add to, gets one more screen, naming it, asking to make those policies reusable (each keeps allowing whom it allows, and shows under Access controls, Policies); on your yes cf makes them so and the token's policy goes beside them, and on a skip the paste screen below says which account and application to make the token in, and why. Only where that sign-in cannot create the token (it grants no Access, or cf does not run here) does the command ask for a Cloudflare API token with two permissions (Access: Apps and Policies, Edit; Access: Service Tokens, Edit), used for that run only and stored nowhere. On a skip, or where that cannot be done, a screen names the address and takes a client id and secret you paste, the secret hidden, again only once an application of yours was read to cover the preview (on a skip, through cf as it is signed in here, with no sign-in opened). It opens the preview with the token in the `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers first and saves it, with the setting that sends them, only once the page shows your app; a token it made that opens nothing is deleted, with its policy. The agent never holds either, and the debug log and the transcript remove both. You revoke it in Zero Trust, Access controls, Service credentials, Service Tokens.
|
|
@@ -4,9 +4,11 @@ import { cfFailure, cfStatus } from './cloudflare.js';
|
|
|
4
4
|
* Getting past Cloudflare Access the way Cloudflare documents it for tools: a service token, a client id and a client
|
|
5
5
|
* secret sent in two headers, which a Service Auth policy of the Access application in front of the previews allows.
|
|
6
6
|
* The run makes both as the person through cf, Cloudflare's CLI, signed in with the scopes below, and signs cf in, in
|
|
7
|
-
* the browser, where it lacks them.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* the browser, where it lacks them. An application on policies of its own, which cf has no command to add to, has them
|
|
8
|
+
* made reusable through cf on the person's yes (#327), or else the person makes the token by hand, told the account, the
|
|
9
|
+
* application and why. Where that sign-in cannot make them (it grants no Access, or cf does not run here), the person
|
|
10
|
+
* gives the run a Cloudflare API token with two permissions instead, used in the command's own process for this run and
|
|
11
|
+
* kept nowhere. The person
|
|
10
12
|
* consents once, on the tool's screen, before anything is made; a token opens the locked address before anything saves
|
|
11
13
|
* it; neither it nor the API token goes back to the model.
|
|
12
14
|
*/
|
|
@@ -54,7 +56,7 @@ function withApiToken(key, deps) {
|
|
|
54
56
|
},
|
|
55
57
|
};
|
|
56
58
|
}
|
|
57
|
-
/** The cf command for each call in an account's Access; a policy of an application's own has none. A delete asks first unless forced, and the run deletes only what it made. */
|
|
59
|
+
/** The cf command for each call in an account's Access; adding a policy of an application's own has none, but making one reusable has. A delete asks first unless forced, and the run deletes only what it made. */
|
|
58
60
|
const CF_COMMANDS = [
|
|
59
61
|
[/^GET apps$/, () => ['applications', 'list']],
|
|
60
62
|
[/^GET policies$/, () => ['policies', 'list']],
|
|
@@ -63,6 +65,7 @@ const CF_COMMANDS = [
|
|
|
63
65
|
[/^PUT apps\/([\w-]+)$/, (id) => ['applications', 'update', id]],
|
|
64
66
|
[/^DELETE service_tokens\/([\w-]+)$/, (id) => ['service-tokens', 'delete', id, '--force']],
|
|
65
67
|
[/^DELETE policies\/([\w-]+)$/, (id) => ['policies', 'delete', id, '--force']],
|
|
68
|
+
[/^PUT apps\/([\w-]+)\/policies\/([\w-]+)\/make_reusable$/, (app, policy) => ['applications', 'policies', 'make-reusable', policy, '--app-id', app]],
|
|
66
69
|
];
|
|
67
70
|
/** Cloudflare's API through cf signed in as the person: each call as the cf command that makes it, in the account its path names, which is one of the sign-in's. */
|
|
68
71
|
function throughCf(cf, accounts, signal) {
|
|
@@ -79,7 +82,7 @@ function throughCf(cf, accounts, signal) {
|
|
|
79
82
|
const command = CF_COMMANDS.find(([pattern]) => pattern.test(call));
|
|
80
83
|
if (!at || !command)
|
|
81
84
|
return { ok: false, status: null };
|
|
82
|
-
const args = ['zero-trust', 'access', ...command[1](command[0].exec(call)
|
|
85
|
+
const args = ['zero-trust', 'access', ...command[1](...command[0].exec(call).slice(1)),
|
|
83
86
|
...(paging.has('page') ? ['--page', paging.get('page'), '--per-page', paging.get('per_page')] : []),
|
|
84
87
|
...(body === undefined ? [] : ['--body', JSON.stringify(body)])];
|
|
85
88
|
try {
|
|
@@ -208,17 +211,41 @@ async function create(way, deps, found) {
|
|
|
208
211
|
return { reason: `${found.length} Access applications cover ${url.host}, so which one's policy should allow the token is not clear.` };
|
|
209
212
|
const { account, app } = found[0];
|
|
210
213
|
const base = `/accounts/${account}/access`;
|
|
211
|
-
|
|
214
|
+
let links = list(app.policies).flatMap((p, i) => (uid(p.id) ? [{ id: p.id, precedence: typeof p.precedence === 'number' ? p.precedence : i + 1 }] : []));
|
|
212
215
|
let reusable = true;
|
|
216
|
+
let ids = new Set();
|
|
213
217
|
if (links.length) {
|
|
214
218
|
const policies = await every(api, `${base}/policies`, 1000);
|
|
215
219
|
if (!policies.ok)
|
|
216
220
|
return stop(way, policies, '');
|
|
217
|
-
|
|
221
|
+
ids = new Set(list(policies.result).map((p) => p.id));
|
|
218
222
|
reusable = links.every((link) => ids.has(link.id));
|
|
219
223
|
}
|
|
220
|
-
if (!reusable && way.cf)
|
|
221
|
-
|
|
224
|
+
if (!reusable && way.cf) {
|
|
225
|
+
// cf adds no policy of an application's own, but makes one reusable: asked first, since the person's policies change kind (#327).
|
|
226
|
+
const name = typeof app.name === 'string' && app.name ? app.name : url.host;
|
|
227
|
+
if (!(await deps.reusable(name))) {
|
|
228
|
+
return {
|
|
229
|
+
reason: `The person kept the policies of the Access application "${name}" its own, which the Cloudflare CLI cannot add the token's policy to.`,
|
|
230
|
+
manual: `The Access application "${name}", in the Cloudflare account ${account}, keeps its policies to itself, and the Cloudflare CLI can't add a policy to those. Make the token by hand in that account: Zero Trust → Access controls → Service credentials → Service Tokens → Create Service Token, named "${TOKEN_NAME}"; then add a policy to "${name}" (Access controls → Applications → "${name}" → Policies) with the action Service Auth, including that token.`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const converted = [];
|
|
234
|
+
for (const link of links) {
|
|
235
|
+
if (ids.has(link.id))
|
|
236
|
+
converted.push(link);
|
|
237
|
+
else {
|
|
238
|
+
const made = await api('PUT', `${base}/apps/${app.id}/policies/${link.id}/make_reusable`);
|
|
239
|
+
if (!made.ok)
|
|
240
|
+
return stop(way, made, '');
|
|
241
|
+
// The reusable policy Cloudflare answers, by its own id.
|
|
242
|
+
const id = record(made.result)?.id;
|
|
243
|
+
converted.push({ ...link, id: uid(id) ? id : link.id });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
links = converted;
|
|
247
|
+
reusable = true;
|
|
248
|
+
}
|
|
222
249
|
const undo = [];
|
|
223
250
|
const rollback = async () => {
|
|
224
251
|
let clean = true;
|
|
@@ -295,8 +322,9 @@ export async function passCloudflareAccess(deps) {
|
|
|
295
322
|
const failed = await deps.save(token);
|
|
296
323
|
return { status: failed ? 'save-failed' : 'saved', token: how, ...made, ...(failed ? { reason: failed } : {}), value: token };
|
|
297
324
|
};
|
|
298
|
-
/** Why the run's own way ended without a token that opens the address, for the paste screen's skip. */
|
|
325
|
+
/** Why the run's own way ended without a token that opens the address, for the paste screen's skip, and how to make one by hand there. */
|
|
299
326
|
let stopped;
|
|
327
|
+
let manual;
|
|
300
328
|
if (!(await deps.consent())) {
|
|
301
329
|
stopped = 'The person skipped creating a service token.';
|
|
302
330
|
// cf signed in here, with whatever scopes, says whether an application of the person's covers the address, since reading takes no write; a skip opens no sign-in for it.
|
|
@@ -319,8 +347,11 @@ export async function passCloudflareAccess(deps) {
|
|
|
319
347
|
made = await build(withApiToken(key, deps));
|
|
320
348
|
}
|
|
321
349
|
}
|
|
322
|
-
if (!('token' in made))
|
|
350
|
+
if (!('token' in made)) {
|
|
323
351
|
stopped = 'reason' in made ? made.reason : made.fallback;
|
|
352
|
+
if ('manual' in made && typeof made.manual === 'string')
|
|
353
|
+
manual = made.manual;
|
|
354
|
+
}
|
|
324
355
|
else {
|
|
325
356
|
const token = made;
|
|
326
357
|
// A token that opens nothing is no use to anyone, nor is one a stopped run leaves behind: it and its policy go.
|
|
@@ -337,7 +368,7 @@ export async function passCloudflareAccess(deps) {
|
|
|
337
368
|
deps.signal?.throwIfAborted();
|
|
338
369
|
if (!covered)
|
|
339
370
|
return { status: 'refused', reason: `${stopped} A service token goes only to a preview an Access application in the person's accounts covers, and none was read for ${url.host}, so none was asked for or sent.` };
|
|
340
|
-
const typed = await deps.paste();
|
|
371
|
+
const typed = await deps.paste(manual);
|
|
341
372
|
if (!typed)
|
|
342
373
|
return { status: 'skipped', reason: stopped };
|
|
343
374
|
const token = { id: typed.id?.trim() ?? '', secret: typed.secret?.trim() ?? '' };
|
package/dist/host-questions.js
CHANGED
|
@@ -224,6 +224,23 @@ export function sentTo(screen, url) {
|
|
|
224
224
|
const line = `This run sends it first to ${url}, to prove it opens before anything saves it.`;
|
|
225
225
|
return screen.kind === 'confirm' ? screen : { ...screen, prompt: `${screen.prompt} ${line}` };
|
|
226
226
|
}
|
|
227
|
+
/** The one consent to making the Access application's own policies reusable, which the run's way through cf needs to add the token's policy (#327): the application named. */
|
|
228
|
+
export function cloudflareAccessReusable(application) {
|
|
229
|
+
return {
|
|
230
|
+
id: 'cloudflare-access-reusable',
|
|
231
|
+
kind: 'confirm',
|
|
232
|
+
title: 'Your Access policies belong to one application',
|
|
233
|
+
hint: `The policies of your Access application "${application}" are its own, and the Cloudflare CLI can't add the token's policy beside those.\n\nThe run can make them reusable policies, as the dashboard makes them now: each keeps allowing exactly whom it allows today, and also shows under Access controls → Policies, where another application could use it. They stay reusable after the run.`,
|
|
234
|
+
prompt: 'Make the policies reusable?',
|
|
235
|
+
confirmLabel: 'Make reusable',
|
|
236
|
+
skipLabel: 'Skip',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/** The paste screen for a service token, with how to make one by hand where the run's way stopped on something the person does there (the account, the application and why). */
|
|
240
|
+
export function cloudflareAccessPaste(manual) {
|
|
241
|
+
const screen = HOST_SCREENS['cloudflare-access-key'];
|
|
242
|
+
return manual ? { ...screen, prompt: `Cloudflare Access keeps your previews behind a login. ${manual} Paste its client ID and secret here. It is stored encrypted and used only to open previews.` } : screen;
|
|
243
|
+
}
|
|
227
244
|
/**
|
|
228
245
|
* The paste screen for Vercel's bypass secret, saying what actually stopped the run's own way (the CLI's read, Vercel's
|
|
229
246
|
* refusal in its own words, a secret made but not read back, or one that did not open the preview) rather than one fixed
|
package/dist/http.js
CHANGED
|
@@ -28,7 +28,8 @@ export async function requestJson(url, init, log) {
|
|
|
28
28
|
return { ok: true, status: res.status, data };
|
|
29
29
|
const said = data && typeof data === 'object' ? data.error : undefined;
|
|
30
30
|
const error = typeof said === 'string' ? said : typeof data === 'string' && data ? data.slice(0, 200) : res.statusText || `HTTP ${res.status}`;
|
|
31
|
-
|
|
31
|
+
const retryAfter = res.status === 429 ? Number(res.headers.get('retry-after') ?? NaN) : NaN;
|
|
32
|
+
return { ok: false, status: res.status, data, error, ...(Number.isFinite(retryAfter) && retryAfter >= 0 ? { retryAfter } : {}) };
|
|
32
33
|
}
|
|
33
34
|
catch (err) {
|
|
34
35
|
const error = err instanceof Error ? (err.name === 'TimeoutError' ? 'the request timed out' : err.message) : String(err);
|
package/dist/netlify.js
CHANGED
|
@@ -325,8 +325,9 @@ export async function connectNetlifySite(owner, repo, cwd, deps) {
|
|
|
325
325
|
// the one site built from this repository with the app's folder as its base (#319). The checks below read it again as built from this
|
|
326
326
|
// repository, so a site found here is never written to.
|
|
327
327
|
const otherApp = site !== undefined && deps.directory !== undefined && ours(site) && folderOf(record(site.build_settings)?.base) !== deps.directory;
|
|
328
|
-
|
|
329
|
-
|
|
328
|
+
const elsewhere = site !== undefined && deps.ownSite === true && !ours(site);
|
|
329
|
+
if (!siteId || otherApp || elsewhere) {
|
|
330
|
+
const linkedTo = otherApp ? "This folder is linked to the Netlify site of another app's folder" : elsewhere ? 'This folder is linked to a Netlify site built from another repository' : 'This folder is linked to no Netlify site';
|
|
330
331
|
const where = deps.directory !== undefined ? ` from ${deps.directory || "the repository's root"}` : '';
|
|
331
332
|
let built;
|
|
332
333
|
try {
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Netlify deploys previews of the pull requests: impeccable_netlify_previews lists a built preview of a pull request (`state` success or ready), with its address. Ask it only where the repository is set up for Netlify (`netlify.toml`, `.netlify/`, `netlify-cli` or an `@netlify/*` package), but its record decides, not the checkout. Netlify builds and serves every preview, so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request.
|
|
4
4
|
|
|
5
|
+
It applies as well **before the first pull request**: a site built from this repository has Deploy Previews on (impeccable_netlify_previews lists none, and its `pullRequestPreviews` says `enabled: true`; on a repository with several apps, read with the app's `appKey`), so its deploy previews start with the test drive's draft, the site's first. Then there is nothing to prove in step 1 yet: impeccable_ask with `netlify-setup` is refused, since there is no sign-in, site or connection to make. Run impeccable_netlify_connect (with the app's `appKey`) first: it changes nothing on that site, and checks that Netlify's GitHub app still reads the repository, asking the person once for its access on GitHub when it does not (a connection left undone ends as netlify-setup's "A step fails before the test drive is pushed" says). Then go on to pull-requests' step 4, which opens the test drive as a draft, waits for its deploy preview (impeccable_netlify_previews with `pull`), proves the test drive's page there, past Netlify's lock as preview-protection says, and then step 2 saves the method.
|
|
6
|
+
|
|
5
7
|
### Steps
|
|
6
8
|
|
|
7
9
|
1. **Prove the newest preview** with impeccable_prove_preview: the address of the newest listed preview whose state is success or ready (or a page on it a visitor opens without signing in), and words the page shows that the checkout holds, such as the app's name in its title. Done when it answers `proven: true`; when an address no longer answers, try the next newest, three at most.
|
|
@@ -14,7 +16,7 @@ Netlify deploys previews of the pull requests: impeccable_netlify_previews lists
|
|
|
14
16
|
- **Behind a password or Netlify's login** (`gate: "netlify-protection"`): the site password, or Netlify's team login, stands in front of the previews. That is the preview-protection case's, whose steps come before step 2: on the person's yes the password is stored, or the site behind the team login made public.
|
|
15
17
|
- **Deploy Previews off now** (`pullRequestPreviews` in the answer, `enabled: false`, with its `reason`): the site built previews before, and its settings build none now. The aim holds: ask once with impeccable_enable_previews (`host: "netlify"`), after step 1's proof and preview-protection's steps and before the test drive is pushed, so the person sees the lock's question and this one back to back, at most one each. `enabled`: go on. `skipped` or `refused`: push no test drive (pull-requests' "No preview, no test drive"), and say under "What was skipped, and why" that Deploy Previews are off for the site, with the `reason`, and name the `page`; `left` stays empty, since the person was asked in this run. `enabled: 'unknown'`: say so in one line under "What is not verified" and go on.
|
|
16
18
|
- **Another app, or no answer from any of the three**: Netlify's previews do not show this app. Set the preview up as server-previews or static-site says instead, and say under "What is not verified" what the Netlify address answered.
|
|
17
|
-
- **Nothing listed**: Netlify's previews are not on record.
|
|
19
|
+
- **Nothing listed**: Netlify's previews are not on record. With a site of this repository whose Deploy Previews are on, that is the first pull request still to come, above. Else, where the checkout is set up for Netlify, that is the netlify-setup case's; else set the preview up as server-previews or static-site says. An `error` means a read failed, so a preview may exist that is not listed: say so under "What is not verified".
|
|
18
20
|
- **The pull requests**: no setup pull request; the test drive branches from the default branch. Netlify builds a deploy preview only for a pull request, so the test drive opens as a draft, its deploy preview is waited for and the test drive's page proven on it, and then the draft is marked ready: pull-requests' step 4. In place of pull-requests' step 3, build the test drive branch once with the build Netlify runs (`netlify.toml`'s `[build]` `command`, else the package's `build` script), so a page that does not build is never pushed.
|
|
19
21
|
- The evidence is read only: link, deploy and change nothing on Netlify, beyond what impeccable_netlify_password and impeccable_enable_previews do on the person's yes. Netlify does not report its previews as GitHub deployments, so Impeccable finds them by their address pattern: save the pattern the proof answered, and no other.
|
|
20
22
|
- The report names the address that answered with this app under "What we found", and the pattern saved under "What was configured, and by whom". The test drive's own preview, which Netlify builds after the run, goes under "What is not verified".
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
### When this applies
|
|
2
2
|
|
|
3
|
-
The checkout is set up for Netlify: a `netlify.toml`, a `.netlify/` folder, or a package script that runs the `netlify` command (a Netlify package alone, `@netlify/*` or `netlify-cli`, says nothing of where the repository deploys); or the person picked Netlify in server-previews. And no pull request has a Netlify deploy preview: impeccable_netlify_previews lists none, and no `error` (an error says nothing either way). These steps replace the playbook's step 3. As in netlify-previews, the repository needs no workflow and no setup pull request.
|
|
3
|
+
The checkout is set up for Netlify: a `netlify.toml`, a `.netlify/` folder, or a package script that runs the `netlify` command (a Netlify package alone, `@netlify/*` or `netlify-cli`, says nothing of where the repository deploys); or the person picked Netlify in server-previews. And no pull request has a Netlify deploy preview: impeccable_netlify_previews lists none, and no `error` (an error says nothing either way). And no site built from this repository has Deploy Previews on: with `pullRequestPreviews` `enabled: true` the site previews the first pull request by itself, which is netlify-previews' (before the first pull request), not this case's. These steps replace the playbook's step 3. As in netlify-previews, the repository needs no workflow and no setup pull request.
|
|
4
4
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
@@ -14,7 +14,7 @@ Every run, once the settings are saved. The person merges the setup pull request
|
|
|
14
14
|
|
|
15
15
|
A host's wait that ran out shows its own screen, which opens the host's page where its builds are turned on and waits again on the person's yes. Done when the test drive's page is proven on its preview, and a host's method saved; when nothing proved it, the reference below says how the run goes on.
|
|
16
16
|
5. **Open the test drive and record it**: `gh pr create --repo <owner>/<repo> --head impeccable/test-drive --base <defaultBranch>` with the title "Impeccable test drive (safe to close)" and a body that starts "This pull request only shows Impeccable reviewing a change. Close it when you are done; nothing here needs to be merged." and then names the pages its diff holds, no more, and lists the flaws planted for the review to find, one line each (a Netlify draft opened this way in step 4, and was marked ready there). Record it right away with impeccable_test_pr, so the web page follows its review; it refuses a draft, and a branch that changes no existing page. Done when impeccable_test_pr answered "Recorded".
|
|
17
|
-
6. **Check the test drive's preview once**, while it is the newest open pull request: impeccable_write to `latest-pull-check` with the app's `appKey`. Done when it answered; this early a 409 `no-preview-running` or a job still starting is expected, and the preview goes under "What is not verified".
|
|
17
|
+
6. **Check the test drive's preview once**, while it is the newest open pull request: impeccable_write to `latest-pull-check` with the app's `appKey`. Done when it answered; this early a 409 `no-preview-running` or a job still starting is expected, and the preview goes under "What is not verified". The setup API checks a repository once every 30 seconds, whichever app asks: the tool waits that out itself and tries again, so make each app's call in turn, and never wait with a command. A 429 it still answered means that app's check did not run: say so under "What is not verified".
|
|
18
18
|
7. **Open the setup pull request**, when there is a setup commit: `git push -u origin impeccable/setup`, then `gh pr create --repo <owner>/<repo> --head impeccable/setup --base <defaultBranch>` with the title "Set up Impeccable previews" and a body that starts "Merge this to keep Impeccable's previews working." and then says what it carries and why, one line each. Record it with impeccable_test_pr: the test drive again, and this one in `setup_pr`. Done when impeccable_test_pr answered "Recorded".
|
|
19
19
|
|
|
20
20
|
### Reference
|
|
@@ -10,7 +10,7 @@ The setup commit carries the workflow the state offers in the preview item's `fi
|
|
|
10
10
|
2. **Sync the workflow** on the setup branch, once the settings are saved: impeccable_sync_workflow writes the state's file byte for byte and removes the retired file the state names. Done when it answers `ok: true`.
|
|
11
11
|
- **`workflowStanding` is current**: nothing to write; leave the file as it is.
|
|
12
12
|
- **absent, out-of-date, older-generation or retired**: the tool writes the fresh file.
|
|
13
|
-
- **unknown**: the current workflow
|
|
13
|
+
- **unknown**: Impeccable has not read the current workflow on GitHub yet (`workflowPending` true), or the read failed (`workflowError` says why). The tool waits on a pending read itself, reading the state again for up to five minutes, and writes the file once the read lands. When it still refuses, leave the file as it is and sync nothing: the step is done, and the workflow goes under "What is not verified" with the refusal's reason (a read still pending comes within the hour; a failed one names its cause).
|
|
14
14
|
- **Refused for local edits**: nothing changed. Keep the person's edits, ask them through impeccable_ask to resolve the conflict, then sync again.
|
|
15
15
|
3. **Commit** only the setup files: the workflow, the retired file's removal, a build fix. Then call impeccable_sync_workflow again. Done when `stagedMatches` and `committedMatches` are true, and `retiredRemoval.staged` and `retiredRemoval.committed` too when it names a retired file; null means nothing was compared.
|
|
16
16
|
|
package/dist/playbook.js
CHANGED
|
@@ -21,7 +21,7 @@ Set the repository up with whatever the host's CLI can do (Netlify's, Vercel's,
|
|
|
21
21
|
|
|
22
22
|
1. **Read the state.** The setup API's instructions and state came with the first message, trimmed to this setup; impeccable_state reads the state again. Done when you know the preview item's status, \`file\` and \`workflowStanding\`, whether there is a \`signin\` item, whether there is an \`apps\` item (the monorepo case comes first then), and the titles in \`notPartOfThisSetup\`.
|
|
23
23
|
2. **Read the repository**: manifests, the lockfile, scripts, framework config, CI workflows, env examples, the routes, the auth code and the seed. Done when you can name the package manager, the install, build and start commands, whether the pages need a server, how people sign in, and which pages a visitor opens without signing in.
|
|
24
|
-
3. **Set up the preview** as the vercel-previews case says when impeccable_hosted_previews lists Vercel's previews of the pull requests, as the netlify-previews case says when impeccable_netlify_previews lists Netlify's (and as preview-protection says when the host's login or password stands in front of either), as the cloudflare-previews case says when impeccable_cloudflare_previews lists Cloudflare Pages', as the vercel-setup case says when the checkout is set up for Vercel and Vercel has never deployed it, as the netlify-setup case says when the checkout is set up for Netlify and it lists none, else as the server-previews case says when the pages need a server, and as the static-site case says when they do not; then the live app's address as the live-app-address case says, in the method's write where it can be. Done when those cases' steps are done.
|
|
24
|
+
3. **Set up the preview** as the vercel-previews case says when impeccable_hosted_previews lists Vercel's previews of the pull requests, as the netlify-previews case says when impeccable_netlify_previews lists Netlify's, or a site of this repository with Deploy Previews on (and as preview-protection says when the host's login or password stands in front of either), as the cloudflare-previews case says when impeccable_cloudflare_previews lists Cloudflare Pages', as the vercel-setup case says when the checkout is set up for Vercel and Vercel has never deployed it, as the netlify-setup case says when the checkout is set up for Netlify and it lists none, else as the server-previews case says when the pages need a server, and as the static-site case says when they do not; then the live app's address as the live-app-address case says, in the method's write where it can be. Done when those cases' steps are done.
|
|
25
25
|
4. **Set up the sign-in** as the sign-in case says, when the app has its own. Done when that case's steps are done.
|
|
26
26
|
5. **Run impeccable_checkup**, do what \`next\` says, and run it again. Done when \`status\` is complete, or all that is left is in \`forPerson\` or \`waitingFor\`.
|
|
27
27
|
6. **Open the pull requests** as the pull-requests case says. What waits on the person holds neither back. Done when that case's steps are done.
|
package/dist/tools.js
CHANGED
|
@@ -10,7 +10,7 @@ import { defineTool } from '@earendil-works/pi-coding-agent';
|
|
|
10
10
|
import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
|
|
11
11
|
import { requestJson } from './http.js';
|
|
12
12
|
import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
|
|
13
|
-
import { APPS_QUESTION, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, sentTo, startedIn, vercelBypassPaste } from './host-questions.js';
|
|
13
|
+
import { APPS_QUESTION, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, sentTo, startedIn, vercelBypassPaste } from './host-questions.js';
|
|
14
14
|
import { enableNetlifyPreviews, folderOf } from './host-previews.js';
|
|
15
15
|
import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews } from './hosted.js';
|
|
16
16
|
import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
|
|
@@ -108,14 +108,17 @@ 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
|
+
/** 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
|
+
const CHECK_RETRIES = 3;
|
|
111
113
|
/** 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. */
|
|
112
114
|
export function appsSummary(apps) {
|
|
113
115
|
const picked = apps.filter((a) => a.state !== 'unpicked');
|
|
114
116
|
const ready = picked.filter((a) => a.state === 'ready');
|
|
115
117
|
const rest = picked.filter((a) => a.state !== 'ready');
|
|
116
118
|
const names = (list) => list.map((a) => a.name).join(', ');
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
+
const unchecked = ready.filter((a) => a.unchecked);
|
|
120
|
+
const line = [...(ready.length ? [`Set up: ${names(ready)}.`] : []), ...(unchecked.length ? [`Not checked: ${names(unchecked)}.`] : []), ...(rest.length ? [`Not set up: ${names(rest)}.`] : [])].join(' ');
|
|
121
|
+
return { notReady: rest.map((a) => a.name), line, todo: [...rest, ...unchecked].flatMap((a) => (a.left ? [a.left] : [])) };
|
|
119
122
|
}
|
|
120
123
|
/** A host's lock in front of a preview, as impeccable_prove_preview names it: how the person reads it, the tool that gets a key past it, and the step left where none was saved (preview-protection's step 3). */
|
|
121
124
|
const LOCKS = {
|
|
@@ -1287,7 +1290,11 @@ export async function refreshWorkflowBase(owner, repo, defaultBranch, root, depe
|
|
|
1287
1290
|
return fail(dependencies.signal?.aborted ? 'cancelled' : stage);
|
|
1288
1291
|
}
|
|
1289
1292
|
}
|
|
1290
|
-
|
|
1293
|
+
/**
|
|
1294
|
+
* `pending`: how long a state whose workflow standing is unknown because Impeccable has not read GitHub yet
|
|
1295
|
+
* (`workflowPending`, pristine#2698) is read again before the sync gives up on it; `status` says so meanwhile.
|
|
1296
|
+
*/
|
|
1297
|
+
export function createWorkflowSynchronizer(opts = {}) {
|
|
1291
1298
|
const CURRENT = '.github/workflows/impeccable-preview.yml';
|
|
1292
1299
|
const RETIRED = '.github/workflows/pristine-preview.yml';
|
|
1293
1300
|
const MAX_BYTES = 1_000_000;
|
|
@@ -1361,6 +1368,13 @@ export function createWorkflowSynchronizer() {
|
|
|
1361
1368
|
|| (file.index !== owned.baselineIndex && !ownIndex))
|
|
1362
1369
|
refuse('The workflow changed since this run wrote it. Keep those edits and resolve them before syncing.');
|
|
1363
1370
|
}
|
|
1371
|
+
/** Whether the state's workflow standing is unknown only because Impeccable has not read GitHub yet. */
|
|
1372
|
+
function pendingRead(state, repoName) {
|
|
1373
|
+
const repos = obj(state)?.repos;
|
|
1374
|
+
const matches = Array.isArray(repos) ? repos.map(obj).filter((repo) => repo?.name === repoName) : [];
|
|
1375
|
+
const preview = matches.length === 1 ? obj(obj(matches[0]?.items)?.preview) : null;
|
|
1376
|
+
return preview?.workflowStanding === 'unknown' && preview.workflowPending === true;
|
|
1377
|
+
}
|
|
1364
1378
|
function offeredPreview(state, repoName) {
|
|
1365
1379
|
const repos = obj(state)?.repos;
|
|
1366
1380
|
const matches = Array.isArray(repos) ? repos.map(obj).filter((repo) => repo?.name === repoName) : [];
|
|
@@ -1369,8 +1383,13 @@ export function createWorkflowSynchronizer() {
|
|
|
1369
1383
|
if (!preview || preview.status === 'not-selected' || preview.status === 'skipped'
|
|
1370
1384
|
|| !standings.includes(standing))
|
|
1371
1385
|
refuse('The selected repository has no verified workflow state. Nothing was changed.');
|
|
1372
|
-
if (standing === 'unknown')
|
|
1373
|
-
|
|
1386
|
+
if (standing === 'unknown') {
|
|
1387
|
+
// Which unknown it is (pristine#2698): a read not made yet, or one that failed, and why.
|
|
1388
|
+
if (preview.workflowPending === true)
|
|
1389
|
+
refuse("Impeccable has not read the current workflow on GitHub yet, and it was still not read after waiting: its reads wait while the GitHub account's hourly budget is spent, so it comes within the hour. Its drift is unknown; nothing was changed.");
|
|
1390
|
+
const error = typeof preview.workflowError === 'string' ? preview.workflowError.replace(/\s+/g, ' ').trim().slice(0, 200) : '';
|
|
1391
|
+
refuse(error ? `Impeccable could not read the current workflow on GitHub: ${error.replace(/\.$/, '')}. Its drift is unknown; nothing was changed.` : 'The current workflow could not be read. Its drift is unknown; nothing was changed.');
|
|
1392
|
+
}
|
|
1374
1393
|
return { preview, standing: standing };
|
|
1375
1394
|
}
|
|
1376
1395
|
function offeredBytes(preview) {
|
|
@@ -1400,7 +1419,15 @@ export function createWorkflowSynchronizer() {
|
|
|
1400
1419
|
const topLevel = (await git(root, ['rev-parse', '--show-toplevel'], signal)).toString('utf8').trim();
|
|
1401
1420
|
if (await realpath(topLevel) !== root)
|
|
1402
1421
|
refuse('Run workflow sync from the selected repository root. Nothing was changed.');
|
|
1403
|
-
|
|
1422
|
+
// A workflow Impeccable has not read on GitHub yet is waited for, the state read again until the read lands (#330).
|
|
1423
|
+
let state = await readState();
|
|
1424
|
+
const wait = opts.pending ?? { totalMs: 300_000, everyMs: 30_000 };
|
|
1425
|
+
for (const end = Date.now() + wait.totalMs; pendingRead(state, repo) && Date.now() < end;) {
|
|
1426
|
+
opts.status?.('Waiting for Impeccable to read the workflow on GitHub');
|
|
1427
|
+
await sleep(wait.everyMs, undefined, { signal });
|
|
1428
|
+
state = await readState();
|
|
1429
|
+
}
|
|
1430
|
+
const { preview, standing } = offeredPreview(state, repo);
|
|
1404
1431
|
const retired = preview.workflowRetiredPath;
|
|
1405
1432
|
if (retired != null && retired !== RETIRED && retired !== CURRENT)
|
|
1406
1433
|
refuse('State named an unsupported retired workflow path. Nothing was changed.');
|
|
@@ -1643,7 +1670,7 @@ export function createTools(ctx) {
|
|
|
1643
1670
|
/** What impeccable_cloudflare_connect answered: asked once, so that answer stands. */
|
|
1644
1671
|
let connectOutcome;
|
|
1645
1672
|
// Keep ownership of files for this one run; never recreate it per call.
|
|
1646
|
-
const workflowSynchronizer = ctx.workflowSynchronizer ?? createWorkflowSynchronizer();
|
|
1673
|
+
const workflowSynchronizer = ctx.workflowSynchronizer ?? createWorkflowSynchronizer({ status: (line) => said(line) });
|
|
1647
1674
|
const workflowRefusal = (reason) => ({ ok: false, reason, actions: [] });
|
|
1648
1675
|
const workflowReady = (access) => access.authenticated === true
|
|
1649
1676
|
&& access.originMatches === true && access.defaultBranch !== null && !access.needsWorkflowScope
|
|
@@ -1723,12 +1750,16 @@ export function createTools(ctx) {
|
|
|
1723
1750
|
/** On a repository with several apps: the app each production address a host tool answered is the live app of, read for that app's own project or site, and the apps whose host preview a proof opened. */
|
|
1724
1751
|
const liveApps = new Map();
|
|
1725
1752
|
const provenApps = new Set();
|
|
1753
|
+
/** The apps whose preview check (connection-check or latest-pull-check) the setup API answered in this run ('' for the repository): a ready app missing here is named on the last screen (#338). */
|
|
1754
|
+
const checkedApps = new Set();
|
|
1726
1755
|
/** On a repository with several apps (#286): the method each app's preview write saved; the host's lock a proof of each app's preview met; and the apps whose lock was met with no key tool called for them since, which holds every other app's steps until it is. */
|
|
1727
1756
|
const appMethods = new Map();
|
|
1728
1757
|
const lockMet = new Map();
|
|
1729
1758
|
const lockPending = new Map();
|
|
1730
1759
|
/** The projects whose deployments Vercel made of this repository, as impeccable_hosted_previews read them, by the app read for ('' for the repository): a repository Vercel deploys already needs no Vercel setup, since its previews start with the first pull request (#286). */
|
|
1731
1760
|
const vercelDeployed = new Map();
|
|
1761
|
+
/** The Netlify sites built from this repository whose settings build Deploy Previews, as impeccable_netlify_previews read them, by the app read for ('' for the repository): their previews start with the first pull request, so there is no Netlify setup to ask about (#311). */
|
|
1762
|
+
const netlifyPreviewing = new Map();
|
|
1732
1763
|
/** Each Vercel project's production address, read from Vercel, by origin, and the project it was read from: the one live address the project's own bypass secret is sent to, since a secret is one project's, and the link file can name another project of this repository between the two reads. */
|
|
1733
1764
|
const vercelProductions = new Map();
|
|
1734
1765
|
/** The person's Vercel teams as the CLI listed them this run, once: a project's dashboard page is named by its own team, never the CLI's current scope or Vercel's picker, which open whatever project they like. */
|
|
@@ -1871,14 +1902,14 @@ export function createTools(ctx) {
|
|
|
1871
1902
|
}, signal);
|
|
1872
1903
|
}
|
|
1873
1904
|
/** 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. */
|
|
1874
|
-
async function netlifyConnect(pi, settings, signal, directory) {
|
|
1875
|
-
// The site's settings change on the host: only once the person opened the Netlify setup, as the fence holds for the CLI.
|
|
1876
|
-
if (!hostSetupAgreed(ctx.log, 'netlify'))
|
|
1905
|
+
async function netlifyConnect(pi, settings, signal, directory, previewing = false) {
|
|
1906
|
+
// 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.
|
|
1907
|
+
if (!hostSetupAgreed(ctx.log, 'netlify') && !previewing)
|
|
1877
1908
|
return { connected: false, reason: 'The Netlify sign-in comes first: impeccable_ask with the id netlify-setup.' };
|
|
1878
1909
|
const netlify = (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal });
|
|
1879
1910
|
const gh = (ctx.hostRunner ?? commandRunner)('gh', pi.cwd, { signal });
|
|
1880
1911
|
const clone = ctx.hostWait?.clone;
|
|
1881
|
-
const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, linked: linkedByRun(ctx.log), settings, signal, ...(clone ? { wait: clone } : {}), ...(directory !== undefined ? { directory } : {}) });
|
|
1912
|
+
const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, linked: linkedByRun(ctx.log), settings, signal, ...(clone ? { wait: clone } : {}), ...(directory !== undefined ? { directory } : {}), ...(previewing ? { ownSite: true } : {}) });
|
|
1882
1913
|
netlifySite = result.site?.name ?? netlifySite;
|
|
1883
1914
|
if (result.pullRequestPreviews)
|
|
1884
1915
|
previewFacts.set('netlify', result.pullRequestPreviews);
|
|
@@ -2142,8 +2173,12 @@ export function createTools(ctx) {
|
|
|
2142
2173
|
const proven = provenApps.has(key);
|
|
2143
2174
|
if (!pickedApps?.has(key))
|
|
2144
2175
|
return { key, name, folder, state: 'unpicked' };
|
|
2145
|
-
if (method !== undefined && (method !== 'preview-deploy' || proven))
|
|
2146
|
-
|
|
2176
|
+
if (method !== undefined && (method !== 'preview-deploy' || proven)) {
|
|
2177
|
+
// With a test drive open, each app's preview is checked once (pull-requests' step 6): one the setup API never answered is named (#338).
|
|
2178
|
+
return ctx.sync.payload.test_pr && !checkedApps.has(key)
|
|
2179
|
+
? { 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.` }
|
|
2180
|
+
: { key, name, folder, state: 'ready' };
|
|
2181
|
+
}
|
|
2147
2182
|
if (gate && !proven)
|
|
2148
2183
|
return { key, name, folder, state: 'locked', left: `${name}: its previews are behind ${LOCKS[gate].lock}, and no key was saved. ${LOCKS[gate].left}.` };
|
|
2149
2184
|
return { key, name, folder, state: 'unset', left: `${name}: no preview method was saved; the report says why.` };
|
|
@@ -2154,7 +2189,7 @@ export function createTools(ctx) {
|
|
|
2154
2189
|
const where = `${a.name} (${a.folder || '/'})`;
|
|
2155
2190
|
const lock = lockMet.has(a.key) ? LOCKS[lockMet.get(a.key)].lock : undefined;
|
|
2156
2191
|
if (a.state === 'ready')
|
|
2157
|
-
return `- ${where}: set up. Method: ${appMethods.get(a.key)}${provenApps.has(a.key) ? `, its preview proven${lock ? ` past ${lock}` : ''}` : ''}
|
|
2192
|
+
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." : ''}`;
|
|
2158
2193
|
if (a.state === 'locked')
|
|
2159
2194
|
return `- ${where}: not set up. Its previews are behind ${lock}, and no key was saved for it.`;
|
|
2160
2195
|
if (a.state === 'unset')
|
|
@@ -2331,7 +2366,20 @@ export function createTools(ctx) {
|
|
|
2331
2366
|
body = { ...object(body), accounts: list.map((account) => ({ ...object(account), password: typed.get(String(object(account)?.password)) })) };
|
|
2332
2367
|
}
|
|
2333
2368
|
const checkedHead = previews.active && target.url.endsWith('/latest-pull-check') ? await observe(signal) : undefined;
|
|
2334
|
-
|
|
2369
|
+
let response = await call(pi, target.url, { method: target.method, body });
|
|
2370
|
+
// The setup API checks a repository's preview once per 30 seconds, whichever app asks: the next app's check waits its turn here, a bounded wait in place of the sleep the fence refuses (#338).
|
|
2371
|
+
const check = /\/(connection-check|latest-pull-check)$/.test(target.url);
|
|
2372
|
+
for (let tries = 0; check && response.status === 429 && tries < CHECK_RETRIES && !signal?.aborted; tries++) {
|
|
2373
|
+
const seconds = Math.min(Math.max(Math.ceil(response.retryAfter ?? 30), 1), 60);
|
|
2374
|
+
said(`Waiting ${seconds} s for the next preview check`);
|
|
2375
|
+
await (ctx.checkWait ?? ((ms, stop) => sleep(ms, undefined, { signal: stop })))(seconds * 1000, signal).catch(() => undefined);
|
|
2376
|
+
if (signal?.aborted)
|
|
2377
|
+
break;
|
|
2378
|
+
response = await call(pi, target.url, { method: target.method, body });
|
|
2379
|
+
}
|
|
2380
|
+
// A check that answered, a preview not running yet included, ran for its app; one still turned away did not.
|
|
2381
|
+
if (check && (response.ok || response.status === 409))
|
|
2382
|
+
checkedApps.add(typeof object(p.body)?.appKey === 'string' ? String(object(p.body)?.appKey) : '');
|
|
2335
2383
|
if (target.url.endsWith('/apps') && response.ok && object(p.body)?.action === 'confirm')
|
|
2336
2384
|
confirmedApps = true;
|
|
2337
2385
|
if (target.url.endsWith('/preview') && response.ok) {
|
|
@@ -2384,6 +2432,11 @@ export function createTools(ctx) {
|
|
|
2384
2432
|
const projects = [...vercelDeployed.values()].filter(Boolean);
|
|
2385
2433
|
return `Refused: Vercel deploys this repository already${projects.length ? ` (${projects.join(', ')})` : ''}: it made deployments of it, production included, so its previews start with the first pull request, and there is no Vercel sign-in, project or connection to make. Go on as vercel-previews says: build and push the test drive as pull-requests' step 4 says, wait for Vercel's deployment of the pushed branch with impeccable_hosted_previews and branch, prove the test drive's page there (past Vercel's login as preview-protection says: impeccable_vercel_bypass finds the project with no vercel link, and signs the Vercel CLI in itself where it is signed out), and save the method.`;
|
|
2386
2434
|
}
|
|
2435
|
+
// The Netlify setup is for a repository no Netlify site previews yet: a site built from it with Deploy Previews on (every app's, on a repository with several) builds the first pull request's deploy preview by itself (#311).
|
|
2436
|
+
const previewing = appsRepo ? pickedApps !== undefined && pickedApps.size > 0 && [...pickedApps].every((key) => netlifyPreviewing.has(key)) : netlifyPreviewing.has('');
|
|
2437
|
+
if (asked.id === 'netlify-setup' && previewing && !hostSignIns.has('netlify')) {
|
|
2438
|
+
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.`;
|
|
2439
|
+
}
|
|
2387
2440
|
if (host?.[2] === 'setup')
|
|
2388
2441
|
return JSON.stringify(await hostSignIn(host[1], pi, signal));
|
|
2389
2442
|
if (host && (teamChoices.get(host[1])?.length ?? 0) < 2)
|
|
@@ -2698,6 +2751,13 @@ export function createTools(ctx) {
|
|
|
2698
2751
|
}
|
|
2699
2752
|
if (evidence.pullRequestPreviews)
|
|
2700
2753
|
previewFacts.set('netlify', evidence.pullRequestPreviews);
|
|
2754
|
+
// 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).
|
|
2755
|
+
if (pull === undefined) {
|
|
2756
|
+
if (!evidence.unmatched && evidence.pullRequestPreviews?.enabled === true)
|
|
2757
|
+
netlifyPreviewing.set(appKey ?? '', evidence.pullRequestPreviews.project);
|
|
2758
|
+
else
|
|
2759
|
+
netlifyPreviewing.delete(appKey ?? '');
|
|
2760
|
+
}
|
|
2701
2761
|
noteProduction(evidence.production, 'netlify', liveSite(evidence), appKey);
|
|
2702
2762
|
const site = evidence.unmatched ? undefined : liveSite(evidence);
|
|
2703
2763
|
if (site)
|
|
@@ -2829,7 +2889,7 @@ export function createTools(ctx) {
|
|
|
2829
2889
|
const directory = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
|
|
2830
2890
|
if (appKey !== undefined && directory === undefined)
|
|
2831
2891
|
return JSON.stringify({ appKey, connected: false, reason: `The setup state names no folder for the app ${appKey}.` });
|
|
2832
|
-
const result = await netlifyConnect(pi, { command, publish }, signal, directory);
|
|
2892
|
+
const result = await netlifyConnect(pi, { command, publish }, signal, directory, netlifyPreviewing.has(appKey ?? ''));
|
|
2833
2893
|
if (result.how && result.site)
|
|
2834
2894
|
noteApp(appKey, { host_project: result.site.name });
|
|
2835
2895
|
return JSON.stringify(appKey !== undefined ? { appKey, ...result } : result);
|
|
@@ -3200,7 +3260,7 @@ export function createTools(ctx) {
|
|
|
3200
3260
|
}
|
|
3201
3261
|
return JSON.stringify(passwordOutcome);
|
|
3202
3262
|
}),
|
|
3203
|
-
tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token, Cloudflare\'s sign-in in the browser where cf, Cloudflare\'s CLI, is not signed in here with Access (with a retry), then the token named "Impeccable reviews" created through cf and allowed in the policy of the application in front of the preview; one screen taking a Cloudflare API token only where that sign-in cannot create it; on a skip, or where that cannot be done, one screen taking a pasted client id and secret, only once an Access application in the person\'s accounts was read to cover the preview (none read: refused, with the reason, since the token goes to no other address). It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out, apiToken true when the person\'s Cloudflare API token created it), skipped (nothing was saved; reason says what stopped it), refused (no Access application of the person\'s covers the preview, or the typed token did not open it; reason says which) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
3263
|
+
tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token, Cloudflare\'s sign-in in the browser where cf, Cloudflare\'s CLI, is not signed in here with Access (with a retry), then the token named "Impeccable reviews" created through cf and allowed in the policy of the application in front of the preview (an application whose policies are its own gets one screen asking to make them reusable first, and on its skip the paste screen says how to make the token by hand); one screen taking a Cloudflare API token only where that sign-in cannot create it; on a skip, or where that cannot be done, one screen taking a pasted client id and secret, only once an Access application in the person\'s accounts was read to cover the preview (none read: refused, with the reason, since the token goes to no other address). It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out, apiToken true when the person\'s Cloudflare API token created it), skipped (nothing was saved; reason says what stopped it), refused (no Access application of the person\'s covers the preview, or the typed token did not open it; reason says which) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
3204
3264
|
if (!accessGated)
|
|
3205
3265
|
return 'Refused: this is for a Cloudflare Pages preview impeccable_cloudflare_previews listed, which impeccable_prove_preview answered with gate "cloudflare-access". Prove one first.';
|
|
3206
3266
|
const at = accessGated;
|
|
@@ -3247,8 +3307,9 @@ export function createTools(ctx) {
|
|
|
3247
3307
|
const typed = await ask(HOST_SCREENS['cloudflare-access-api']);
|
|
3248
3308
|
return typed.skipped ? undefined : typed.value;
|
|
3249
3309
|
},
|
|
3250
|
-
|
|
3251
|
-
|
|
3310
|
+
reusable: async (application) => !(await ask(cloudflareAccessReusable(application))).skipped,
|
|
3311
|
+
paste: async (manual) => {
|
|
3312
|
+
const typed = await ask(sentTo(cloudflareAccessPaste(manual), at.url));
|
|
3252
3313
|
return typed.skipped ? undefined : { id: typed.username, secret: typed.password };
|
|
3253
3314
|
},
|
|
3254
3315
|
prove,
|