@impeccable/reviews 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/fence.js +2 -1
- package/dist/main.js +9 -9
- package/dist/netlify.js +58 -11
- package/dist/playbook/cases/cloudflare-previews.md +1 -1
- package/dist/playbook/cases/monorepo.md +1 -1
- package/dist/playbook/cases/netlify-previews.md +1 -1
- package/dist/playbook/cases/netlify-setup.md +2 -1
- package/dist/playbook/cases/preview-protection.md +1 -1
- package/dist/playbook.js +1 -1
- package/dist/tools.js +153 -36
- 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
|
|
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/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
|
@@ -123,6 +123,24 @@ async function fromGitHub(owner, repo, gh, only) {
|
|
|
123
123
|
}
|
|
124
124
|
return { previews: found, heads: new Map(heads.map((head) => [head.number, head.sha])), failed };
|
|
125
125
|
}
|
|
126
|
+
/** A page of the person's site list, and how many pages are read at most: what `netlify sites:list` reads. */
|
|
127
|
+
const SITES_PER_PAGE = 100;
|
|
128
|
+
const SITE_PAGES = 10;
|
|
129
|
+
/**
|
|
130
|
+
* The person's Netlify sites, as `netlify sites:list --json` lists them, read through `netlify api listSites`: in a
|
|
131
|
+
* checkout with several workspace packages the CLI asks which one to work in before sites:list, and without a terminal
|
|
132
|
+
* exits on that question, while its api command never asks (#319).
|
|
133
|
+
*/
|
|
134
|
+
async function netlifySites(netlify) {
|
|
135
|
+
const sites = [];
|
|
136
|
+
for (let page = 1; page <= SITE_PAGES; page++) {
|
|
137
|
+
const answer = JSON.parse(await netlify(['api', 'listSites', '--data', JSON.stringify({ filter: 'all', page, per_page: SITES_PER_PAGE })]));
|
|
138
|
+
sites.push(...list(answer));
|
|
139
|
+
if (!Array.isArray(answer) || answer.length < SITES_PER_PAGE)
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
return sites;
|
|
143
|
+
}
|
|
126
144
|
/** What the Netlify CLI on this machine knows, when it is signed in: the sites built from this repository, and their deploy previews. */
|
|
127
145
|
async function fromCli(owner, repo, netlify, only, directory) {
|
|
128
146
|
const { cli } = await netlifyStatus(netlify);
|
|
@@ -135,7 +153,7 @@ async function fromCli(owner, repo, netlify, only, directory) {
|
|
|
135
153
|
const facts = [];
|
|
136
154
|
let failed = false;
|
|
137
155
|
try {
|
|
138
|
-
for (const site of
|
|
156
|
+
for (const site of await netlifySites(netlify)) {
|
|
139
157
|
const path = record(site.build_settings)?.repo_path;
|
|
140
158
|
// A site is this repository's only when it is built from it: the folder's own link to a site says nothing of that.
|
|
141
159
|
if (typeof site.name !== 'string' || typeof site.id !== 'string' || !new RegExp(`^${SITE}$`).test(site.name) || typeof path !== 'string' || path.toLowerCase() !== slug)
|
|
@@ -286,25 +304,54 @@ export async function waitForNetlifyAccess(siteId, deps) {
|
|
|
286
304
|
* repository's GitHub account, which Netlify names on every site of the person's that the app already builds from that
|
|
287
305
|
* account. GitHub sends the app each push and pull request, so the pull requests build deploy previews. Then the first
|
|
288
306
|
* build says whether the app reads the repository. Only a site this run's own sites:create made takes the connection,
|
|
289
|
-
* and a site built from another repository keeps it: the person's sites are never pointed elsewhere.
|
|
307
|
+
* and a site built from another repository keeps it: the person's sites are never pointed elsewhere. A folder linked to
|
|
308
|
+
* no site stands for the one site built from this repository (from `directory`, for one app), which is connected already.
|
|
290
309
|
*/
|
|
291
310
|
export async function connectNetlifySite(owner, repo, cwd, deps) {
|
|
292
311
|
const not = (reason, more = {}) => ({ connected: false, reason, ...more });
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
312
|
+
const slug = `${owner}/${repo}`.toLowerCase();
|
|
313
|
+
const ours = (site) => { const path = record(site?.build_settings)?.repo_path; return typeof path === 'string' && path.toLowerCase() === slug; };
|
|
314
|
+
let siteId = linkedNetlifySite(cwd);
|
|
296
315
|
let site;
|
|
297
|
-
|
|
298
|
-
|
|
316
|
+
if (siteId) {
|
|
317
|
+
try {
|
|
318
|
+
site = await api(deps.netlify, 'getSite', { site_id: siteId });
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return not('Netlify did not answer for the linked site.');
|
|
322
|
+
}
|
|
299
323
|
}
|
|
300
|
-
|
|
301
|
-
|
|
324
|
+
// No link (netlify link asks in a terminal when several sites are built from the repository), or a link to the site of another app's folder:
|
|
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
|
+
// repository, so a site found here is never written to.
|
|
327
|
+
const otherApp = site !== undefined && deps.directory !== undefined && ours(site) && folderOf(record(site.build_settings)?.base) !== deps.directory;
|
|
328
|
+
if (!siteId || otherApp) {
|
|
329
|
+
const linkedTo = otherApp ? "This folder is linked to the Netlify site of another app's folder" : 'This folder is linked to no Netlify site';
|
|
330
|
+
const where = deps.directory !== undefined ? ` from ${deps.directory || "the repository's root"}` : '';
|
|
331
|
+
let built;
|
|
332
|
+
try {
|
|
333
|
+
built = (await netlifySites(deps.netlify)).filter((one) => typeof one.id === 'string' && /^[A-Za-z0-9-]{1,64}$/.test(one.id) && typeof one.name === 'string' && new RegExp(`^${SITE}$`).test(one.name) && ours(one)
|
|
334
|
+
&& (deps.directory === undefined || folderOf(record(one.build_settings)?.base) === deps.directory));
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return not(`${linkedTo}, and the person's Netlify sites could not be read.`);
|
|
338
|
+
}
|
|
339
|
+
if (built.length > 1)
|
|
340
|
+
return not(`${linkedTo}, and several of the person's sites are built from this repository${where} (${built.map((one) => one.name).join(', ')}), so which one to connect cannot be told.`);
|
|
341
|
+
if (!built.length)
|
|
342
|
+
return not(`${linkedTo}, and none of the person's sites is built from this repository${where}: create one first.`);
|
|
343
|
+
siteId = built[0].id;
|
|
344
|
+
try {
|
|
345
|
+
site = await api(deps.netlify, 'getSite', { site_id: siteId });
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return not('Netlify did not answer for the site built from this repository.');
|
|
349
|
+
}
|
|
302
350
|
}
|
|
303
351
|
const name = typeof site?.name === 'string' && new RegExp(`^${SITE}$`).test(site.name) ? site.name : undefined;
|
|
304
352
|
if (!site || !name)
|
|
305
353
|
return not('The linked site could not be read.');
|
|
306
354
|
const linked = { id: siteId, name };
|
|
307
|
-
const slug = `${owner}/${repo}`.toLowerCase();
|
|
308
355
|
const settings = record(site.build_settings);
|
|
309
356
|
let github;
|
|
310
357
|
try {
|
|
@@ -335,7 +382,7 @@ export async function connectNetlifySite(owner, repo, cwd, deps) {
|
|
|
335
382
|
// The app's installation on this GitHub account, as another site of the person's built from it names it.
|
|
336
383
|
let installation;
|
|
337
384
|
try {
|
|
338
|
-
for (const other of
|
|
385
|
+
for (const other of await netlifySites(deps.netlify)) {
|
|
339
386
|
const build = record(other.build_settings);
|
|
340
387
|
const path = typeof build?.repo_path === 'string' ? build.repo_path : '';
|
|
341
388
|
if (build?.provider === 'github' && path.toLowerCase().startsWith(`${owner.toLowerCase()}/`) && positive(build.installation_id)) {
|
|
@@ -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
|
|
|
@@ -8,7 +8,7 @@ The state's repository has an `apps` item: the scan proposed several apps, folde
|
|
|
8
8
|
2. **Ask which apps this run sets up**, on every run, with impeccable_ask: `id: "apps"`, `kind: "multi"`, and the `prompt` *"Which apps should get Impeccable previews?"*. The tool words the question and lists the state's apps (the repository's own, `main`, only where the Worker kept it as a product of its own), each picked as it is on, or every one after this run's confirm. Mark the Apps task in progress first. Every write and host tool for an app is refused until it is answered. Done when it answered `values`, `named` (the command was started for that one app with `--app`, from its card on the dashboard: the tool showed one confirm screen naming that app instead of the list, and the list all the same when the person asked for other apps there), or a skip, whose `on` lists the apps that are on: the ones this run sets up. After `named`, that app is the one this run sets up: every host read, key, proof, method and check is its own, and no other app is mentioned past that screen.
|
|
9
9
|
3. **Write the picks**, one action a call as the item's instructions give them: `enable` each chosen app that is off, then `disable` each app that is on and was not chosen. The last app that is on cannot be switched off (409), which is why the enables come first. **After `named`**, `enable` that app when it is off, and switch no other off. **After Skip**, write nothing more: the apps that are on stay on. Done when the last answer's `apps` show `enabled` true for every chosen app and false for the rest (after `named`, true for that app), or, after Skip, nothing was written.
|
|
10
10
|
4. **Read the state again** with impeccable_state. Done when the preview item's `apps` names each chosen app's `directory`, `build` and `method` (none yet for an app not set up), and `perApp` its status.
|
|
11
|
-
5. **Set each chosen app up**, one after the other, as the playbook's steps 3 and 4 say for that app alone, `appKey` on every write and check (`preview`, `test-accounts`, `latest-pull-check`, `connection-check`): its method by the case that applies to it (server-previews when its pages need a server, static-site when they do not, a host's case when the host deploys that app as a project of its own, proven on that app's own address: impeccable_hosted_previews, impeccable_netlify_previews and impeccable_cloudflare_previews take its `appKey` and list only the previews of the project or site that builds its folder, so another app's address, project or key never stands in for it; their `unmatched` says why none can be told, and that app goes as server-previews or static-site says, with the reason under "What is not verified"), and its own sign-in when the scan item's `accessByApp` says it has one, each account question's title naming the app (*"Order desk admin account"*). **The lock in front of each app's previews is that app's own**: when its proof answers a `gate`, preview-protection's steps run for that app, with its `appKey`, before any other app's step (the tools refuse the next app's reads and writes until its key tool was called), and the key goes to that app's own project or site; a key saved for one app says nothing about another's, and the state's `keySaved` speaks for one app at most. Done when every chosen app's method is saved, or its preview skipped as server-previews says, and its sign-in saved or skipped, and no other app got a write, a build or a question.
|
|
11
|
+
5. **Set each chosen app up**, one after the other, as the playbook's steps 3 and 4 say for that app alone, `appKey` on every write and check (`preview`, `test-accounts`, `latest-pull-check`, `connection-check`): its method by the case that applies to it (server-previews when its pages need a server, static-site when they do not, a host's case when the host deploys that app as a project of its own, proven on that app's own address: impeccable_hosted_previews, impeccable_netlify_previews and impeccable_cloudflare_previews take its `appKey` and list only the previews of the project or site that builds its folder (impeccable_netlify_connect takes it too, and connects that site), so another app's address, project or key never stands in for it; their `unmatched` says why none can be told, and that app goes as server-previews or static-site says, with the reason under "What is not verified"), and its own sign-in when the scan item's `accessByApp` says it has one, each account question's title naming the app (*"Order desk admin account"*). **The lock in front of each app's previews is that app's own**: when its proof answers a `gate`, preview-protection's steps run for that app, with its `appKey`, before any other app's step (the tools refuse the next app's reads and writes until its key tool was called), and the key goes to that app's own project or site; a key saved for one app says nothing about another's, and the state's `keySaved` speaks for one app at most. Done when every chosen app's method is saved, or its preview skipped as server-previews says, and its sign-in saved or skipped, and no other app got a write, a build or a question.
|
|
12
12
|
6. **The pull requests** as pull-requests says, once for the repository: the setup commit takes the state's one workflow `file` (a job for each chosen static site whose preview Impeccable builds) once every chosen app's method and build settings are saved, or its preview skipped as server-previews says; the test drive adds its page to every chosen app with a preview (an app whose preview was skipped gets none, and goes under "What was skipped, and why"), in that app's own framework and linked from that app's own public page, so one review renders each of them on its own preview; that case's step 3 proves each chosen app with the input that proved it; its step 4 proves the test drive's page on each chosen app's own preview, with that app's key where its lock stands in front; its step 6 checks each chosen app with a preview, with its `appKey`, one call each. Done when pull-requests' steps are done.
|
|
13
13
|
- **A chosen app whose preview was skipped** (server-previews): it stays on, with no method, no job in the workflow and no test drive page. It holds nothing back: the other apps' setup commit, test drive and checks go on as above, and its checkup entry left in `next` counts as done for the playbook's step 5. The report says, on its line and under "What was skipped, and why", that it gets no preview until its host deploys them.
|
|
14
14
|
- **A host previews some chosen apps and Impeccable builds the others**: a host's case speaks for its own app. The setup commit carries the workflow for the others and the test drive branches from it, and pull-requests' step 4 goes each app's way: the host's app proven on the host's build, the others by the workflow. The repository goes without a workflow and a setup pull request only where a host previews every chosen app.
|
|
@@ -5,7 +5,7 @@ Netlify deploys previews of the pull requests: impeccable_netlify_previews lists
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
7
7
|
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"}
|
|
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 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
9
|
|
|
10
10
|
### Reference
|
|
11
11
|
|
|
@@ -9,7 +9,8 @@ The checkout is set up for Netlify: a `netlify.toml`, a `.netlify/` folder, or a
|
|
|
9
9
|
- **`teams` in the answer** (the person has several Netlify teams): ask once which owns the site, `kind: "single"`, `id: "netlify-team"`, `options` the teams as given (the tool words the rest), and pass the pick to `sites:create` as `--account-slug`.
|
|
10
10
|
- **Skip**: set the preview up as server-previews or static-site says, and say under "What was skipped, and why" that Netlify was found and the sign-in was skipped. The steps below do not apply.
|
|
11
11
|
2. **Link or create the site** (run each Netlify command plain, on its own: a command chained after `sites:create` leaves the site unrecorded): `netlify link --git-remote-url <the GitHub origin>` links a site Netlify already builds from this repository; when it finds none, `netlify sites:create --name <name>` creates one in the person's team and links this folder. Name it after the repository: lowercase letters, digits and hyphens, at most 40 characters; a name Netlify says is taken gets `-2`. Done when `netlify status` names the linked project.
|
|
12
|
-
|
|
12
|
+
- **A site already built from this repository** (impeccable_netlify_previews named it in `sites`; on a repository with several apps, read with the app's `appKey`): link nothing, since `netlify link` asks in a terminal this run does not have when several sites are built from the repository. Step 3 finds that site by itself.
|
|
13
|
+
3. **Connect the repository** with impeccable_netlify_connect (with the app's `appKey` on a repository with several apps): it connects the linked site to this repository through Netlify's GitHub app, so pull requests build deploy previews, when that site is the one this run created or one built from this repository already (a site built from another repository, or one the run did not create, is left as it is, with the reason), and asks the person for the app's access when Netlify cannot read the repository. Give `command` and `publish` only where no `netlify.toml` sets the build. Done when it answered `connected: true`; a site `netlify link` found is connected already, and it says so. **Deploy Previews off** on that site (`pullRequestPreviews` in its answer, or in impeccable_netlify_previews', `enabled: false`): the aim holds, so ask once with impeccable_enable_previews (`host: "netlify"`) before step 4, and never a second time. `enabled`: step 4. `skipped` or `refused`: the setup ends here, as "A step fails before the test drive is pushed" says, with the `reason` and the `page` in the report, and `left` empty, since the person was asked in this run.
|
|
13
14
|
4. **Build and push the test drive** (the Test drive task) as pull-requests' steps 2 to 4 say, with the changes netlify-previews' reference gives: from the default branch, no setup pull request, built once with the build Netlify runs. Stage the new page and the page that links to it by name: `netlify sites:create` writes `.netlify/` and may add it to `.gitignore`, and that change stays out of the pull request. Done when `git push -u origin impeccable/test-drive` succeeded.
|
|
14
15
|
5. **Its draft, its preview proven and saved, then marked ready** as pull-requests' step 4 (Netlify) and step 5 say: the draft, whose deploy preview is the site's first, the wait for it by `pull`, the test drive's page proven on it, impeccable_write to `preview` as netlify-previews' step 2 says, `gh pr ready`, then the recording. The playbook's step 6 then has no pull request left to open: go on with the playbook's step 4. Done when impeccable_test_pr answered "Recorded".
|
|
15
16
|
|
|
@@ -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.
|
package/dist/playbook.js
CHANGED
|
@@ -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
|
@@ -17,7 +17,7 @@ import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enab
|
|
|
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';
|
|
@@ -133,13 +133,35 @@ function stageError(status, tasks) {
|
|
|
133
133
|
const task = running[0];
|
|
134
134
|
if (!task || fitsTask(status, task.title))
|
|
135
135
|
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}`;
|
|
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. Mark "${task.title}" completed only once its result is saved, never to get past this. ${again}`;
|
|
137
137
|
}
|
|
138
|
-
/** The line under the last screen's heading
|
|
138
|
+
/** 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
139
|
export function resultLine(report) {
|
|
140
|
-
const lead = !report.testPr ?
|
|
140
|
+
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
141
|
return report.setupPr || !report.hostedBy ? lead : `${lead} ${nothingToMerge(report.hostedBy)}`;
|
|
142
142
|
}
|
|
143
|
+
/** Why a run opened no test drive, as far as what it saved and met tells. */
|
|
144
|
+
const noTestDrive = ({ methodSaved, lock }) => methodSaved ? 'A preview method is saved, but no test drive opened, so nothing shows Impeccable reviewing yet.'
|
|
145
|
+
: lock ? `The previews are behind ${LOCKS[lock].lock} and no key was saved, so no preview method was saved and no test drive opened.`
|
|
146
|
+
: 'No preview method was saved and no test drive opened, so Impeccable has nothing to review yet.';
|
|
147
|
+
/**
|
|
148
|
+
* How the last screen reads and the command exits (#321): "You're all set up" and 0 only with a test drive and every
|
|
149
|
+
* picked app set up; otherwise a heading that says what is not, the host's step first where its lock stopped the run,
|
|
150
|
+
* and 1, so a script tells a run that set nothing up from one that set everything up.
|
|
151
|
+
*/
|
|
152
|
+
export function ending(report) {
|
|
153
|
+
const apps = appsSummary(report.apps ?? []);
|
|
154
|
+
// On a repository with several apps, each locked app's own line says it.
|
|
155
|
+
const step = !report.apps && !report.methodSaved && report.lock ? [LOCKS[report.lock].left] : [];
|
|
156
|
+
const todo = [...new Set([...step, ...apps.todo, ...report.left])];
|
|
157
|
+
if (report.testPr && !apps.notReady.length)
|
|
158
|
+
return { outcome: 'done', title: "You're all set up", todo, code: 0 };
|
|
159
|
+
const outcome = report.methodSaved ? 'partial' : 'stopped';
|
|
160
|
+
if (apps.notReady.length)
|
|
161
|
+
return { outcome, title: `${apps.notReady.join(' and ')} ${apps.notReady.length === 1 ? 'is' : 'are'} not set up`, todo, code: 1 };
|
|
162
|
+
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'}`;
|
|
163
|
+
return { outcome, title, todo, code: 1 };
|
|
164
|
+
}
|
|
143
165
|
/** Said once, here and in the report, when the host builds the previews and the repository needed no change. */
|
|
144
166
|
export const nothingToMerge = (host) => `Nothing to merge: ${host} hosts your previews, so the repository needed no change.`;
|
|
145
167
|
const PROGRESS = {
|
|
@@ -293,6 +315,7 @@ const NETLIFY_CONNECT = {
|
|
|
293
315
|
type: 'object',
|
|
294
316
|
additionalProperties: false,
|
|
295
317
|
properties: {
|
|
318
|
+
appKey: APP_KEY,
|
|
296
319
|
command: { type: 'string', minLength: 1, description: "The build command Netlify runs, only where no netlify.toml sets one: the package's build script." },
|
|
297
320
|
publish: { type: 'string', minLength: 1, description: 'The folder the build writes the pages to, relative to the checkout, only where no netlify.toml sets one.' },
|
|
298
321
|
},
|
|
@@ -1512,13 +1535,28 @@ export function createTools(ctx) {
|
|
|
1512
1535
|
const publicProduction = new Set();
|
|
1513
1536
|
const cloudflareListed = new Set();
|
|
1514
1537
|
let hostedPreviewProven = false;
|
|
1515
|
-
|
|
1538
|
+
/**
|
|
1539
|
+
* The address pattern each proof answered, by the app it was proven for, and the last one under '' for a repository
|
|
1540
|
+
* 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
|
|
1541
|
+
* replaces another's pattern (#332).
|
|
1542
|
+
*/
|
|
1543
|
+
const provenPatterns = new Map();
|
|
1544
|
+
const provePattern = (pattern, appKey) => {
|
|
1545
|
+
if (!pattern)
|
|
1546
|
+
return;
|
|
1547
|
+
provenPatterns.set('', pattern);
|
|
1548
|
+
if (appKey)
|
|
1549
|
+
provenPatterns.set(appKey, pattern);
|
|
1550
|
+
};
|
|
1516
1551
|
/** The host whose preview a proof answered with this app, and the one whose previews impeccable_write then saved as the method. */
|
|
1517
1552
|
let provenHost;
|
|
1518
1553
|
let savedHost;
|
|
1519
1554
|
/** 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. */
|
|
1520
1555
|
let hostMethod = false;
|
|
1521
1556
|
let perApp = false;
|
|
1557
|
+
/** 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). */
|
|
1558
|
+
let methodSaved = false;
|
|
1559
|
+
let lastLock;
|
|
1522
1560
|
/**
|
|
1523
1561
|
* A host's build, waited for on the screen as the task in progress, naming `host`, with the time so far: `read` again
|
|
1524
1562
|
* 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.
|
|
@@ -1833,14 +1871,14 @@ export function createTools(ctx) {
|
|
|
1833
1871
|
}, signal);
|
|
1834
1872
|
}
|
|
1835
1873
|
/** 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. */
|
|
1836
|
-
async function netlifyConnect(pi, settings, signal) {
|
|
1874
|
+
async function netlifyConnect(pi, settings, signal, directory) {
|
|
1837
1875
|
// The site's settings change on the host: only once the person opened the Netlify setup, as the fence holds for the CLI.
|
|
1838
1876
|
if (!hostSetupAgreed(ctx.log, 'netlify'))
|
|
1839
1877
|
return { connected: false, reason: 'The Netlify sign-in comes first: impeccable_ask with the id netlify-setup.' };
|
|
1840
1878
|
const netlify = (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal });
|
|
1841
1879
|
const gh = (ctx.hostRunner ?? commandRunner)('gh', pi.cwd, { signal });
|
|
1842
1880
|
const clone = ctx.hostWait?.clone;
|
|
1843
|
-
const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, linked: linkedByRun(ctx.log), settings, signal, ...(clone ? { wait: clone } : {}) });
|
|
1881
|
+
const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, linked: linkedByRun(ctx.log), settings, signal, ...(clone ? { wait: clone } : {}), ...(directory !== undefined ? { directory } : {}) });
|
|
1844
1882
|
netlifySite = result.site?.name ?? netlifySite;
|
|
1845
1883
|
if (result.pullRequestPreviews)
|
|
1846
1884
|
previewFacts.set('netlify', result.pullRequestPreviews);
|
|
@@ -2264,8 +2302,8 @@ export function createTools(ctx) {
|
|
|
2264
2302
|
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.`;
|
|
2265
2303
|
}
|
|
2266
2304
|
const saved = object(p.body);
|
|
2267
|
-
if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !==
|
|
2268
|
-
return
|
|
2305
|
+
if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !== provenPatterns.get(appsRepo ? String(saved.appKey) : ''))) {
|
|
2306
|
+
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.`;
|
|
2269
2307
|
}
|
|
2270
2308
|
// 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.
|
|
2271
2309
|
if (target.url.endsWith('/preview') && saved && 'beforeUrl' in saved && (typeof saved.beforeUrl !== 'string' || !URL.canParse(saved.beforeUrl) || !provenLive.has(new URL(saved.beforeUrl).origin))) {
|
|
@@ -2310,6 +2348,8 @@ export function createTools(ctx) {
|
|
|
2310
2348
|
appMethods.set(String(saved.appKey), saved.intendedMethod);
|
|
2311
2349
|
if (saved && 'intendedMethod' in saved)
|
|
2312
2350
|
hostMethod = saved.intendedMethod === 'preview-deploy';
|
|
2351
|
+
if (typeof saved?.intendedMethod === 'string')
|
|
2352
|
+
methodSaved = true;
|
|
2313
2353
|
for (const key of Object.keys(object(p.body) ?? {}))
|
|
2314
2354
|
if (PREVIEW_FIELDS.has(key) && keys?.[key] === 'stored')
|
|
2315
2355
|
previews.changes.add(key);
|
|
@@ -2342,7 +2382,7 @@ export function createTools(ctx) {
|
|
|
2342
2382
|
const deployed = appsRepo ? pickedApps !== undefined && pickedApps.size > 0 && [...pickedApps].every((key) => vercelDeployed.has(key)) : vercelDeployed.has('');
|
|
2343
2383
|
if (asked.id === 'vercel-setup' && deployed && !hostSignIns.has('vercel')) {
|
|
2344
2384
|
const projects = [...vercelDeployed.values()].filter(Boolean);
|
|
2345
|
-
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.`;
|
|
2385
|
+
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.`;
|
|
2346
2386
|
}
|
|
2347
2387
|
if (host?.[2] === 'setup')
|
|
2348
2388
|
return JSON.stringify(await hostSignIn(host[1], pi, signal));
|
|
@@ -2386,13 +2426,20 @@ export function createTools(ctx) {
|
|
|
2386
2426
|
typed.set(reference, (q.kind === 'account' ? answer.password : answer.value) ?? '');
|
|
2387
2427
|
return JSON.stringify(q.kind === 'account' ? { username: answer.username ?? '', password: reference } : { value: reference });
|
|
2388
2428
|
}),
|
|
2389
|
-
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) => {
|
|
2429
|
+
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) => {
|
|
2390
2430
|
const status = p.status.trim();
|
|
2391
2431
|
const tasks = withAttempts(p.tasks);
|
|
2392
2432
|
const own = p.tasks.filter((t) => !t.id.startsWith('preview-attempt-'));
|
|
2393
2433
|
const voice = statusVoiceError(status) ?? stageError(status, own);
|
|
2394
2434
|
if (voice)
|
|
2395
2435
|
return voice;
|
|
2436
|
+
// The list only moves forward (#322): a task done stays done, and Preview method is done only once a method is saved.
|
|
2437
|
+
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');
|
|
2438
|
+
if (undone.length)
|
|
2439
|
+
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.`;
|
|
2440
|
+
const early = methodSaved ? undefined : own.find((t) => t.status === 'completed' && stagesOf(t.title).includes('Preview method'));
|
|
2441
|
+
if (early)
|
|
2442
|
+
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.`;
|
|
2396
2443
|
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')) : [];
|
|
2397
2444
|
if (unneeded.length)
|
|
2398
2445
|
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.`;
|
|
@@ -2510,7 +2557,7 @@ export function createTools(ctx) {
|
|
|
2510
2557
|
return `Error: the report was refused (${res.status}: ${res.error}). Fix what it says and call impeccable_report again.`;
|
|
2511
2558
|
}
|
|
2512
2559
|
// Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
|
|
2513
|
-
ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}) });
|
|
2560
|
+
ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock } : {}) });
|
|
2514
2561
|
pi.shutdown();
|
|
2515
2562
|
const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
|
|
2516
2563
|
return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
|
|
@@ -2755,8 +2802,10 @@ export function createTools(ctx) {
|
|
|
2755
2802
|
lockPending.set(app.appKey, proof.gate);
|
|
2756
2803
|
provenApps.delete(app.appKey);
|
|
2757
2804
|
}
|
|
2758
|
-
if (proof.
|
|
2759
|
-
|
|
2805
|
+
if (proof.gate && Object.hasOwn(LOCKS, proof.gate))
|
|
2806
|
+
lastLock = proof.gate;
|
|
2807
|
+
if (proof.proven)
|
|
2808
|
+
provePattern(proof.pattern, app?.appKey);
|
|
2760
2809
|
if (proof.gate === 'vercel-protection' || proof.gate === 'netlify-protection')
|
|
2761
2810
|
gated = { url, expect: p.expect, gate: proof.gate, ...(proof.teamLogin ? { teamLogin: proof.teamLogin } : {}), ...(app ? { app } : {}) };
|
|
2762
2811
|
if (proof.gate === 'cloudflare-access' && cloudflareListed.has(new URL(url).origin))
|
|
@@ -2765,14 +2814,25 @@ export function createTools(ctx) {
|
|
|
2765
2814
|
return JSON.stringify({ ...proof, appKey: app.appKey });
|
|
2766
2815
|
return JSON.stringify(proof);
|
|
2767
2816
|
}),
|
|
2768
|
-
tool('impeccable_netlify_connect', 'Connect the repository to Netlify', "Connect the Netlify site this folder is linked to with this repository, through the site's settings and Netlify's GitHub app, so that pull requests build deploy previews: a site this run created with netlify sites:create, or one built from this repository already. A site built from another repository, or not created by this run, is left as it is, with the reason. The run does it; when Netlify's app cannot read the repository, this asks the person once for its access on GitHub and waits. connected true means Netlify read the repository; how \"already\" means the site was built from it before. A connection left undone answers why, and the GitHub page for the person in access.", NETLIFY_CONNECT, async (p, pi, signal) => {
|
|
2817
|
+
tool('impeccable_netlify_connect', 'Connect the repository to Netlify', "Connect the Netlify site this folder is linked to with this repository, through the site's settings and Netlify's GitHub app, so that pull requests build deploy previews: a site this run created with netlify sites:create, or one built from this repository already. With the folder linked to no site, the one site built from this repository already stands for it, with appKey the one whose base directory is that app's folder: no netlify link needed. A site built from another repository, or not created by this run, is left as it is, with the reason. The run does it; when Netlify's app cannot read the repository, this asks the person once for its access on GitHub and waits. connected true means Netlify read the repository; how \"already\" means the site was built from it before. A connection left undone answers why, and the GitHub page for the person in access.", NETLIFY_CONNECT, async (p, pi, signal) => {
|
|
2769
2818
|
const command = p.command;
|
|
2770
2819
|
const publish = p.publish;
|
|
2771
2820
|
if (command !== undefined && (command.length > 200 || /[\r\n\0]/.test(command)))
|
|
2772
2821
|
return 'Refused: the build command is one line of at most 200 characters.';
|
|
2773
2822
|
if (publish !== undefined && publish !== '.' && !staticRelativePath(publish))
|
|
2774
2823
|
return 'Refused: publish is a folder relative to the checkout, such as "dist".';
|
|
2775
|
-
|
|
2824
|
+
const refused = appRefusal(p.appKey, 'connected') ?? lockRefusal(p.appKey, 'connected');
|
|
2825
|
+
if (refused)
|
|
2826
|
+
return refused;
|
|
2827
|
+
// On a repository with several apps, only the site that builds the app's folder (#319).
|
|
2828
|
+
const appKey = appsRepo ? p.appKey : undefined;
|
|
2829
|
+
const directory = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
|
|
2830
|
+
if (appKey !== undefined && directory === undefined)
|
|
2831
|
+
return JSON.stringify({ appKey, connected: false, reason: `The setup state names no folder for the app ${appKey}.` });
|
|
2832
|
+
const result = await netlifyConnect(pi, { command, publish }, signal, directory);
|
|
2833
|
+
if (result.how && result.site)
|
|
2834
|
+
noteApp(appKey, { host_project: result.site.name });
|
|
2835
|
+
return JSON.stringify(appKey !== undefined ? { appKey, ...result } : result);
|
|
2776
2836
|
}),
|
|
2777
2837
|
tool('impeccable_enable_previews', 'Pull-request previews', "Turn on previews of pull requests on the host that builds this repository but not its pull requests, as pullRequestPreviews in the answer of impeccable_netlify_previews, impeccable_netlify_connect or impeccable_cloudflare_previews said (enabled false). It does the rest as the person: one screen asking to turn them on, then the host's CLI changes that one setting (Netlify: Deploy Previews on; Cloudflare Pages: preview branch control set to every non-production branch, or the test drive's branch added to the custom includes) and reads it back; where the CLI cannot (the role, a cf sign-in without pages:write, builds stopped altogether), it opens the host's settings page and asks the person to try again once they changed it there. Answers {\"status\": …}: enabled (pull-request previews are on now; by says whether the run or the person turned them on, and project which project or site), skipped (the person skipped a screen; reason says what came before) or refused (still off after the retries; reason says why, and page where it is changed). Vercel builds every branch once Git is connected, so its fact is reported and never changed here.", ENABLE_PREVIEWS, async (p, pi, signal) => {
|
|
2778
2838
|
const host = p.host;
|
|
@@ -2889,7 +2949,7 @@ export function createTools(ctx) {
|
|
|
2889
2949
|
noteProduction(answer.productionUrl, 'vercel', undefined, connectedApp);
|
|
2890
2950
|
return JSON.stringify(answer);
|
|
2891
2951
|
}),
|
|
2892
|
-
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) => {
|
|
2952
|
+
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) => {
|
|
2893
2953
|
if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
|
|
2894
2954
|
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.';
|
|
2895
2955
|
const at = gated;
|
|
@@ -2902,26 +2962,81 @@ export function createTools(ctx) {
|
|
|
2902
2962
|
if (asked?.status === 'skipped')
|
|
2903
2963
|
return JSON.stringify(asked);
|
|
2904
2964
|
// 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
|
|
2905
|
-
// 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
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2965
|
+
// 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,
|
|
2966
|
+
// else the one Vercel lists as connected to this repository (#310).
|
|
2967
|
+
const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
|
|
2968
|
+
const fileLink = at.app ? null : await readLink(pi.cwd);
|
|
2969
|
+
/** The consent screen's yes, given before Vercel's sign-in, stands for the secret: it is not asked again. */
|
|
2970
|
+
let consented = false;
|
|
2971
|
+
/**
|
|
2972
|
+
* 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
|
|
2973
|
+
* run at a dead end. Null once it is signed in; else what the tool answers.
|
|
2974
|
+
*/
|
|
2975
|
+
const signIn = async () => {
|
|
2976
|
+
/** A skip, of the consent or of the sign-in, stands for the run: it is asked once, as the consent alone is. */
|
|
2977
|
+
const skip = (reason) => {
|
|
2978
|
+
const outcome = { status: 'skipped', reason };
|
|
2979
|
+
bypassOutcomes.set(store, outcome);
|
|
2980
|
+
return JSON.stringify(outcome);
|
|
2981
|
+
};
|
|
2982
|
+
if ((await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped)
|
|
2983
|
+
return skip('The person skipped creating a bypass secret.');
|
|
2984
|
+
consented = true;
|
|
2985
|
+
const agreed = hostSetupAgreed(ctx.log, 'vercel');
|
|
2986
|
+
const had = hostSignIns.get('vercel');
|
|
2987
|
+
hostSignIns.set('vercel', 'confirmed');
|
|
2988
|
+
const signedIn = await hostSignIn('vercel', pi, signal);
|
|
2989
|
+
// 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.
|
|
2990
|
+
if (!agreed) {
|
|
2991
|
+
endHostSetup(ctx.log, 'vercel');
|
|
2992
|
+
teamChoices.delete('vercel');
|
|
2993
|
+
}
|
|
2994
|
+
if (had)
|
|
2995
|
+
hostSignIns.set('vercel', had);
|
|
2996
|
+
else
|
|
2997
|
+
hostSignIns.delete('vercel');
|
|
2998
|
+
if (signedIn.signedIn === true)
|
|
2999
|
+
return null;
|
|
3000
|
+
return skip(typeof signedIn.reason === 'string' ? signedIn.reason : "The person skipped Vercel's sign-in, which the bypass secret needs.");
|
|
3001
|
+
};
|
|
3002
|
+
// 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,
|
|
3003
|
+
// with the CLI signed in as the person.
|
|
3004
|
+
let found;
|
|
3005
|
+
if (!at.app && !fileLink) {
|
|
3006
|
+
const cli = (await vercelStatus(vercel)).cli;
|
|
3007
|
+
const stop = cli === 'signed-out' ? await signIn() : null;
|
|
3008
|
+
if (stop)
|
|
3009
|
+
return stop;
|
|
3010
|
+
const listed = cli === 'missing' ? { reason: 'The Vercel CLI is not installed here.' } : await vercelRepoProject(vercel, repository);
|
|
3011
|
+
if ('reason' in listed)
|
|
3012
|
+
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.`;
|
|
3013
|
+
found = listed;
|
|
3014
|
+
}
|
|
3015
|
+
const link = at.app?.link ?? fileLink ?? found.link;
|
|
2909
3016
|
// 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.
|
|
2910
|
-
// An app's project
|
|
3017
|
+
// 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.
|
|
2911
3018
|
const links = linkedByRun(ctx.log);
|
|
2912
3019
|
const own = links.get(link.projectId);
|
|
2913
|
-
if (
|
|
3020
|
+
if (fileLink && [...links.values()].some((l) => l.orgId !== undefined) && own?.orgId !== link.orgId)
|
|
2914
3021
|
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.`;
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
3022
|
+
let project = await readOwnProject(vercel, link, repository);
|
|
3023
|
+
// A linked project, or an app's, read nothing because the CLI is signed out: the same sign-in, then the read again.
|
|
3024
|
+
if ('reason' in project && !consented && (await vercelStatus(vercel)).cli === 'signed-out') {
|
|
3025
|
+
const stop = await signIn();
|
|
3026
|
+
if (stop)
|
|
3027
|
+
return stop;
|
|
3028
|
+
project = await readOwnProject(vercel, link, repository);
|
|
3029
|
+
}
|
|
3030
|
+
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';
|
|
2918
3031
|
if ('reason' in project)
|
|
2919
3032
|
return `Refused: nothing was sent to ${at.url}. ${project.reason} A bypass secret goes only to a preview the linked project serves.`;
|
|
2920
3033
|
// 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.
|
|
3034
|
+
if (!project.previewsProtected && lastLock === 'vercel-protection')
|
|
3035
|
+
lastLock = undefined;
|
|
2921
3036
|
if (!project.previewsProtected)
|
|
2922
3037
|
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.`;
|
|
2923
3038
|
if (!(await projectMadeDeployment(vercel, link, project, host)))
|
|
2924
|
-
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.`;
|
|
3039
|
+
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.`;
|
|
2925
3040
|
/** The secret opened this address and is saved: proofs of it send that secret, and the app's preview is proven. */
|
|
2926
3041
|
const opened = () => {
|
|
2927
3042
|
bypassOrigins.set(origin, store);
|
|
@@ -2941,9 +3056,9 @@ export function createTools(ctx) {
|
|
|
2941
3056
|
const { value, ...told } = await passVercelLogin({
|
|
2942
3057
|
run: vercel,
|
|
2943
3058
|
link: async () => link,
|
|
2944
|
-
// 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.
|
|
2945
|
-
...(at.app?.name ? { project: at.app.name } : {}),
|
|
2946
|
-
consent: async () => !(await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped,
|
|
3059
|
+
// 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.
|
|
3060
|
+
...(at.app?.name ? { project: at.app.name } : found ? { project: found.name, ...(found.scope ? { scope: found.scope } : {}) } : {}),
|
|
3061
|
+
consent: async () => consented || !(await ask(sentTo(HOST_SCREENS['vercel-bypass'], at.url))).skipped,
|
|
2947
3062
|
paste: async (stopped) => {
|
|
2948
3063
|
// 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.
|
|
2949
3064
|
(ctx.openBrowser ?? openBrowser)(await vercelPageFor(vercel, link, 'settings/deployment-protection', project.name));
|
|
@@ -2998,7 +3113,7 @@ export function createTools(ctx) {
|
|
|
2998
3113
|
if (stands?.status === 'opened') {
|
|
2999
3114
|
// A site made public stands while the address opens with nothing sent; behind the login again, it is read again.
|
|
3000
3115
|
if ((await prove(undefined)).proven) {
|
|
3001
|
-
|
|
3116
|
+
provePattern(pattern, at.app?.appKey);
|
|
3002
3117
|
if (at.app)
|
|
3003
3118
|
proveApp(at.app.appKey, at.url);
|
|
3004
3119
|
return JSON.stringify(stands);
|
|
@@ -3009,7 +3124,7 @@ export function createTools(ctx) {
|
|
|
3009
3124
|
return JSON.stringify(stands);
|
|
3010
3125
|
// A password in hand already: another deploy preview of the same site opens with it. Another site's lock is read for that site.
|
|
3011
3126
|
if (passwordSaved && sitePassword && passwordSites.has(site) && (await prove(sitePassword)).proven) {
|
|
3012
|
-
|
|
3127
|
+
provePattern(pattern, at.app?.appKey);
|
|
3013
3128
|
if (at.app)
|
|
3014
3129
|
proveApp(at.app.appKey, at.url);
|
|
3015
3130
|
return JSON.stringify(passwordSaved);
|
|
@@ -3052,6 +3167,9 @@ export function createTools(ctx) {
|
|
|
3052
3167
|
remember: noteSensitiveAnswer,
|
|
3053
3168
|
});
|
|
3054
3169
|
const passwordOutcome = { ...told, ...(told.status === 'saved' || told.status === 'opened' ? { proven: true } : {}) };
|
|
3170
|
+
// Netlify's settings show no lock on the site (none, or none left once it was made public): the last screen blames none (#321).
|
|
3171
|
+
if (told.protection === 'none' && lastLock === 'netlify-protection')
|
|
3172
|
+
lastLock = undefined;
|
|
3055
3173
|
if (told.status === 'skipped' || told.status === 'login' || told.status === 'opened')
|
|
3056
3174
|
passwordStands.set(site, passwordOutcome);
|
|
3057
3175
|
if (told.status === 'opened') {
|
|
@@ -3060,7 +3178,7 @@ export function createTools(ctx) {
|
|
|
3060
3178
|
provenHost = 'Netlify';
|
|
3061
3179
|
if (at.app)
|
|
3062
3180
|
proveApp(at.app.appKey, at.url);
|
|
3063
|
-
|
|
3181
|
+
provePattern(pattern, at.app?.appKey);
|
|
3064
3182
|
}
|
|
3065
3183
|
if (value) {
|
|
3066
3184
|
// 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.
|
|
@@ -3071,7 +3189,7 @@ export function createTools(ctx) {
|
|
|
3071
3189
|
provenHost = 'Netlify';
|
|
3072
3190
|
if (at.app)
|
|
3073
3191
|
proveApp(at.app.appKey, at.url);
|
|
3074
|
-
|
|
3192
|
+
provePattern(pattern, at.app?.appKey);
|
|
3075
3193
|
passwordSaved = passwordOutcome;
|
|
3076
3194
|
}
|
|
3077
3195
|
else if (told.status === 'save-failed' && stored) {
|
|
@@ -3158,8 +3276,7 @@ export function createTools(ctx) {
|
|
|
3158
3276
|
provenHost = 'Cloudflare Pages';
|
|
3159
3277
|
if (at.app)
|
|
3160
3278
|
proveApp(at.app.appKey, at.url);
|
|
3161
|
-
|
|
3162
|
-
provenPattern = pattern;
|
|
3279
|
+
provePattern(pattern, at.app?.appKey);
|
|
3163
3280
|
}
|
|
3164
3281
|
return JSON.stringify(accessOutcome);
|
|
3165
3282
|
}),
|
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
|