@impeccable/reviews 0.1.14 → 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 +2 -2
- package/dist/cloudflare-access.js +43 -12
- package/dist/fence.js +2 -1
- package/dist/host-questions.js +17 -0
- package/dist/http.js +2 -1
- package/dist/main.js +9 -9
- package/dist/netlify.js +3 -2
- package/dist/playbook/cases/cloudflare-previews.md +1 -1
- package/dist/playbook/cases/netlify-previews.md +4 -2
- package/dist/playbook/cases/netlify-setup.md +1 -1
- package/dist/playbook/cases/preview-protection.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 +2 -2
- package/dist/tools.js +218 -52
- package/dist/vercel-bypass.js +11 -9
- package/dist/vercel.js +23 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ Keys: Enter goes on, Esc cancels the intro or the sign-in and skips a question (
|
|
|
44
44
|
4. **Two pull requests,** opened as you with `gh`, even when something is left for a person, which goes into the report, both into the default branch and in this order:
|
|
45
45
|
- the test drive, safe to close (`impeccable/test-drive`, "Impeccable test drive (safe to close)"), always: a few real style or layout changes on a page the preview renders, on top of the setup commit when there is one, so it carries the workflow, whose job builds the preview on the pull request while Impeccable's review waits for it. Its pull request opens only once its preview is proven: the host's build of the pushed branch, or the local proof of Impeccable's own preview. No preview, no test drive: the run then says why in the report and what you can do, and still opens the setup pull request when the repository needs one;
|
|
46
46
|
- the setup pull request, to merge (`impeccable/setup`, "Set up Impeccable previews"), only when the repository needs a change to keep previews working: the preview workflow, any fix the build needed. The report lists the files it changes, so one the setup never meant to change stands out.
|
|
47
|
-
5. **The end.** The run ends once the pull requests are open and recorded. The report (what it found, configured, skipped and could not verify, and what to do before merging) goes to the web page, which follows Impeccable's review of the test drive; the terminal ends on the test drive's screen (when there is a setup pull request too), then "You're all set up" with the setup pull request, or the test drive when there is none, and what is left, and its summary keeps both pull requests and the web page; exit 0. If something stops it, it says why in a sentence and exits 1.
|
|
47
|
+
5. **The end.** The run ends once the pull requests are open and recorded. The report (what it found, configured, skipped and could not verify, and what to do before merging) goes to the web page, which follows Impeccable's review of the test drive; the terminal ends on the test drive's screen (when there is a setup pull request too), then "You're all set up" with the setup pull request, or the test drive when there is none, and what is left, and its summary keeps both pull requests and the web page; exit 0. A run that opened no test drive, or left a picked app not set up, says so in its heading ("Not set up: the previews are behind Cloudflare Access") with the reason under it, prints the report, and exits 1. If something stops it, it says why in a sentence and exits 1.
|
|
48
48
|
|
|
49
49
|
The sign-in's setup token lasts an hour. A run that goes longer asks you to sign in again, in the same terminal, and carries on.
|
|
50
50
|
|
|
@@ -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/fence.js
CHANGED
|
@@ -782,8 +782,9 @@ function vercel(args, where, agreed, team, settlement = null) {
|
|
|
782
782
|
return allow();
|
|
783
783
|
if (sub === 'login')
|
|
784
784
|
return deny('the Vercel sign-in is impeccable_ask\'s, with the id vercel-setup: it opens the browser from the sign-in screen');
|
|
785
|
+
// The gate is the person's yes to setting Vercel up, not the CLI's sign-in: a project Vercel deploys already needs no link for its login, which impeccable_vercel_bypass gets past (#310).
|
|
785
786
|
if (!agreed)
|
|
786
|
-
return deny(
|
|
787
|
+
return deny("sets Vercel up, which waits for the person's yes to the Vercel setup (impeccable_ask vercel-setup), whether or not the CLI is signed in; a preview behind Vercel's login needs no vercel link, since impeccable_vercel_bypass finds the repository's project");
|
|
787
788
|
const cwd = options.get('--cwd');
|
|
788
789
|
if (cwd !== undefined && (typeof cwd !== 'string' || outside(cwd, where)))
|
|
789
790
|
return deny('points the Vercel CLI outside the checkout');
|
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/main.js
CHANGED
|
@@ -16,7 +16,7 @@ import { scanRecorder } from './scan.js';
|
|
|
16
16
|
import { createReporter, reportingOff } from './report.js';
|
|
17
17
|
import { createSessionSync, pageStatus, plainReport, syncExtension } from './sync.js';
|
|
18
18
|
import { checkPushAccess, readPushAccess } from './push-access.js';
|
|
19
|
-
import { appsSummary, createTools, ownPullRequest, resultLine } from './tools.js';
|
|
19
|
+
import { appsSummary, createTools, ending, ownPullRequest, resultLine } from './tools.js';
|
|
20
20
|
import { transcriptPaths } from './transcript.js';
|
|
21
21
|
import { askPerson } from './ui/bridge.js';
|
|
22
22
|
import { atExit, closeScreens, helpMark, onScreenExit, recordFrames, showIntro, showOutro, showPullRequest, showRun, showSignIn } from './ui/screens.js';
|
|
@@ -235,16 +235,16 @@ export async function main(argv) {
|
|
|
235
235
|
const saved = await settle(liveSync.flush(), 5000);
|
|
236
236
|
const { testPr } = report;
|
|
237
237
|
const setupPr = shownSetupPr(report.setupPr);
|
|
238
|
-
// 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).
|
|
238
|
+
// 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).
|
|
239
239
|
const apps = appsSummary(report.apps ?? []);
|
|
240
|
-
const
|
|
240
|
+
const end = ending(report);
|
|
241
241
|
// One pull request a screen, each opened in the browser and put on the clipboard as its screen appears: the test drive, then the setup pull request.
|
|
242
242
|
const hand = { open: openBrowser, copy: copyText };
|
|
243
243
|
if (testPr && setupPr)
|
|
244
244
|
await showPullRequest(testPr.url, hand);
|
|
245
245
|
await showOutro({
|
|
246
|
-
outcome:
|
|
247
|
-
title:
|
|
246
|
+
outcome: end.outcome,
|
|
247
|
+
title: end.title,
|
|
248
248
|
result: [resultLine({ ...report, setupPr }), apps.line].filter(Boolean).join(' '),
|
|
249
249
|
link: (setupPr ?? testPr)?.url,
|
|
250
250
|
links: [
|
|
@@ -253,16 +253,16 @@ export async function main(argv) {
|
|
|
253
253
|
...(setupPr ? [{ label: testPr ? 'Setup pull request, merge this' : 'Setup pull request', url: setupPr.url }] : []),
|
|
254
254
|
{ label: 'Web page', url: page },
|
|
255
255
|
],
|
|
256
|
-
todo:
|
|
256
|
+
todo: end.todo,
|
|
257
257
|
warnings: [...removed, ...(saved && !saved.ok ? [`The web page missed the last update (${saved.error}), so it may lag behind this.`] : [])],
|
|
258
258
|
}, hand);
|
|
259
|
-
// The web page has the report; when it missed it, or
|
|
260
|
-
if (!saved?.ok ||
|
|
259
|
+
// 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.
|
|
260
|
+
if (!saved?.ok || end.code)
|
|
261
261
|
say(`\n${plainReport(report.markdown)}`);
|
|
262
262
|
if (!saved?.ok)
|
|
263
263
|
reporter.step({ event: 'setup_command_stopped', outcome: 'update_lost' });
|
|
264
264
|
await reporter.done();
|
|
265
|
-
process.exit(
|
|
265
|
+
process.exit(end.code);
|
|
266
266
|
}
|
|
267
267
|
const waiting = liveSync.payload.phase === 'waiting' ? liveSync.payload.question?.prompt : undefined;
|
|
268
268
|
const why = pageStatus(fatal ? `Impeccable stopped: ${fatal}` : waiting ? `Impeccable stopped while waiting for an answer: ${waiting}` : 'Impeccable stopped before the setup finished.');
|
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 {
|
|
@@ -5,7 +5,7 @@ Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_pr
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
7
7
|
1. **Prove the newest preview** with impeccable_prove_preview: the branch's alias (`alias`) of the newest listed preview whose state is success (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. The alias is the address the pattern saves, so it is the one proven; a preview listed without one is proven on its own address (`url`). Done when it answers `proven: true`; when an address no longer answers, try the next newest, three at most.
|
|
8
|
-
2. **Save the method** with impeccable_write to `preview`: `{"previewUrlPattern": "<the proof's pattern>", "intendedMethod": "preview-deploy"}
|
|
8
|
+
2. **Save the method** with impeccable_write to `preview`: `{"previewUrlPattern": "<the proof's pattern>", "intendedMethod": "preview-deploy"}`, with the app's `appKey` in a monorepo, since each app's project has a pattern of its own. The proof stands in for the person's answer, so ask nothing. Done when the answer's `keys` show both stored or unchanged.
|
|
9
9
|
|
|
10
10
|
### Reference
|
|
11
11
|
|
|
@@ -2,10 +2,12 @@
|
|
|
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.
|
|
8
|
-
2. **Save the method** with impeccable_write to `preview`: `{"previewUrlPattern": "<the proof's pattern>", "intendedMethod": "preview-deploy"}
|
|
10
|
+
2. **Save the method** with impeccable_write to `preview`: `{"previewUrlPattern": "<the proof's pattern>", "intendedMethod": "preview-deploy"}`, with the app's `appKey` in a monorepo, since each app's site has a pattern of its own. The proof stands in for the person's answer, so ask nothing. Done when the answer's `keys` show both stored or unchanged.
|
|
9
11
|
|
|
10
12
|
### Reference
|
|
11
13
|
|
|
@@ -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
|
|
|
@@ -4,7 +4,7 @@ impeccable_prove_preview answered a `gate`: the host's own lock stands in front
|
|
|
4
4
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
7
|
-
1. **Get the key in place** (the Preview method task): impeccable_vercel_bypass for Vercel's login, under the status line *"Getting past Vercel's login"*; impeccable_netlify_password for Netlify's lock, under *"Getting past Netlify's password"*; impeccable_cloudflare_access for Cloudflare Access, under *"Getting past Cloudflare Access"*. The tool does the rest: it finds the project's secret labelled "Impeccable reviews", or reads what Netlify's settings say stands in front of the site; shows one screen asking to create the secret, to store the site password Netlify gives back, to make a site behind Netlify's team login public (signing the Netlify CLI in, in the browser, on that yes where it is signed out; where Netlify refuses, it opens Netlify's Visitor access page and asks the person to try again once they changed it there), or to create a service token (then Cloudflare's sign-in in the browser where cf here lacks it, and it creates the token as the person and allows it in the Access application's policy; a screen takes a Cloudflare API token only where that sign-in cannot), or, when it cannot make or read one, one screen taking a pasted one (Vercel's with the page where it is made opened); opens the preview with it; and saves it for reviews. The key never comes back to you. Done when it answered `status`:
|
|
7
|
+
1. **Get the key in place** (the Preview method task): impeccable_vercel_bypass for Vercel's login, under the status line *"Getting past Vercel's login"*; impeccable_netlify_password for Netlify's lock, under *"Getting past Netlify's password"*; impeccable_cloudflare_access for Cloudflare Access, under *"Getting past Cloudflare Access"*. The tool does the rest: it finds Vercel's project with or without a `.vercel/` link (no `vercel link` is needed, and the tool signs the Vercel CLI in where it is signed out), then the project's secret labelled "Impeccable reviews", or reads what Netlify's settings say stands in front of the site; shows one screen asking to create the secret, to store the site password Netlify gives back, to make a site behind Netlify's team login public (signing the Netlify CLI in, in the browser, on that yes where it is signed out; where Netlify refuses, it opens Netlify's Visitor access page and asks the person to try again once they changed it there), or to create a service token (then Cloudflare's sign-in in the browser where cf here lacks it, and it creates the token as the person and allows it in the Access application's policy; a screen takes a Cloudflare API token only where that sign-in cannot), or, when it cannot make or read one, one screen taking a pasted one (Vercel's with the page where it is made opened); opens the preview with it; and saves it for reviews. The key never comes back to you. Done when it answered `status`:
|
|
8
8
|
- `saved`: the preview is proven (`proven: true`); `secret`, `password` or `token` says whether it was created, existing or typed, and `project`, `site` or `application` which one holds or allows it; Cloudflare's `pattern` is the address pattern to save with the method, and `apiToken` says a Cloudflare API token the person made created the token. Step 2.
|
|
9
9
|
- `opened` (Netlify): the site is public now and the preview proven (`proven: true`); `madePublic` says whether the run or the person made it public, and `site` which site. Step 2.
|
|
10
10
|
- `login` (Netlify): Netlify kept its team login in front through the retries; `reason` says so. Step 3.
|
|
@@ -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.
|
|
@@ -61,7 +61,7 @@ Keep secrets out of files, settings and messages: the workflow names a repositor
|
|
|
61
61
|
## Voice
|
|
62
62
|
The person reads your [STATUS] lines, task titles and questions, here and on the web page: calm, specific, one line each.
|
|
63
63
|
- **[STATUS] lines**: start each step's message with one, a present-tense verb phrase of at most 70 characters saying what is happening (*"Running the build"*, *"Opening the test drive"*), or a short finding (*"Next.js 16, pnpm, Prisma on SQLite. No hosting found."*). impeccable_progress refuses a line that narrates like an assistant: write *"Reading the deploy workflows"*, not *"I'm trying to work out how the app deploys."* Detail belongs in the report.
|
|
64
|
-
- **Tasks**: after each task, call impeccable_progress with the whole list. Titles are short noun phrases the person recognizes, the same every run where they fit: Read the repository, Apps (on a repository with several apps), Install and build, Preview method, Preview workflow, Sign-in, Test drive, Setup pull request. The list carries only what this run does: add a task when its case starts, never before, and leave off a task no case of this run takes, such as Preview workflow and Setup pull request where a host builds the previews (impeccable_progress refuses them once its previews are the method). Mark a task skipped only when the person skipped it, and failed only when it was tried and failed. One task is in progress at a time, and every status line is the detail of that task (*"Installing with pnpm"* while Install and build is in progress); impeccable_progress refuses a status line about another task.
|
|
64
|
+
- **Tasks**: after each task, call impeccable_progress with the whole list. Titles are short noun phrases the person recognizes, the same every run where they fit: Read the repository, Apps (on a repository with several apps), Install and build, Preview method, Preview workflow, Sign-in, Test drive, Setup pull request. The list carries only what this run does: add a task when its case starts, never before, and leave off a task no case of this run takes, such as Preview workflow and Setup pull request where a host builds the previews (impeccable_progress refuses them once its previews are the method). Mark a task completed only once its result is saved, and a task completed stays so: Preview method once a method is saved, so where the test drive's preview is what proves the method (a host's previews with no pull request yet), it waits as pending while Test drive is in progress and is completed with the save. Mark a task skipped only when the person skipped it, and failed only when it was tried and failed. One task is in progress at a time, and every status line is the detail of that task (*"Installing with pnpm"* while Install and build is in progress); impeccable_progress refuses a status line about another task.
|
|
65
65
|
- **Questions**: one sentence, at most one hint line, and a title that names the subject.
|
|
66
66
|
|
|
67
67
|
## The report
|
package/dist/tools.js
CHANGED
|
@@ -10,14 +10,14 @@ 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';
|
|
17
17
|
import { passCloudflareAccess } from './cloudflare-access.js';
|
|
18
18
|
import { commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
|
|
19
19
|
import { basicCredentials, PASSWORD_HEADER, passNetlifyLock } from './netlify-password.js';
|
|
20
|
-
import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelDashboardScope, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
|
|
20
|
+
import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelDashboardScope, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelRepoProject, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
|
|
21
21
|
import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
|
|
22
22
|
import { fitsTask, stagesOf } from './stages.js';
|
|
23
23
|
import { redact } from './log.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 = {
|
|
@@ -133,13 +136,35 @@ function stageError(status, tasks) {
|
|
|
133
136
|
const task = running[0];
|
|
134
137
|
if (!task || fitsTask(status, task.title))
|
|
135
138
|
return null;
|
|
136
|
-
return `Refused: the status "${status.trim()}" is about ${stagesOf(status).join(' and ')}, but the task in progress is "${task.title}". The status line is the detail of the task in progress: mark the task it is about in progress, or say what "${task.title}" is doing. ${again}`;
|
|
139
|
+
return `Refused: the status "${status.trim()}" is about ${stagesOf(status).join(' and ')}, but the task in progress is "${task.title}". The status line is the detail of the task in progress: mark the task it is about in progress, or say what "${task.title}" is doing. Mark "${task.title}" completed only once its result is saved, never to get past this. ${again}`;
|
|
137
140
|
}
|
|
138
|
-
/** The line under the last screen's heading
|
|
141
|
+
/** 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). */
|
|
139
142
|
export function resultLine(report) {
|
|
140
|
-
const lead = !report.testPr ?
|
|
143
|
+
const lead = !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;
|
|
141
144
|
return report.setupPr || !report.hostedBy ? lead : `${lead} ${nothingToMerge(report.hostedBy)}`;
|
|
142
145
|
}
|
|
146
|
+
/** Why a run opened no test drive, as far as what it saved and met tells. */
|
|
147
|
+
const noTestDrive = ({ methodSaved, lock }) => methodSaved ? 'A preview method is saved, but no test drive opened, so nothing shows Impeccable reviewing yet.'
|
|
148
|
+
: lock ? `The previews are behind ${LOCKS[lock].lock} and no key was saved, so no preview method was saved and no test drive opened.`
|
|
149
|
+
: 'No preview method was saved and no test drive opened, so Impeccable has nothing to review yet.';
|
|
150
|
+
/**
|
|
151
|
+
* How the last screen reads and the command exits (#321): "You're all set up" and 0 only with a test drive and every
|
|
152
|
+
* picked app set up; otherwise a heading that says what is not, the host's step first where its lock stopped the run,
|
|
153
|
+
* and 1, so a script tells a run that set nothing up from one that set everything up.
|
|
154
|
+
*/
|
|
155
|
+
export function ending(report) {
|
|
156
|
+
const apps = appsSummary(report.apps ?? []);
|
|
157
|
+
// On a repository with several apps, each locked app's own line says it.
|
|
158
|
+
const step = !report.apps && !report.methodSaved && report.lock ? [LOCKS[report.lock].left] : [];
|
|
159
|
+
const todo = [...new Set([...step, ...apps.todo, ...report.left])];
|
|
160
|
+
if (report.testPr && !apps.notReady.length)
|
|
161
|
+
return { outcome: 'done', title: "You're all set up", todo, code: 0 };
|
|
162
|
+
const outcome = report.methodSaved ? 'partial' : 'stopped';
|
|
163
|
+
if (apps.notReady.length)
|
|
164
|
+
return { outcome, title: `${apps.notReady.join(' and ')} ${apps.notReady.length === 1 ? 'is' : 'are'} not set up`, todo, code: 1 };
|
|
165
|
+
const title = report.methodSaved ? 'No test drive opened' : `Not set up: ${report.lock ? `the previews are behind ${LOCKS[report.lock].lock}` : 'no preview method was saved'}`;
|
|
166
|
+
return { outcome, title, todo, code: 1 };
|
|
167
|
+
}
|
|
143
168
|
/** Said once, here and in the report, when the host builds the previews and the repository needed no change. */
|
|
144
169
|
export const nothingToMerge = (host) => `Nothing to merge: ${host} hosts your previews, so the repository needed no change.`;
|
|
145
170
|
const PROGRESS = {
|
|
@@ -1265,7 +1290,11 @@ export async function refreshWorkflowBase(owner, repo, defaultBranch, root, depe
|
|
|
1265
1290
|
return fail(dependencies.signal?.aborted ? 'cancelled' : stage);
|
|
1266
1291
|
}
|
|
1267
1292
|
}
|
|
1268
|
-
|
|
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 = {}) {
|
|
1269
1298
|
const CURRENT = '.github/workflows/impeccable-preview.yml';
|
|
1270
1299
|
const RETIRED = '.github/workflows/pristine-preview.yml';
|
|
1271
1300
|
const MAX_BYTES = 1_000_000;
|
|
@@ -1339,6 +1368,13 @@ export function createWorkflowSynchronizer() {
|
|
|
1339
1368
|
|| (file.index !== owned.baselineIndex && !ownIndex))
|
|
1340
1369
|
refuse('The workflow changed since this run wrote it. Keep those edits and resolve them before syncing.');
|
|
1341
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
|
+
}
|
|
1342
1378
|
function offeredPreview(state, repoName) {
|
|
1343
1379
|
const repos = obj(state)?.repos;
|
|
1344
1380
|
const matches = Array.isArray(repos) ? repos.map(obj).filter((repo) => repo?.name === repoName) : [];
|
|
@@ -1347,8 +1383,13 @@ export function createWorkflowSynchronizer() {
|
|
|
1347
1383
|
if (!preview || preview.status === 'not-selected' || preview.status === 'skipped'
|
|
1348
1384
|
|| !standings.includes(standing))
|
|
1349
1385
|
refuse('The selected repository has no verified workflow state. Nothing was changed.');
|
|
1350
|
-
if (standing === 'unknown')
|
|
1351
|
-
|
|
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
|
+
}
|
|
1352
1393
|
return { preview, standing: standing };
|
|
1353
1394
|
}
|
|
1354
1395
|
function offeredBytes(preview) {
|
|
@@ -1378,7 +1419,15 @@ export function createWorkflowSynchronizer() {
|
|
|
1378
1419
|
const topLevel = (await git(root, ['rev-parse', '--show-toplevel'], signal)).toString('utf8').trim();
|
|
1379
1420
|
if (await realpath(topLevel) !== root)
|
|
1380
1421
|
refuse('Run workflow sync from the selected repository root. Nothing was changed.');
|
|
1381
|
-
|
|
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);
|
|
1382
1431
|
const retired = preview.workflowRetiredPath;
|
|
1383
1432
|
if (retired != null && retired !== RETIRED && retired !== CURRENT)
|
|
1384
1433
|
refuse('State named an unsupported retired workflow path. Nothing was changed.');
|
|
@@ -1513,13 +1562,28 @@ export function createTools(ctx) {
|
|
|
1513
1562
|
const publicProduction = new Set();
|
|
1514
1563
|
const cloudflareListed = new Set();
|
|
1515
1564
|
let hostedPreviewProven = false;
|
|
1516
|
-
|
|
1565
|
+
/**
|
|
1566
|
+
* The address pattern each proof answered, by the app it was proven for, and the last one under '' for a repository
|
|
1567
|
+
* of one app. A monorepo's apps may each be a project of their own on a host of their own, so one app's proof never
|
|
1568
|
+
* replaces another's pattern (#332).
|
|
1569
|
+
*/
|
|
1570
|
+
const provenPatterns = new Map();
|
|
1571
|
+
const provePattern = (pattern, appKey) => {
|
|
1572
|
+
if (!pattern)
|
|
1573
|
+
return;
|
|
1574
|
+
provenPatterns.set('', pattern);
|
|
1575
|
+
if (appKey)
|
|
1576
|
+
provenPatterns.set(appKey, pattern);
|
|
1577
|
+
};
|
|
1517
1578
|
/** The host whose preview a proof answered with this app, and the one whose previews impeccable_write then saved as the method. */
|
|
1518
1579
|
let provenHost;
|
|
1519
1580
|
let savedHost;
|
|
1520
1581
|
/** Whether the repository's last saved method is a host's previews, and whether any write named an app: a monorepo's apps each go their own way, and only a run whose host builds them all opens no workflow and no setup pull request. */
|
|
1521
1582
|
let hostMethod = false;
|
|
1522
1583
|
let perApp = false;
|
|
1584
|
+
/** Whether any preview write with a method was taken this run, and the host's lock the last gated proof met: the last screen names it when no preview ever opened (#321), and Preview method is completed only once a method is saved (#322). */
|
|
1585
|
+
let methodSaved = false;
|
|
1586
|
+
let lastLock;
|
|
1523
1587
|
/**
|
|
1524
1588
|
* A host's build, waited for on the screen as the task in progress, naming `host`, with the time so far: `read` again
|
|
1525
1589
|
* every so often until `built` says the evidence is final, or the wait runs out. The status line names the host and what it builds.
|
|
@@ -1606,7 +1670,7 @@ export function createTools(ctx) {
|
|
|
1606
1670
|
/** What impeccable_cloudflare_connect answered: asked once, so that answer stands. */
|
|
1607
1671
|
let connectOutcome;
|
|
1608
1672
|
// Keep ownership of files for this one run; never recreate it per call.
|
|
1609
|
-
const workflowSynchronizer = ctx.workflowSynchronizer ?? createWorkflowSynchronizer();
|
|
1673
|
+
const workflowSynchronizer = ctx.workflowSynchronizer ?? createWorkflowSynchronizer({ status: (line) => said(line) });
|
|
1610
1674
|
const workflowRefusal = (reason) => ({ ok: false, reason, actions: [] });
|
|
1611
1675
|
const workflowReady = (access) => access.authenticated === true
|
|
1612
1676
|
&& access.originMatches === true && access.defaultBranch !== null && !access.needsWorkflowScope
|
|
@@ -1686,12 +1750,16 @@ export function createTools(ctx) {
|
|
|
1686
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. */
|
|
1687
1751
|
const liveApps = new Map();
|
|
1688
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();
|
|
1689
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. */
|
|
1690
1756
|
const appMethods = new Map();
|
|
1691
1757
|
const lockMet = new Map();
|
|
1692
1758
|
const lockPending = new Map();
|
|
1693
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). */
|
|
1694
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();
|
|
1695
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. */
|
|
1696
1764
|
const vercelProductions = new Map();
|
|
1697
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. */
|
|
@@ -1834,14 +1902,14 @@ export function createTools(ctx) {
|
|
|
1834
1902
|
}, signal);
|
|
1835
1903
|
}
|
|
1836
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. */
|
|
1837
|
-
async function netlifyConnect(pi, settings, signal, directory) {
|
|
1838
|
-
// The site's settings change on the host: only once the person opened the Netlify setup, as the fence holds for the CLI.
|
|
1839
|
-
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)
|
|
1840
1908
|
return { connected: false, reason: 'The Netlify sign-in comes first: impeccable_ask with the id netlify-setup.' };
|
|
1841
1909
|
const netlify = (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal });
|
|
1842
1910
|
const gh = (ctx.hostRunner ?? commandRunner)('gh', pi.cwd, { signal });
|
|
1843
1911
|
const clone = ctx.hostWait?.clone;
|
|
1844
|
-
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 } : {}) });
|
|
1845
1913
|
netlifySite = result.site?.name ?? netlifySite;
|
|
1846
1914
|
if (result.pullRequestPreviews)
|
|
1847
1915
|
previewFacts.set('netlify', result.pullRequestPreviews);
|
|
@@ -2105,8 +2173,12 @@ export function createTools(ctx) {
|
|
|
2105
2173
|
const proven = provenApps.has(key);
|
|
2106
2174
|
if (!pickedApps?.has(key))
|
|
2107
2175
|
return { key, name, folder, state: 'unpicked' };
|
|
2108
|
-
if (method !== undefined && (method !== 'preview-deploy' || proven))
|
|
2109
|
-
|
|
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
|
+
}
|
|
2110
2182
|
if (gate && !proven)
|
|
2111
2183
|
return { key, name, folder, state: 'locked', left: `${name}: its previews are behind ${LOCKS[gate].lock}, and no key was saved. ${LOCKS[gate].left}.` };
|
|
2112
2184
|
return { key, name, folder, state: 'unset', left: `${name}: no preview method was saved; the report says why.` };
|
|
@@ -2117,7 +2189,7 @@ export function createTools(ctx) {
|
|
|
2117
2189
|
const where = `${a.name} (${a.folder || '/'})`;
|
|
2118
2190
|
const lock = lockMet.has(a.key) ? LOCKS[lockMet.get(a.key)].lock : undefined;
|
|
2119
2191
|
if (a.state === 'ready')
|
|
2120
|
-
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." : ''}`;
|
|
2121
2193
|
if (a.state === 'locked')
|
|
2122
2194
|
return `- ${where}: not set up. Its previews are behind ${lock}, and no key was saved for it.`;
|
|
2123
2195
|
if (a.state === 'unset')
|
|
@@ -2265,8 +2337,8 @@ export function createTools(ctx) {
|
|
|
2265
2337
|
return `Refused: prove a preview address with impeccable_prove_preview first: the host's previews become the method only once one answered with this app${appsRepo ? `, read for it with its appKey (${String(object(p.body)?.appKey)})` : ''}. Nothing was saved.`;
|
|
2266
2338
|
}
|
|
2267
2339
|
const saved = object(p.body);
|
|
2268
|
-
if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !==
|
|
2269
|
-
return
|
|
2340
|
+
if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !== provenPatterns.get(appsRepo ? String(saved.appKey) : ''))) {
|
|
2341
|
+
return `Refused: the only address pattern saved is the one impeccable_prove_preview answered for ${appsRepo ? `this app's (${String(saved.appKey)})` : 'a'} Netlify or Cloudflare Pages preview. Nothing was saved.`;
|
|
2270
2342
|
}
|
|
2271
2343
|
// The before is the live app's address, and only one a proof opened with this app: an address no key opens is not set, and one the person saved is never cleared from here.
|
|
2272
2344
|
if (target.url.endsWith('/preview') && saved && 'beforeUrl' in saved && (typeof saved.beforeUrl !== 'string' || !URL.canParse(saved.beforeUrl) || !provenLive.has(new URL(saved.beforeUrl).origin))) {
|
|
@@ -2294,7 +2366,20 @@ export function createTools(ctx) {
|
|
|
2294
2366
|
body = { ...object(body), accounts: list.map((account) => ({ ...object(account), password: typed.get(String(object(account)?.password)) })) };
|
|
2295
2367
|
}
|
|
2296
2368
|
const checkedHead = previews.active && target.url.endsWith('/latest-pull-check') ? await observe(signal) : undefined;
|
|
2297
|
-
|
|
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) : '');
|
|
2298
2383
|
if (target.url.endsWith('/apps') && response.ok && object(p.body)?.action === 'confirm')
|
|
2299
2384
|
confirmedApps = true;
|
|
2300
2385
|
if (target.url.endsWith('/preview') && response.ok) {
|
|
@@ -2311,6 +2396,8 @@ export function createTools(ctx) {
|
|
|
2311
2396
|
appMethods.set(String(saved.appKey), saved.intendedMethod);
|
|
2312
2397
|
if (saved && 'intendedMethod' in saved)
|
|
2313
2398
|
hostMethod = saved.intendedMethod === 'preview-deploy';
|
|
2399
|
+
if (typeof saved?.intendedMethod === 'string')
|
|
2400
|
+
methodSaved = true;
|
|
2314
2401
|
for (const key of Object.keys(object(p.body) ?? {}))
|
|
2315
2402
|
if (PREVIEW_FIELDS.has(key) && keys?.[key] === 'stored')
|
|
2316
2403
|
previews.changes.add(key);
|
|
@@ -2343,7 +2430,12 @@ export function createTools(ctx) {
|
|
|
2343
2430
|
const deployed = appsRepo ? pickedApps !== undefined && pickedApps.size > 0 && [...pickedApps].every((key) => vercelDeployed.has(key)) : vercelDeployed.has('');
|
|
2344
2431
|
if (asked.id === 'vercel-setup' && deployed && !hostSignIns.has('vercel')) {
|
|
2345
2432
|
const projects = [...vercelDeployed.values()].filter(Boolean);
|
|
2346
|
-
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), and save the method.`;
|
|
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.`;
|
|
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.`;
|
|
2347
2439
|
}
|
|
2348
2440
|
if (host?.[2] === 'setup')
|
|
2349
2441
|
return JSON.stringify(await hostSignIn(host[1], pi, signal));
|
|
@@ -2387,13 +2479,20 @@ export function createTools(ctx) {
|
|
|
2387
2479
|
typed.set(reference, (q.kind === 'account' ? answer.password : answer.value) ?? '');
|
|
2388
2480
|
return JSON.stringify(q.kind === 'account' ? { username: answer.username ?? '', password: reference } : { value: reference });
|
|
2389
2481
|
}),
|
|
2390
|
-
tool('impeccable_progress', 'Progress', 'Show the person where things stand, here and on the web page: the [STATUS] line and the whole task list. It refuses, saving nothing, a status line out of the playbook\'s voice, more than one task in progress, a status line about another task than the one in progress, and a Preview workflow or Setup pull request task once a host\'s previews are the method.', PROGRESS, async (p) => {
|
|
2482
|
+
tool('impeccable_progress', 'Progress', 'Show the person where things stand, here and on the web page: the [STATUS] line and the whole task list. It refuses, saving nothing, a status line out of the playbook\'s voice, more than one task in progress, a status line about another task than the one in progress, a task once completed that is no longer, Preview method completed before a method is saved, and a Preview workflow or Setup pull request task once a host\'s previews are the method.', PROGRESS, async (p) => {
|
|
2391
2483
|
const status = p.status.trim();
|
|
2392
2484
|
const tasks = withAttempts(p.tasks);
|
|
2393
2485
|
const own = p.tasks.filter((t) => !t.id.startsWith('preview-attempt-'));
|
|
2394
2486
|
const voice = statusVoiceError(status) ?? stageError(status, own);
|
|
2395
2487
|
if (voice)
|
|
2396
2488
|
return voice;
|
|
2489
|
+
// The list only moves forward (#322): a task done stays done, and Preview method is done only once a method is saved.
|
|
2490
|
+
const undone = ctx.sync.payload.tasks.filter((t) => t.status === 'completed' && !t.id.startsWith('preview-attempt-') && own.find((n) => n.id === t.id)?.status !== 'completed');
|
|
2491
|
+
if (undone.length)
|
|
2492
|
+
return `Refused: ${undone.map((t) => `"${t.title}"`).join(' and ')} ${undone.length === 1 ? 'shows' : 'show'} as done, and a task done stays done and on the list, so the count never goes down. Keep ${undone.length === 1 ? 'it' : 'them'} completed, and mark what failed since on the task it belongs to. Nothing was saved: call impeccable_progress again.`;
|
|
2493
|
+
const early = methodSaved ? undefined : own.find((t) => t.status === 'completed' && stagesOf(t.title).includes('Preview method'));
|
|
2494
|
+
if (early)
|
|
2495
|
+
return `Refused: "${early.title}" is done only once a preview method is saved (impeccable_write to preview with its intendedMethod, taken by the setup API; one an earlier run saved is written again as it is), and none is yet. Keep it in progress while its work goes on, pending while another task is, and failed when the run saves none. Nothing was saved: call impeccable_progress again.`;
|
|
2397
2496
|
const unneeded = hostMethod && !perApp && !ctx.sync.payload.setup_pr ? own.filter((t) => /\bsetup pull request\b/i.test(t.title) || stagesOf(t.title).includes('Preview workflow')) : [];
|
|
2398
2497
|
if (unneeded.length)
|
|
2399
2498
|
return `Refused: ${savedHost ?? 'the host'} builds this run's previews, so the repository needs no workflow and no setup pull request. Leave ${unneeded.map((t) => `"${t.title}"`).join(' and ')} off the list: a task the run never does is not shown, not even as skipped. Nothing was saved: call impeccable_progress again.`;
|
|
@@ -2511,7 +2610,7 @@ export function createTools(ctx) {
|
|
|
2511
2610
|
return `Error: the report was refused (${res.status}: ${res.error}). Fix what it says and call impeccable_report again.`;
|
|
2512
2611
|
}
|
|
2513
2612
|
// Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
|
|
2514
|
-
ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}) });
|
|
2613
|
+
ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock } : {}) });
|
|
2515
2614
|
pi.shutdown();
|
|
2516
2615
|
const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
|
|
2517
2616
|
return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
|
|
@@ -2652,6 +2751,13 @@ export function createTools(ctx) {
|
|
|
2652
2751
|
}
|
|
2653
2752
|
if (evidence.pullRequestPreviews)
|
|
2654
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
|
+
}
|
|
2655
2761
|
noteProduction(evidence.production, 'netlify', liveSite(evidence), appKey);
|
|
2656
2762
|
const site = evidence.unmatched ? undefined : liveSite(evidence);
|
|
2657
2763
|
if (site)
|
|
@@ -2756,8 +2862,10 @@ export function createTools(ctx) {
|
|
|
2756
2862
|
lockPending.set(app.appKey, proof.gate);
|
|
2757
2863
|
provenApps.delete(app.appKey);
|
|
2758
2864
|
}
|
|
2759
|
-
if (proof.
|
|
2760
|
-
|
|
2865
|
+
if (proof.gate && Object.hasOwn(LOCKS, proof.gate))
|
|
2866
|
+
lastLock = proof.gate;
|
|
2867
|
+
if (proof.proven)
|
|
2868
|
+
provePattern(proof.pattern, app?.appKey);
|
|
2761
2869
|
if (proof.gate === 'vercel-protection' || proof.gate === 'netlify-protection')
|
|
2762
2870
|
gated = { url, expect: p.expect, gate: proof.gate, ...(proof.teamLogin ? { teamLogin: proof.teamLogin } : {}), ...(app ? { app } : {}) };
|
|
2763
2871
|
if (proof.gate === 'cloudflare-access' && cloudflareListed.has(new URL(url).origin))
|
|
@@ -2781,7 +2889,7 @@ export function createTools(ctx) {
|
|
|
2781
2889
|
const directory = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
|
|
2782
2890
|
if (appKey !== undefined && directory === undefined)
|
|
2783
2891
|
return JSON.stringify({ appKey, connected: false, reason: `The setup state names no folder for the app ${appKey}.` });
|
|
2784
|
-
const result = await netlifyConnect(pi, { command, publish }, signal, directory);
|
|
2892
|
+
const result = await netlifyConnect(pi, { command, publish }, signal, directory, netlifyPreviewing.has(appKey ?? ''));
|
|
2785
2893
|
if (result.how && result.site)
|
|
2786
2894
|
noteApp(appKey, { host_project: result.site.name });
|
|
2787
2895
|
return JSON.stringify(appKey !== undefined ? { appKey, ...result } : result);
|
|
@@ -2901,7 +3009,7 @@ export function createTools(ctx) {
|
|
|
2901
3009
|
noteProduction(answer.productionUrl, 'vercel', undefined, connectedApp);
|
|
2902
3010
|
return JSON.stringify(answer);
|
|
2903
3011
|
}),
|
|
2904
|
-
tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it reads the project this folder is linked to (.vercel/project.json; once this run\'s own vercel link wrote that file, only what it wrote counts) and refuses unless that project is connected to this repository, shows Deployment Protection in its settings (a page that looks like Vercel\'s login is the deployment\'s own to serve, so with none set the sign-in is the app\'s own: prove a page a visitor opens without signing in) and made the preview\'s deployment (among its latest, or read by its address), since the secret goes only to a preview the linked project serves; then it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No secret ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
3012
|
+
tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it reads the project this folder is linked to (.vercel/project.json; once this run\'s own vercel link wrote that file, only what it wrote counts), or, with no such file, the one Vercel lists as connected to this repository, so no vercel link is needed (where the CLI is signed out, the consent screen comes first and its yes signs it in, in the browser), and refuses unless that project is connected to this repository, shows Deployment Protection in its settings (a page that looks like Vercel\'s login is the deployment\'s own to serve, so with none set the sign-in is the app\'s own: prove a page a visitor opens without signing in) and made the preview\'s deployment (among its latest, or read by its address), since the secret goes only to a preview the linked project serves; then it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No secret ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
2905
3013
|
if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
|
|
2906
3014
|
return 'Refused: this is for a preview that Vercel made, listed by impeccable_hosted_previews, which impeccable_prove_preview answered with gate "vercel-protection". Prove one first.';
|
|
2907
3015
|
const at = gated;
|
|
@@ -2914,26 +3022,81 @@ export function createTools(ctx) {
|
|
|
2914
3022
|
if (asked?.status === 'skipped')
|
|
2915
3023
|
return JSON.stringify(asked);
|
|
2916
3024
|
// Whose preview this is: a deployment status anyone with push access records gives any address, and the page there says nothing, so the project has to have
|
|
2917
|
-
// made the deployment before any secret of it goes there. That project is the app's own on a repository with several apps (#281), else the one the link file names
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
3025
|
+
// made the deployment before any secret of it goes there. That project is the app's own on a repository with several apps (#281), else the one the link file names,
|
|
3026
|
+
// else the one Vercel lists as connected to this repository (#310).
|
|
3027
|
+
const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
|
|
3028
|
+
const fileLink = at.app ? null : await readLink(pi.cwd);
|
|
3029
|
+
/** The consent screen's yes, given before Vercel's sign-in, stands for the secret: it is not asked again. */
|
|
3030
|
+
let consented = false;
|
|
3031
|
+
/**
|
|
3032
|
+
* Signed out, the CLI reads nothing, linked or not: the consent screen comes first, and its yes signs the CLI in, in the browser, rather than leaving the
|
|
3033
|
+
* run at a dead end. Null once it is signed in; else what the tool answers.
|
|
3034
|
+
*/
|
|
3035
|
+
const signIn = async () => {
|
|
3036
|
+
/** A skip, of the consent or of the sign-in, stands for the run: it is asked once, as the consent alone is. */
|
|
3037
|
+
const skip = (reason) => {
|
|
3038
|
+
const outcome = { status: 'skipped', reason };
|
|
3039
|
+
bypassOutcomes.set(store, outcome);
|
|
3040
|
+
return JSON.stringify(outcome);
|
|
3041
|
+
};
|
|
3042
|
+
if ((await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped)
|
|
3043
|
+
return skip('The person skipped creating a bypass secret.');
|
|
3044
|
+
consented = true;
|
|
3045
|
+
const agreed = hostSetupAgreed(ctx.log, 'vercel');
|
|
3046
|
+
const had = hostSignIns.get('vercel');
|
|
3047
|
+
hostSignIns.set('vercel', 'confirmed');
|
|
3048
|
+
const signedIn = await hostSignIn('vercel', pi, signal);
|
|
3049
|
+
// The sign-in was for the secret alone: the Vercel setup stays as it was, so the fence neither opens the CLI's setup commands nor holds pushes for a connection nobody makes.
|
|
3050
|
+
if (!agreed) {
|
|
3051
|
+
endHostSetup(ctx.log, 'vercel');
|
|
3052
|
+
teamChoices.delete('vercel');
|
|
3053
|
+
}
|
|
3054
|
+
if (had)
|
|
3055
|
+
hostSignIns.set('vercel', had);
|
|
3056
|
+
else
|
|
3057
|
+
hostSignIns.delete('vercel');
|
|
3058
|
+
if (signedIn.signedIn === true)
|
|
3059
|
+
return null;
|
|
3060
|
+
return skip(typeof signedIn.reason === 'string' ? signedIn.reason : "The person skipped Vercel's sign-in, which the bypass secret needs.");
|
|
3061
|
+
};
|
|
3062
|
+
// 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,
|
|
3063
|
+
// with the CLI signed in as the person.
|
|
3064
|
+
let found;
|
|
3065
|
+
if (!at.app && !fileLink) {
|
|
3066
|
+
const cli = (await vercelStatus(vercel)).cli;
|
|
3067
|
+
const stop = cli === 'signed-out' ? await signIn() : null;
|
|
3068
|
+
if (stop)
|
|
3069
|
+
return stop;
|
|
3070
|
+
const listed = cli === 'missing' ? { reason: 'The Vercel CLI is not installed here.' } : await vercelRepoProject(vercel, repository);
|
|
3071
|
+
if ('reason' in listed)
|
|
3072
|
+
return `Refused: nothing was sent to ${at.url}. This folder is linked to no Vercel project (.vercel/project.json), and Vercel named none for this repository: ${listed.reason} A bypass secret goes only to a preview the repository's project serves.`;
|
|
3073
|
+
found = listed;
|
|
3074
|
+
}
|
|
3075
|
+
const link = at.app?.link ?? fileLink ?? found.link;
|
|
2921
3076
|
// Once this run's own vercel link wrote the file, only that link counts, as impeccable_vercel_connect holds it: rewritten since, it would name another project of the person's. A project the person linked before the run has no such record, and the reads below vouch for it.
|
|
2922
|
-
// An app's project
|
|
3077
|
+
// An app's project, and one found with no link file, are read from Vercel's list of this repository's projects, never from the file.
|
|
2923
3078
|
const links = linkedByRun(ctx.log);
|
|
2924
3079
|
const own = links.get(link.projectId);
|
|
2925
|
-
if (
|
|
3080
|
+
if (fileLink && [...links.values()].some((l) => l.orgId !== undefined) && own?.orgId !== link.orgId)
|
|
2926
3081
|
return `Refused: nothing was sent to ${at.url}. This folder's .vercel/project.json is not what this run's vercel link wrote (another project, or another team), and a bypass secret goes only to a preview the linked project serves.`;
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
3082
|
+
let project = await readOwnProject(vercel, link, repository);
|
|
3083
|
+
// A linked project, or an app's, read nothing because the CLI is signed out: the same sign-in, then the read again.
|
|
3084
|
+
if ('reason' in project && !consented && (await vercelStatus(vercel)).cli === 'signed-out') {
|
|
3085
|
+
const stop = await signIn();
|
|
3086
|
+
if (stop)
|
|
3087
|
+
return stop;
|
|
3088
|
+
project = await readOwnProject(vercel, link, repository);
|
|
3089
|
+
}
|
|
3090
|
+
const which = at.app ? 'The Vercel project that builds this app' : found ? 'The Vercel project connected to this repository' : 'The Vercel project this folder is linked to';
|
|
2930
3091
|
if ('reason' in project)
|
|
2931
3092
|
return `Refused: nothing was sent to ${at.url}. ${project.reason} A bypass secret goes only to a preview the linked project serves.`;
|
|
2932
3093
|
// The page at the address is the deployment's to word, whatever host it is on, so the lock is told from the project's settings, read as the person: with none, no login of Vercel's stood in front.
|
|
3094
|
+
if (!project.previewsProtected && lastLock === 'vercel-protection')
|
|
3095
|
+
lastLock = undefined;
|
|
2933
3096
|
if (!project.previewsProtected)
|
|
2934
3097
|
return `Refused: nothing was sent to ${at.url}. Vercel's settings show no Deployment Protection on the project${project.name ? ` (${project.name})` : ''}, so what the address answered is the deployment's own page and the sign-in is the app's own: prove a page a visitor opens without signing in. A bypass secret goes only to a preview the linked project serves.`;
|
|
2935
3098
|
if (!(await projectMadeDeployment(vercel, link, project, host)))
|
|
2936
|
-
return `Refused: nothing was sent to ${at.url}. ${which}${project.name ? ` (${project.name})` : ''} did not make the deployment at ${host}, and a bypass secret goes only to a preview ${at.app ? 'that project' : 'the linked project'} serves.`;
|
|
3099
|
+
return `Refused: nothing was sent to ${at.url}. ${which}${project.name ? ` (${project.name})` : ''} did not make the deployment at ${host}, and a bypass secret goes only to a preview ${at.app || found ? 'that project' : 'the linked project'} serves.`;
|
|
2937
3100
|
/** The secret opened this address and is saved: proofs of it send that secret, and the app's preview is proven. */
|
|
2938
3101
|
const opened = () => {
|
|
2939
3102
|
bypassOrigins.set(origin, store);
|
|
@@ -2953,9 +3116,9 @@ export function createTools(ctx) {
|
|
|
2953
3116
|
const { value, ...told } = await passVercelLogin({
|
|
2954
3117
|
run: vercel,
|
|
2955
3118
|
link: async () => link,
|
|
2956
|
-
// A project the folder is not linked to is named on the CLI's reads and makes; the reads are held to its id all the same.
|
|
2957
|
-
...(at.app?.name ? { project: at.app.name } : {}),
|
|
2958
|
-
consent: async () => !(await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped,
|
|
3119
|
+
// A project the folder is not linked to is named on the CLI's reads and makes, in its own team; the reads are held to its id all the same.
|
|
3120
|
+
...(at.app?.name ? { project: at.app.name } : found ? { project: found.name, ...(found.scope ? { scope: found.scope } : {}) } : {}),
|
|
3121
|
+
consent: async () => consented || !(await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped,
|
|
2959
3122
|
paste: async (stopped) => {
|
|
2960
3123
|
// This project's own settings page, under its own team (the one its ids name, as the CLI lists the person's teams): never another project of the account's.
|
|
2961
3124
|
(ctx.openBrowser ?? openBrowser)(await vercelPageFor(vercel, link, 'settings/deployment-protection', project.name));
|
|
@@ -3010,7 +3173,7 @@ export function createTools(ctx) {
|
|
|
3010
3173
|
if (stands?.status === 'opened') {
|
|
3011
3174
|
// A site made public stands while the address opens with nothing sent; behind the login again, it is read again.
|
|
3012
3175
|
if ((await prove(undefined)).proven) {
|
|
3013
|
-
|
|
3176
|
+
provePattern(pattern, at.app?.appKey);
|
|
3014
3177
|
if (at.app)
|
|
3015
3178
|
proveApp(at.app.appKey, at.url);
|
|
3016
3179
|
return JSON.stringify(stands);
|
|
@@ -3021,7 +3184,7 @@ export function createTools(ctx) {
|
|
|
3021
3184
|
return JSON.stringify(stands);
|
|
3022
3185
|
// A password in hand already: another deploy preview of the same site opens with it. Another site's lock is read for that site.
|
|
3023
3186
|
if (passwordSaved && sitePassword && passwordSites.has(site) && (await prove(sitePassword)).proven) {
|
|
3024
|
-
|
|
3187
|
+
provePattern(pattern, at.app?.appKey);
|
|
3025
3188
|
if (at.app)
|
|
3026
3189
|
proveApp(at.app.appKey, at.url);
|
|
3027
3190
|
return JSON.stringify(passwordSaved);
|
|
@@ -3064,6 +3227,9 @@ export function createTools(ctx) {
|
|
|
3064
3227
|
remember: noteSensitiveAnswer,
|
|
3065
3228
|
});
|
|
3066
3229
|
const passwordOutcome = { ...told, ...(told.status === 'saved' || told.status === 'opened' ? { proven: true } : {}) };
|
|
3230
|
+
// Netlify's settings show no lock on the site (none, or none left once it was made public): the last screen blames none (#321).
|
|
3231
|
+
if (told.protection === 'none' && lastLock === 'netlify-protection')
|
|
3232
|
+
lastLock = undefined;
|
|
3067
3233
|
if (told.status === 'skipped' || told.status === 'login' || told.status === 'opened')
|
|
3068
3234
|
passwordStands.set(site, passwordOutcome);
|
|
3069
3235
|
if (told.status === 'opened') {
|
|
@@ -3072,7 +3238,7 @@ export function createTools(ctx) {
|
|
|
3072
3238
|
provenHost = 'Netlify';
|
|
3073
3239
|
if (at.app)
|
|
3074
3240
|
proveApp(at.app.appKey, at.url);
|
|
3075
|
-
|
|
3241
|
+
provePattern(pattern, at.app?.appKey);
|
|
3076
3242
|
}
|
|
3077
3243
|
if (value) {
|
|
3078
3244
|
// What is in hand follows the store: a new password is the one saved now, and the site the last one opened no longer stands.
|
|
@@ -3083,7 +3249,7 @@ export function createTools(ctx) {
|
|
|
3083
3249
|
provenHost = 'Netlify';
|
|
3084
3250
|
if (at.app)
|
|
3085
3251
|
proveApp(at.app.appKey, at.url);
|
|
3086
|
-
|
|
3252
|
+
provePattern(pattern, at.app?.appKey);
|
|
3087
3253
|
passwordSaved = passwordOutcome;
|
|
3088
3254
|
}
|
|
3089
3255
|
else if (told.status === 'save-failed' && stored) {
|
|
@@ -3094,7 +3260,7 @@ export function createTools(ctx) {
|
|
|
3094
3260
|
}
|
|
3095
3261
|
return JSON.stringify(passwordOutcome);
|
|
3096
3262
|
}),
|
|
3097
|
-
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) => {
|
|
3098
3264
|
if (!accessGated)
|
|
3099
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.';
|
|
3100
3266
|
const at = accessGated;
|
|
@@ -3141,8 +3307,9 @@ export function createTools(ctx) {
|
|
|
3141
3307
|
const typed = await ask(HOST_SCREENS['cloudflare-access-api']);
|
|
3142
3308
|
return typed.skipped ? undefined : typed.value;
|
|
3143
3309
|
},
|
|
3144
|
-
|
|
3145
|
-
|
|
3310
|
+
reusable: async (application) => !(await ask(cloudflareAccessReusable(application))).skipped,
|
|
3311
|
+
paste: async (manual) => {
|
|
3312
|
+
const typed = await ask(sentTo(cloudflareAccessPaste(manual), at.url));
|
|
3146
3313
|
return typed.skipped ? undefined : { id: typed.username, secret: typed.password };
|
|
3147
3314
|
},
|
|
3148
3315
|
prove,
|
|
@@ -3170,8 +3337,7 @@ export function createTools(ctx) {
|
|
|
3170
3337
|
provenHost = 'Cloudflare Pages';
|
|
3171
3338
|
if (at.app)
|
|
3172
3339
|
proveApp(at.app.appKey, at.url);
|
|
3173
|
-
|
|
3174
|
-
provenPattern = pattern;
|
|
3340
|
+
provePattern(pattern, at.app?.appKey);
|
|
3175
3341
|
}
|
|
3176
3342
|
return JSON.stringify(accessOutcome);
|
|
3177
3343
|
}),
|
package/dist/vercel-bypass.js
CHANGED
|
@@ -18,10 +18,12 @@ export const SECRET_NOTE = 'Impeccable reviews';
|
|
|
18
18
|
export const isBypassSecret = (value) => typeof value === 'string' && /^[A-Za-z0-9]{32}$/.test(value);
|
|
19
19
|
/** The CLI's read of the linked project's protection, or with `name` that project's: `protectionBypass` maps each secret to its note and creation time. */
|
|
20
20
|
export const READ = ['project', 'protection', '--format', 'json', '--non-interactive'];
|
|
21
|
-
|
|
21
|
+
/** A team other than the CLI's current scope, which its own commands name with `--scope`. */
|
|
22
|
+
const inScope = (args, scope) => (scope ? [...args, '--scope', scope] : args);
|
|
23
|
+
const readArgs = (name, scope) => inScope(name ? ['project', 'protection', name, ...READ.slice(2)] : READ, scope);
|
|
22
24
|
/** The CLI's own way to make a secret, which carries no label, for a CLI without `api`; with `name`, on that project. */
|
|
23
25
|
export const MAKE_CLI = ['project', 'protection', 'enable', '--protection-bypass', '--non-interactive'];
|
|
24
|
-
const makeArgs = (name) => (name ? ['project', 'protection', 'enable', name, ...MAKE_CLI.slice(3)] : MAKE_CLI);
|
|
26
|
+
const makeArgs = (name, scope) => inScope(name ? ['project', 'protection', 'enable', name, ...MAKE_CLI.slice(3)] : MAKE_CLI, scope);
|
|
25
27
|
/** `vercel api` (beta) for PATCH /v1/projects/{id}/protection-bypass with `generate.note`, so the secret carries the label; a team's project needs its team on the request. */
|
|
26
28
|
export const makeApi = (link) => [
|
|
27
29
|
'api',
|
|
@@ -47,10 +49,10 @@ export async function readLink(cwd) {
|
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
/** The linked project's name and automation secrets; null when the CLI could not say: signed out, not linked, or the role does not allow it; or when what it read is not the project `link` names. */
|
|
50
|
-
async function readProject(run, link, name) {
|
|
52
|
+
async function readProject(run, link, name, scope) {
|
|
51
53
|
let settings;
|
|
52
54
|
try {
|
|
53
|
-
const printed = await run(readArgs(name));
|
|
55
|
+
const printed = await run(readArgs(name, scope));
|
|
54
56
|
settings = record(JSON.parse(printed.slice(printed.indexOf('{'), printed.lastIndexOf('}') + 1)));
|
|
55
57
|
}
|
|
56
58
|
catch {
|
|
@@ -99,7 +101,7 @@ const REFUSED = /forbidden|not authorized|not_authorized|permission|\b403\b/i;
|
|
|
99
101
|
* command, which the same role check stops. Once made, which kind the project now holds (labelled through `api`, or the
|
|
100
102
|
* CLI's own without a label); else why not, in Vercel's words where it gave any, the role named only where Vercel refused.
|
|
101
103
|
*/
|
|
102
|
-
async function make(run, link, name) {
|
|
104
|
+
async function make(run, link, name, scope) {
|
|
103
105
|
const said = [];
|
|
104
106
|
if (link) {
|
|
105
107
|
try {
|
|
@@ -112,7 +114,7 @@ async function make(run, link, name) {
|
|
|
112
114
|
}
|
|
113
115
|
}
|
|
114
116
|
try {
|
|
115
|
-
await run(makeArgs(name));
|
|
117
|
+
await run(makeArgs(name, scope));
|
|
116
118
|
return { made: 'unlabelled' };
|
|
117
119
|
}
|
|
118
120
|
catch (err) {
|
|
@@ -143,7 +145,7 @@ async function admitted(secret, deps) {
|
|
|
143
145
|
*/
|
|
144
146
|
export async function passVercelLogin(deps) {
|
|
145
147
|
const link = await deps.link();
|
|
146
|
-
const project = await readProject(deps.run, link, deps.project);
|
|
148
|
+
const project = await readProject(deps.run, link, deps.project, deps.scope);
|
|
147
149
|
const from = project?.name ? { project: project.name } : {};
|
|
148
150
|
const wait = deps.wait ?? { totalMs: 30_000, everyMs: 3_000 };
|
|
149
151
|
const finish = async (secret, how) => {
|
|
@@ -174,7 +176,7 @@ export async function passVercelLogin(deps) {
|
|
|
174
176
|
if (!(await deps.consent()))
|
|
175
177
|
return { status: 'skipped', reason: 'The person skipped creating a bypass secret.', ...from };
|
|
176
178
|
const before = new Set(project.secrets.map((s) => s.value));
|
|
177
|
-
const made = await make(deps.run, link, deps.project);
|
|
179
|
+
const made = await make(deps.run, link, deps.project, deps.scope);
|
|
178
180
|
if ('reason' in made)
|
|
179
181
|
stopped = made.reason;
|
|
180
182
|
else {
|
|
@@ -183,7 +185,7 @@ export async function passVercelLogin(deps) {
|
|
|
183
185
|
// Read back by its label, again for a few seconds while the settings do not list it yet.
|
|
184
186
|
let ours;
|
|
185
187
|
for (const end = Date.now() + wait.totalMs;;) {
|
|
186
|
-
ours = pick((await readProject(deps.run, link, deps.project))?.secrets ?? [], before);
|
|
188
|
+
ours = pick((await readProject(deps.run, link, deps.project, deps.scope))?.secrets ?? [], before);
|
|
187
189
|
if (ours || Date.now() >= end || deps.signal?.aborted)
|
|
188
190
|
break;
|
|
189
191
|
await sleep(wait.everyMs, undefined, { signal: deps.signal });
|
package/dist/vercel.js
CHANGED
|
@@ -167,11 +167,12 @@ const PROJECT_NAME = /^[a-z0-9][a-z0-9._-]{0,99}$/;
|
|
|
167
167
|
/**
|
|
168
168
|
* The Vercel project that builds `directory` of `repository`, on a repository with several apps: of the projects Vercel
|
|
169
169
|
* lists as connected to the repository (GET /v10/projects?repoUrl=), in the team `team` names (the root link's) or the
|
|
170
|
-
* CLI's current scope, the one whose Root Directory is that folder
|
|
171
|
-
*
|
|
170
|
+
* CLI's current scope, the one whose Root Directory is that folder; with no `directory`, the one project connected to
|
|
171
|
+
* the repository, whatever folder it builds. Never the newest, nor the one the root link names: none (`none`), or
|
|
172
|
+
* several, is a `reason`, so one app never borrows another's project.
|
|
172
173
|
*/
|
|
173
174
|
export async function vercelProjectFor(vercel, repository, directory, team) {
|
|
174
|
-
const where = directory ? `the folder ${directory}` : "the repository's root";
|
|
175
|
+
const where = directory === undefined ? 'this repository' : directory ? `the folder ${directory}` : "the repository's root";
|
|
175
176
|
let listed;
|
|
176
177
|
try {
|
|
177
178
|
const query = `repoUrl=${encodeURIComponent(`https://github.com/${repository}`)}&limit=100${team?.startsWith('team_') ? `&teamId=${encodeURIComponent(team)}` : ''}`;
|
|
@@ -185,7 +186,7 @@ export async function vercelProjectFor(vercel, repository, directory, team) {
|
|
|
185
186
|
return { reason: "The Vercel CLI could not list this repository's projects (vercel api): it is signed out, or this CLI has no api command." };
|
|
186
187
|
const builds = listed.map(record).filter((p) => {
|
|
187
188
|
const git = record(p?.link);
|
|
188
|
-
return git?.type === 'github' && `${git.org}/${git.repo}`.toLowerCase() === repository.toLowerCase() && folderOf(p.rootDirectory) === folderOf(directory);
|
|
189
|
+
return git?.type === 'github' && `${git.org}/${git.repo}`.toLowerCase() === repository.toLowerCase() && (directory === undefined || folderOf(p.rootDirectory) === folderOf(directory));
|
|
189
190
|
});
|
|
190
191
|
const found = builds.flatMap((p) => {
|
|
191
192
|
const link = asLink(p.id, p.accountId);
|
|
@@ -194,9 +195,26 @@ export async function vercelProjectFor(vercel, repository, directory, team) {
|
|
|
194
195
|
if (builds.length > 1)
|
|
195
196
|
return { reason: `Several Vercel projects build ${where} (${found.map((p) => p.name).join(', ') || 'unnamed'}), so which one is this app's cannot be told.` };
|
|
196
197
|
if (!found.length)
|
|
197
|
-
return { reason: `No Vercel project of this repository builds ${where} (its Root Directory), as far as the Vercel CLI here can see
|
|
198
|
+
return { reason: directory === undefined ? 'No Vercel project is connected to this repository, as far as the Vercel CLI here can see.' : `No Vercel project of this repository builds ${where} (its Root Directory), as far as the Vercel CLI here can see.`, none: true };
|
|
198
199
|
return found[0];
|
|
199
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* The Vercel project of a repository Vercel deploys from GitHub, for a folder `vercel link` never linked: the link file
|
|
203
|
+
* is gitignored, so a clone has none (#310). The one project connected to the repository in the CLI's current scope,
|
|
204
|
+
* else in another of the person's teams, with that team's slug (`scope`) for the CLI's own commands, which take the
|
|
205
|
+
* current scope otherwise.
|
|
206
|
+
*/
|
|
207
|
+
export async function vercelRepoProject(vercel, repository) {
|
|
208
|
+
const here = await vercelProjectFor(vercel, repository);
|
|
209
|
+
if (!('none' in here))
|
|
210
|
+
return here;
|
|
211
|
+
for (const team of (await vercelTeams(vercel)).filter((t) => !t.current && t.id)) {
|
|
212
|
+
const there = await vercelProjectFor(vercel, repository, undefined, team.id);
|
|
213
|
+
if (!('reason' in there))
|
|
214
|
+
return { ...there, scope: team.value };
|
|
215
|
+
}
|
|
216
|
+
return here;
|
|
217
|
+
}
|
|
200
218
|
/**
|
|
201
219
|
* Whether the project made the deployment at `host`: one of its latest, as the project read lists them; else, since a
|
|
202
220
|
* project lists its newest deployments alone while an older pull request's preview stays served, the deployment read
|