@impeccable/reviews 0.1.5 → 0.1.7
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 +21 -20
- package/dist/agent.js +9 -4
- package/dist/cloudflare-access.js +77 -47
- package/dist/cloudflare.js +325 -23
- package/dist/fence.js +939 -143
- package/dist/focus.js +5 -4
- package/dist/host-previews.js +107 -0
- package/dist/host-questions.js +90 -20
- package/dist/hosted.js +72 -21
- package/dist/http.js +2 -0
- package/dist/log.js +7 -1
- package/dist/login.js +45 -13
- package/dist/main.js +32 -17
- package/dist/netlify-password.js +8 -6
- package/dist/netlify.js +43 -14
- package/dist/pi-paths.js +69 -0
- package/dist/playbook/cases/cloudflare-previews.md +12 -4
- package/dist/playbook/cases/first-preview-failure.md +1 -1
- package/dist/playbook/cases/live-app-address.md +15 -0
- package/dist/playbook/cases/monorepo.md +1 -1
- package/dist/playbook/cases/netlify-previews.md +3 -1
- package/dist/playbook/cases/netlify-setup.md +8 -7
- package/dist/playbook/cases/preview-protection.md +2 -1
- package/dist/playbook/cases/pull-requests.md +4 -4
- package/dist/playbook/cases/static-site.md +2 -2
- package/dist/playbook/cases/vercel-previews.md +2 -0
- package/dist/playbook/cases/vercel-setup.md +9 -6
- package/dist/playbook.js +2 -2
- package/dist/preflight.js +36 -7
- package/dist/report.js +1 -0
- package/dist/sandbox.js +364 -50
- package/dist/scan.js +134 -0
- package/dist/sync.js +44 -7
- package/dist/tools.js +397 -88
- package/dist/ui/bridge.js +18 -4
- package/dist/ui/look.js +1 -1
- package/dist/ui/screens.js +5 -3
- package/dist/ui/sensitive.js +10 -0
- package/dist/vercel-bypass.js +12 -7
- package/dist/vercel.js +110 -17
- package/package.json +1 -1
package/dist/cloudflare.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { cloudflarePreviewsFact, cloudflarePreviewsPage } from './host-previews.js';
|
|
3
4
|
import { commandRunner } from './netlify.js';
|
|
4
|
-
import { ownFolder } from './sandbox.js';
|
|
5
|
+
import { ownFolder, ownNpmCache } from './sandbox.js';
|
|
5
6
|
/**
|
|
6
7
|
* Cloudflare Pages: the preview deployments this repository's pull requests
|
|
7
8
|
* get, from GitHub's record and from cf, Cloudflare's CLI, on this machine
|
|
8
|
-
* when it is signed in (read only),
|
|
9
|
+
* when it is signed in (read only), whether cf is signed in, whether the
|
|
10
|
+
* project built from this repository builds previews of pull requests at all,
|
|
11
|
+
* and the one change that turns them on as the person (host-previews.ts). Cloudflare's
|
|
9
12
|
* GitHub app records no deployment and no commit status: its record is a
|
|
10
13
|
* check run on each commit it built and a comment on the pull request, both
|
|
11
14
|
* naming the deployment's address (https://<id>.<project>.pages.dev) and the
|
|
@@ -30,9 +33,11 @@ const sha = (value) => typeof value === 'string' && /^[0-9a-f]{40}$/.test(value)
|
|
|
30
33
|
const branchName = (value) => /^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/.test(value) && !value.includes('..') && !value.endsWith('/') && !value.endsWith('.');
|
|
31
34
|
/** A branch, as GitHub names it in a check run's summary or cf in a deployment: git's characters, and not too long to be one. */
|
|
32
35
|
const branchOf = (value) => (typeof value === 'string' && branchName(value) ? value : undefined);
|
|
33
|
-
/** The project
|
|
36
|
+
/** The project's own address, https://<project>.pages.dev. */
|
|
37
|
+
const PAGES_SITE = new RegExp(`^https://(${LABEL})\\.pages\\.dev$`);
|
|
38
|
+
/** The project a Pages address belongs to, a deployment's, a branch's alias or the project's own; null for any other address. */
|
|
34
39
|
export function pagesProject(url) {
|
|
35
|
-
return PAGES_ADDRESS.exec(url)?.[2] ?? null;
|
|
40
|
+
return PAGES_ADDRESS.exec(url)?.[2] ?? PAGES_SITE.exec(url)?.[1] ?? null;
|
|
36
41
|
}
|
|
37
42
|
/** The deployment's address and the branch's alias, as the app's table names them, else the first two Pages links in order; a page mentions each once. */
|
|
38
43
|
function addresses(html) {
|
|
@@ -70,6 +75,8 @@ async function fromGitHub(owner, repo, gh, only, branch) {
|
|
|
70
75
|
}
|
|
71
76
|
}
|
|
72
77
|
const found = [];
|
|
78
|
+
/** The Workers Builds check run on the newest head that has one: Cloudflare building a Worker from the repository, with no preview address. */
|
|
79
|
+
let worker;
|
|
73
80
|
// Cloudflare posts its check run once the build ended, so a head with none is still building, or was never built.
|
|
74
81
|
const runs = await Promise.all(heads.map((head) => read(get(`${base}/commits/${head.sha}/check-runs?per_page=100`), null)));
|
|
75
82
|
runs.forEach((result, i) => {
|
|
@@ -77,6 +84,12 @@ async function fromGitHub(owner, repo, gh, only, branch) {
|
|
|
77
84
|
for (const run of list(record(result)?.check_runs)) {
|
|
78
85
|
if (!isApp(record(run.app)?.slug))
|
|
79
86
|
continue;
|
|
87
|
+
const script = typeof run.name === 'string' ? /^Workers Builds: (\S+)$/.exec(run.name)?.[1] : undefined;
|
|
88
|
+
if (script) {
|
|
89
|
+
if (!worker || head.at > worker.at)
|
|
90
|
+
worker = { script, build: run.status === 'completed' ? stateOf(run.conclusion) : 'building', at: head.at };
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
80
93
|
const { url, alias } = addresses(String(record(run.output)?.summary ?? ''));
|
|
81
94
|
if (!url)
|
|
82
95
|
continue;
|
|
@@ -101,15 +114,19 @@ async function fromGitHub(owner, repo, gh, only, branch) {
|
|
|
101
114
|
found.push({ pullRequest: issue, ...(head.branch ? { branch: head.branch } : {}), sha: head.sha, url, ...(alias ? { alias } : {}), state: stateOf(/Status:<\/strong>[\s\S]{0,120}?<\/td>/.exec(comment.body)?.[0]), from: 'bot comment', at: when(comment.updated_at) || head.at });
|
|
102
115
|
}
|
|
103
116
|
}
|
|
104
|
-
return { previews: found, heads, failed };
|
|
117
|
+
return { previews: found, heads, failed, ...(worker ? { worker: { script: worker.script, build: worker.build } } : {}) };
|
|
105
118
|
}
|
|
106
|
-
/**
|
|
107
|
-
|
|
119
|
+
/**
|
|
120
|
+
* What the checkout's wrangler config is for: a Pages project, when it has a Pages output directory, or a Worker, when
|
|
121
|
+
* it names a script (`main`) or static assets instead; and the name it gives. A config with neither says nothing.
|
|
122
|
+
*/
|
|
123
|
+
export async function wranglerConfig(cwd) {
|
|
108
124
|
const name = (value) => (typeof value === 'string' && new RegExp(`^${LABEL}$`).test(value) ? value : undefined);
|
|
109
125
|
try {
|
|
110
126
|
const toml = await readFile(join(cwd, 'wrangler.toml'), 'utf8');
|
|
111
|
-
|
|
112
|
-
|
|
127
|
+
const kind = /^\s*pages_build_output_dir\s*=/m.test(toml) ? 'pages' : /^\s*(?:main\s*=|\[assets\]|assets\s*=)/m.test(toml) ? 'worker' : undefined;
|
|
128
|
+
if (kind)
|
|
129
|
+
return { kind, name: name(/^\s*name\s*=\s*"([^"\n]+)"/m.exec(toml)?.[1]) };
|
|
113
130
|
}
|
|
114
131
|
catch {
|
|
115
132
|
// No TOML config: the JSON ones next.
|
|
@@ -118,8 +135,9 @@ export async function configuredProject(cwd) {
|
|
|
118
135
|
try {
|
|
119
136
|
// A jsonc config may carry comments, on their own lines or after a value, and trailing commas.
|
|
120
137
|
const config = record(JSON.parse(plainJson(await readFile(join(cwd, file), 'utf8'))));
|
|
121
|
-
|
|
122
|
-
|
|
138
|
+
const kind = typeof config?.pages_build_output_dir === 'string' ? 'pages' : typeof config?.main === 'string' || record(config?.assets) ? 'worker' : undefined;
|
|
139
|
+
if (kind)
|
|
140
|
+
return { kind, name: name(config?.name) };
|
|
123
141
|
}
|
|
124
142
|
catch {
|
|
125
143
|
// Not there, or not readable as JSON.
|
|
@@ -127,6 +145,67 @@ export async function configuredProject(cwd) {
|
|
|
127
145
|
}
|
|
128
146
|
return undefined;
|
|
129
147
|
}
|
|
148
|
+
/** The Pages project the checkout's wrangler config names: only a config with a Pages output directory is a Pages project's, since a Worker's config names a Worker. */
|
|
149
|
+
export async function configuredProject(cwd) {
|
|
150
|
+
const config = await wranglerConfig(cwd);
|
|
151
|
+
return config?.kind === 'pages' ? config.name : undefined;
|
|
152
|
+
}
|
|
153
|
+
/** The Cloudflare addresses the README names: the hosts under pages.dev (a project's own, `<subdomain>.pages.dev`, or a deployment's) and the first under workers.dev. */
|
|
154
|
+
export async function readmeAddresses(cwd) {
|
|
155
|
+
for (const file of ['README.md', 'readme.md', 'README']) {
|
|
156
|
+
let text;
|
|
157
|
+
try {
|
|
158
|
+
text = await readFile(join(cwd, file), 'utf8');
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const pages = [...new Set((text.match(new RegExp(`https://(?:${LABEL}\\.)+pages\\.dev`, 'g')) ?? []).map((url) => new URL(url).host))];
|
|
164
|
+
const workers = new RegExp(`https://${LABEL}\\.${LABEL}\\.workers\\.dev`).exec(text)?.[0];
|
|
165
|
+
return { pages, ...(workers ? { workers } : {}) };
|
|
166
|
+
}
|
|
167
|
+
return { pages: [] };
|
|
168
|
+
}
|
|
169
|
+
/** How many pages of an organization's app installations are read, a hundred each, before the app's absence is unknown. */
|
|
170
|
+
const INSTALLATION_PAGES = 5;
|
|
171
|
+
/** The page where the person installs Cloudflare's GitHub app, or configures its installation on an account: GitHub's own, which asks which account and repositories. */
|
|
172
|
+
export const CLOUDFLARE_APP_PAGE = 'https://github.com/apps/cloudflare-workers-and-pages/installations/new';
|
|
173
|
+
/**
|
|
174
|
+
* Cloudflare's GitHub app on the repository's owner, as gh reads it as the person: an organization's installations
|
|
175
|
+
* name the app, its id and whether it covers all or selected repositories; a user's own installations gh cannot list
|
|
176
|
+
* (that endpoint takes a GitHub App's token), and neither can a member who is no owner of the organization.
|
|
177
|
+
*/
|
|
178
|
+
export async function readGitHubApp(owner, repo, gh) {
|
|
179
|
+
const get = async (route) => JSON.parse(await gh(['api', '--hostname', 'github.com', '--method', 'GET', route]));
|
|
180
|
+
const unknown = { installed: 'unknown', page: CLOUDFLARE_APP_PAGE };
|
|
181
|
+
let repository;
|
|
182
|
+
try {
|
|
183
|
+
repository = record(await get(`/repos/${owner}/${repo}`));
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return unknown;
|
|
187
|
+
}
|
|
188
|
+
if (record(repository?.owner)?.type !== 'Organization')
|
|
189
|
+
return unknown;
|
|
190
|
+
// The organization's installations a page at a time until the app or the list's end: a list read only in part says nothing of an app missing from it.
|
|
191
|
+
let app;
|
|
192
|
+
for (let page = 1; !app; page++) {
|
|
193
|
+
let installations;
|
|
194
|
+
try {
|
|
195
|
+
installations = list(record(await get(`/orgs/${owner}/installations?per_page=100&page=${page}`))?.installations);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return unknown;
|
|
199
|
+
}
|
|
200
|
+
app = installations.find((i) => isApp(i.app_slug));
|
|
201
|
+
if (!app && installations.length < 100)
|
|
202
|
+
return { installed: false, page: CLOUDFLARE_APP_PAGE };
|
|
203
|
+
if (!app && page === INSTALLATION_PAGES)
|
|
204
|
+
return unknown;
|
|
205
|
+
}
|
|
206
|
+
const selection = app.repository_selection === 'all' ? 'all' : app.repository_selection === 'selected' ? 'selected' : undefined;
|
|
207
|
+
return { installed: true, ...(selection ? { repositories: selection } : {}), page: positive(app.id) ? `https://github.com/organizations/${owner}/settings/installations/${app.id}` : CLOUDFLARE_APP_PAGE };
|
|
208
|
+
}
|
|
130
209
|
/** JSON with comments as JSON: line and block comments outside strings go, and so does a comma before a closing bracket. */
|
|
131
210
|
export function plainJson(text) {
|
|
132
211
|
let out = '';
|
|
@@ -155,15 +234,17 @@ export function plainJson(text) {
|
|
|
155
234
|
return out.replace(/,(\s*[}\]])/g, '$1');
|
|
156
235
|
}
|
|
157
236
|
/** cf, Cloudflare's CLI, at the version this command is built against, run through npx, which installs it on first use. */
|
|
158
|
-
export const CF_PACKAGE = 'cf@1.0.0-beta.
|
|
237
|
+
export const CF_PACKAGE = 'cf@1.0.0-beta.13';
|
|
159
238
|
/**
|
|
160
239
|
* cf here: its JSON uncoloured, and an account's command given its account, which cf otherwise asks the person to pick
|
|
161
240
|
* when they have several. `browser` lets its sign-in open one. npx runs it from a folder of the command's own, never the
|
|
162
241
|
* checkout, whose node_modules and .npmrc would choose which cf runs as the person, outside the sandbox: every cf
|
|
163
|
-
* command names its project or its account, so none needs the checkout.
|
|
242
|
+
* command names its project or its account, so none needs the checkout. It fetches cf through a cache of the command's
|
|
243
|
+
* own, never ~/.npm, where a sandboxed install could have planted a cf under the registry's live ETag; the registry is
|
|
244
|
+
* asked afresh, and no install script of cf's runs, which it does without.
|
|
164
245
|
*/
|
|
165
246
|
export function cfRunner(opts = {}, runner = commandRunner) {
|
|
166
|
-
return (args, { account, signal } = {}) => runner('npx', ownFolder(), { timeoutMs: 120_000, ...opts, signal, env: { NO_COLOR: '1', ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}) } })(['--yes', CF_PACKAGE, ...args]);
|
|
247
|
+
return (args, { account, signal } = {}) => runner('npx', ownFolder(), { timeoutMs: 120_000, ...opts, signal, env: { NO_COLOR: '1', ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}) } })(['--yes', '--cache', ownNpmCache(), '--prefer-online', '--ignore-scripts', CF_PACKAGE, ...args]);
|
|
167
248
|
}
|
|
168
249
|
/** The status of the API call a cf command failed on, which its error box names on a line of its own ("│ 403 Forbidden", "│ 404 Not Found · HTTP /accounts/…"); null when it names none. */
|
|
169
250
|
export function cfFailure(err) {
|
|
@@ -202,12 +283,41 @@ const PAGES = 4;
|
|
|
202
283
|
* a project of the same name, a page at a time, until the list ends or every head has its build; a deployment Cloudflare
|
|
203
284
|
* built from another repository is not this one's.
|
|
204
285
|
*/
|
|
205
|
-
async function fromCf(
|
|
286
|
+
async function fromCf(named, cf, repository, settings, signal) {
|
|
206
287
|
const { cli, scopes, accounts } = await cfStatus(cf, signal);
|
|
207
288
|
const pages = !scopes || scopes.includes('pages:read') || scopes.includes('pages:write');
|
|
208
|
-
if (cli !== 'signed-in' || !
|
|
289
|
+
if (cli !== 'signed-in' || !pages)
|
|
209
290
|
return { cli, rows: [], failed: cli === 'unknown' };
|
|
210
291
|
const same = (value, name) => typeof value === 'string' && value.toLowerCase() === name.toLowerCase();
|
|
292
|
+
// The project built from this repository, as the accounts' project lists name it by its source, and what its settings say of pull-request previews: read once, with the evidence, never while a build is waited for.
|
|
293
|
+
// A project of the name the checkout gives but built from another repository is not this one's: its settings are never the fact, so no yes changes it.
|
|
294
|
+
let project = named;
|
|
295
|
+
let pullRequestPreviews;
|
|
296
|
+
let found = accounts;
|
|
297
|
+
let account;
|
|
298
|
+
let subdomain;
|
|
299
|
+
if (settings) {
|
|
300
|
+
const listed = await listCloudflareProjects(cf, accounts, signal);
|
|
301
|
+
const built = listed.filter(({ project: p }) => {
|
|
302
|
+
const source = record(record(p.source)?.config);
|
|
303
|
+
return same(source?.owner, repository.owner) && same(source?.repo_name, repository.repo);
|
|
304
|
+
});
|
|
305
|
+
// Of several projects built from this repository, the one the addresses or the config name; else one that builds the pull requests, since any that does previews them; else the first.
|
|
306
|
+
// A project the checkout names (its config, or the README's pages.dev address) that is connected to no repository at all is this one's to connect; one built from another repository is not.
|
|
307
|
+
const unconnected = ({ project: p }) => !record(p.source) && ((named !== undefined && p.name === named) || (typeof p.subdomain === 'string' && repository.readme.includes(p.subdomain)));
|
|
308
|
+
const ours = built.find(({ project: p }) => named !== undefined && p.name === named) ?? built.find(({ project: p }) => cloudflarePreviewsFact(p).enabled === true) ?? built[0] ?? listed.find(unconnected);
|
|
309
|
+
if (ours) {
|
|
310
|
+
project = typeof ours.project.name === 'string' ? ours.project.name : project;
|
|
311
|
+
pullRequestPreviews = cloudflarePreviewsFact(ours.project);
|
|
312
|
+
found = [ours.account];
|
|
313
|
+
account = ours.account;
|
|
314
|
+
// The project's own host as Cloudflare named it, which a taken name suffixes (crewline-x1y.pages.dev).
|
|
315
|
+
subdomain = typeof ours.project.subdomain === 'string' && new RegExp(`^${LABEL}\\.pages\\.dev$`).test(ours.project.subdomain) ? ours.project.subdomain : undefined;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const settled = { ...(pullRequestPreviews ? { pullRequestPreviews } : {}), ...(account ? { account } : {}), ...(subdomain ? { subdomain } : {}) };
|
|
319
|
+
if (!project)
|
|
320
|
+
return { cli, rows: [], failed: false, ...settled };
|
|
211
321
|
const ours = (d) => {
|
|
212
322
|
const source = record(record(d.source)?.config);
|
|
213
323
|
return !source || (same(source.owner, repository.owner) && same(source.repo_name, repository.repo));
|
|
@@ -215,7 +325,7 @@ async function fromCf(project, cf, repository, signal) {
|
|
|
215
325
|
const rows = [];
|
|
216
326
|
const built = () => repository.heads.every((sha) => rows.some((row) => row.sha && sha.startsWith(row.sha)));
|
|
217
327
|
let failed = false;
|
|
218
|
-
for (const account of
|
|
328
|
+
for (const account of found.length ? found : [undefined]) {
|
|
219
329
|
if (rows.length && built())
|
|
220
330
|
break;
|
|
221
331
|
for (let page = 1; page <= PAGES; page++) {
|
|
@@ -242,7 +352,31 @@ async function fromCf(project, cf, repository, signal) {
|
|
|
242
352
|
break;
|
|
243
353
|
}
|
|
244
354
|
}
|
|
245
|
-
return { cli, rows, failed };
|
|
355
|
+
return { cli, project, rows, failed, ...settled };
|
|
356
|
+
}
|
|
357
|
+
/** A page of cf's list of an account's Pages projects (ten is the most Cloudflare's API allows), and how many pages it reads at most. */
|
|
358
|
+
const PROJECTS_PER_PAGE = 10;
|
|
359
|
+
const PROJECT_PAGES = 10;
|
|
360
|
+
/** The Pages projects of each account the sign-in reaches, as `cf pages list` prints them, each with its source: an account that could not be read lists none. */
|
|
361
|
+
async function listCloudflareProjects(cf, accounts, signal) {
|
|
362
|
+
const projects = [];
|
|
363
|
+
for (const account of accounts) {
|
|
364
|
+
for (let page = 1; page <= PROJECT_PAGES; page++) {
|
|
365
|
+
let listed;
|
|
366
|
+
try {
|
|
367
|
+
const printed = await cf(['pages', 'list', '--page', String(page), '--per-page', String(PROJECTS_PER_PAGE)], { account, signal });
|
|
368
|
+
listed = list(JSON.parse(printed.slice(printed.indexOf('['), printed.lastIndexOf(']') + 1)));
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
for (const project of listed)
|
|
374
|
+
projects.push({ account, project });
|
|
375
|
+
if (listed.length < PROJECTS_PER_PAGE)
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return projects;
|
|
246
380
|
}
|
|
247
381
|
/**
|
|
248
382
|
* Whether this repository's pull requests get Cloudflare Pages previews: the newest, as GitHub and cf have them;
|
|
@@ -254,10 +388,22 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
|
254
388
|
return unread();
|
|
255
389
|
if (deps.branch !== undefined && !branchName(deps.branch))
|
|
256
390
|
return unread();
|
|
257
|
-
const
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
const
|
|
391
|
+
const gh = deps.gh ?? commandRunner('gh', cwd, { signal: deps.signal });
|
|
392
|
+
const plain = deps.pull === undefined && deps.branch === undefined;
|
|
393
|
+
const github = await fromGitHub(owner, repo, gh, deps.pull, deps.branch);
|
|
394
|
+
const config = await wranglerConfig(cwd);
|
|
395
|
+
const readme = plain ? await readmeAddresses(cwd) : { pages: [] };
|
|
396
|
+
const named = github.previews.map((p) => pagesProject(p.url)).find(Boolean) ?? (config?.kind === 'pages' ? config.name : undefined);
|
|
397
|
+
// cf installs itself through npx on a machine without it, which takes a while the first time. The project's settings are read with the plain evidence, not while a build is waited for.
|
|
398
|
+
const cf = await fromCf(named, deps.cf ?? cfRunner(), { owner, repo, heads: github.heads.map((h) => h.sha), readme: readme.pages }, plain, deps.signal);
|
|
399
|
+
const project = cf.project ?? named;
|
|
400
|
+
// A Worker built from the repository: its check runs, the config or the README say so, and the check runs say how its newest build went.
|
|
401
|
+
const script = github.worker?.script ?? (config?.kind === 'worker' ? config.name : undefined);
|
|
402
|
+
const worker = github.worker || config?.kind === 'worker' || readme.workers
|
|
403
|
+
? { ...(script ? { script } : {}), build: github.worker?.build ?? 'none', ...(readme.workers ? { address: readme.workers } : {}) }
|
|
404
|
+
: undefined;
|
|
405
|
+
// Read once the repository is on Cloudflare by the evidence (a Pages project found or named, or a Worker), with the plain evidence alone.
|
|
406
|
+
const githubApp = plain && (cf.pullRequestPreviews || worker || project) ? await readGitHubApp(owner, repo, gh) : undefined;
|
|
261
407
|
// A row is the head's when its commit is the head's; the pull request's heads say which. A wait for a pushed branch takes its head's rows alone.
|
|
262
408
|
const head = (row) => github.heads.find((h) => row.sha && h.sha.startsWith(row.sha) && (deps.branch === undefined || h.branch === deps.branch));
|
|
263
409
|
const rows = cf.rows.flatMap((row) => {
|
|
@@ -281,11 +427,167 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
|
281
427
|
// The most recently updated pull request's first; a branch with no pull request among them last, as cf lists it.
|
|
282
428
|
previews.sort((a, b) => (b.pullRequest ? 1 : 0) - (a.pullRequest ? 1 : 0) || b.at - a.at);
|
|
283
429
|
return {
|
|
284
|
-
...(project ? { project } : {}),
|
|
430
|
+
...(project ? { project, production: `https://${cf.subdomain ?? `${project}.pages.dev`}` } : {}),
|
|
285
431
|
previews: previews.slice(0, deps.branch !== undefined ? previews.length : NEWEST).map(({ at, ...preview }) => preview),
|
|
286
432
|
cli: cf.cli,
|
|
287
433
|
...(github.failed || cf.failed
|
|
288
434
|
? { error: "Some of Cloudflare's previews could not be read: what is missing is unknown, not absent.", unread: [...(github.failed ? ['GitHub'] : []), ...(cf.failed ? ['cf'] : [])] }
|
|
289
435
|
: {}),
|
|
436
|
+
...(cf.pullRequestPreviews ? { pullRequestPreviews: cf.pullRequestPreviews } : {}),
|
|
437
|
+
...(cf.account ? { account: cf.account } : {}),
|
|
438
|
+
...(worker ? { worker } : {}),
|
|
439
|
+
...(githubApp ? { githubApp } : {}),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/** The Pages project as `cf pages get` prints it, in the first of the sign-in's accounts that holds one of that name; null when none does, or cf could not read it. */
|
|
443
|
+
export async function readCloudflareProject(cf, project, accounts, signal) {
|
|
444
|
+
for (const account of accounts) {
|
|
445
|
+
try {
|
|
446
|
+
const printed = await cf(['pages', 'get', project], { account, signal });
|
|
447
|
+
const read = record(JSON.parse(printed.slice(printed.indexOf('{'), printed.lastIndexOf('}') + 1)));
|
|
448
|
+
if (read?.name === project)
|
|
449
|
+
return { account, project: read };
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
if (cfFailure(err) !== 404)
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
/** The project's source with the test drive's branch allowed: None becomes every non-production branch, Cloudflare's default; Custom branches keep their rules and gain `impeccable/*`. */
|
|
459
|
+
export function cloudflarePreviewsBody(project) {
|
|
460
|
+
const source = record(project.source);
|
|
461
|
+
const config = record(source?.config);
|
|
462
|
+
if (!source || !config)
|
|
463
|
+
return null;
|
|
464
|
+
const includes = Array.isArray(config.preview_branch_includes) ? config.preview_branch_includes.filter((v) => typeof v === 'string') : [];
|
|
465
|
+
if (config.preview_deployment_setting === 'custom')
|
|
466
|
+
return { source: { ...source, config: { ...config, preview_branch_includes: [...includes, 'impeccable/*'] } } };
|
|
467
|
+
return { source: { ...source, config: { ...config, preview_deployment_setting: 'all' } } };
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Turns a Pages project's pull-request previews on through cf signed in as the person, where the preview branch control
|
|
471
|
+
* is the one setting in the way and the sign-in holds pages:write, and reads the project back. `cf pages edit` sends
|
|
472
|
+
* the whole source as read, with the setting changed, so the Git connection stays as it is.
|
|
473
|
+
*/
|
|
474
|
+
export async function enableCloudflarePreviews(cf, project, status, signal) {
|
|
475
|
+
const unread = (reason) => ({ fact: { project, enabled: 'unknown', reason }, page: cloudflarePreviewsPage(status.accounts[0] ?? '', project), changed: false, reason });
|
|
476
|
+
if (status.cli !== 'signed-in')
|
|
477
|
+
return unread("cf, Cloudflare's CLI, is not signed in here.");
|
|
478
|
+
const read = await readCloudflareProject(cf, project, status.accounts, signal);
|
|
479
|
+
if (!read)
|
|
480
|
+
return unread(`cf could not read the Pages project ${project} in the accounts it reaches.`);
|
|
481
|
+
const page = cloudflarePreviewsPage(read.account, project);
|
|
482
|
+
const fact = cloudflarePreviewsFact(read.project);
|
|
483
|
+
if (fact.enabled !== false)
|
|
484
|
+
return { fact, page, changed: false };
|
|
485
|
+
if (fact.cause !== 'previews')
|
|
486
|
+
return { fact, page, changed: false, reason: `The run changes the preview branch control alone. ${fact.reason}` };
|
|
487
|
+
// An exclude rule is the person's, and excludes are evaluated first, so an include added beside it would change nothing.
|
|
488
|
+
if (/exclude rule/.test(fact.reason ?? ''))
|
|
489
|
+
return { fact, page, changed: false, reason: `The run adds to the include rules alone. ${fact.reason}` };
|
|
490
|
+
if (status.scopes && !status.scopes.includes('pages:write'))
|
|
491
|
+
return { fact, page, changed: false, reason: 'cf is signed in here without pages:write, so it cannot change the project.' };
|
|
492
|
+
const body = cloudflarePreviewsBody(read.project);
|
|
493
|
+
if (!body)
|
|
494
|
+
return { fact, page, changed: false, reason: 'The project has no Git source to change.' };
|
|
495
|
+
try {
|
|
496
|
+
await cf(['pages', 'edit', project, '--body', JSON.stringify(body)], { account: read.account, signal });
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
return { fact, page, changed: false, reason: 'Cloudflare refused the change: the role may not allow it.' };
|
|
500
|
+
}
|
|
501
|
+
const after = await readCloudflareProject(cf, project, [read.account], signal);
|
|
502
|
+
const afterFact = after ? cloudflarePreviewsFact(after.project) : fact;
|
|
503
|
+
return { fact: afterFact, page, changed: afterFact.enabled === true };
|
|
504
|
+
}
|
|
505
|
+
/** A Pages project's name for the repository: lowercase letters, digits and hyphens, as Cloudflare takes them. */
|
|
506
|
+
export function pagesProjectName(repo) {
|
|
507
|
+
return repo.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 58).replace(/-+$/, '') || 'site';
|
|
508
|
+
}
|
|
509
|
+
/** The source body `cf pages source connect` takes: the repository as GitHub identifies it, every branch but production a preview, as Cloudflare's dashboard connects one. */
|
|
510
|
+
export function cloudflareSourceBody(repository) {
|
|
511
|
+
return {
|
|
512
|
+
type: 'github',
|
|
513
|
+
config: {
|
|
514
|
+
owner: repository.owner,
|
|
515
|
+
owner_id: String(repository.ownerId),
|
|
516
|
+
repo_name: repository.name,
|
|
517
|
+
repo_id: String(repository.id),
|
|
518
|
+
production_branch: repository.defaultBranch,
|
|
519
|
+
deployments_enabled: true,
|
|
520
|
+
production_deployments_enabled: true,
|
|
521
|
+
preview_deployment_setting: 'all',
|
|
522
|
+
preview_branch_includes: ['*'],
|
|
523
|
+
preview_branch_excludes: [],
|
|
524
|
+
pr_comments_enabled: true,
|
|
525
|
+
path_includes: ['*'],
|
|
526
|
+
path_excludes: [],
|
|
527
|
+
},
|
|
290
528
|
};
|
|
291
529
|
}
|
|
530
|
+
/**
|
|
531
|
+
* Connects the repository to a Pages project through cf signed in as the person, the way the dashboard does: the
|
|
532
|
+
* project of that name in the account when it exists with no Git source, else one created for the repository with the
|
|
533
|
+
* build the checkout runs (`create`); then `cf pages source connect` with the repository as gh identifies it, so that
|
|
534
|
+
* Cloudflare builds the production branch and every other branch as a preview; then the project read back.
|
|
535
|
+
*/
|
|
536
|
+
export async function connectCloudflareProject(cf, gh, status, repository, plan, signal) {
|
|
537
|
+
const { project } = plan;
|
|
538
|
+
const refused = (reason) => ({ status: 'refused', project, reason });
|
|
539
|
+
if (status.cli !== 'signed-in')
|
|
540
|
+
return refused("cf, Cloudflare's CLI, is not signed in here.");
|
|
541
|
+
if (status.scopes && !status.scopes.includes('pages:write'))
|
|
542
|
+
return refused('cf is signed in here without pages:write, so it cannot change the account.');
|
|
543
|
+
const account = plan.account ?? status.accounts[0];
|
|
544
|
+
if (!account)
|
|
545
|
+
return refused('The Cloudflare sign-in reaches no account.');
|
|
546
|
+
let github;
|
|
547
|
+
try {
|
|
548
|
+
github = record(JSON.parse(await gh(['api', '--hostname', 'github.com', '--method', 'GET', `/repos/${repository.owner}/${repository.repo}`])));
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
github = undefined;
|
|
552
|
+
}
|
|
553
|
+
const owner = record(github?.owner);
|
|
554
|
+
const defaultBranch = github?.default_branch;
|
|
555
|
+
if (!github || !positive(github.id) || !owner || !positive(owner.id) || typeof owner.login !== 'string' || typeof github.name !== 'string' || typeof defaultBranch !== 'string' || !branchName(defaultBranch))
|
|
556
|
+
return refused('GitHub did not answer for the repository.');
|
|
557
|
+
const existing = await readCloudflareProject(cf, project, [account], signal);
|
|
558
|
+
let created = false;
|
|
559
|
+
if (!existing) {
|
|
560
|
+
if (!plan.create)
|
|
561
|
+
return refused(`cf could not read the Pages project ${project} in the account.`);
|
|
562
|
+
const build = { ...(plan.create.command ? { build_command: plan.create.command } : {}), ...(plan.create.outputDir ? { destination_dir: plan.create.outputDir } : {}) };
|
|
563
|
+
try {
|
|
564
|
+
await cf(['pages', 'create', '--body', JSON.stringify({ name: project, production_branch: defaultBranch, ...(Object.keys(build).length ? { build_config: build } : {}) })], { account, signal });
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
const code = cfFailure(err);
|
|
568
|
+
return refused(code === 409 || code === 400 ? `Cloudflare refused creating the Pages project ${project}: the name may be taken in the account.` : `Cloudflare refused creating the Pages project ${project}.`);
|
|
569
|
+
}
|
|
570
|
+
created = true;
|
|
571
|
+
}
|
|
572
|
+
else if (record(existing.project.source)) {
|
|
573
|
+
const config = record(record(existing.project.source)?.config);
|
|
574
|
+
const same = typeof config?.owner === 'string' && typeof config.repo_name === 'string' && config.owner.toLowerCase() === repository.owner.toLowerCase() && config.repo_name.toLowerCase() === repository.repo.toLowerCase();
|
|
575
|
+
return same
|
|
576
|
+
? { status: 'connected', project, created: false, ...(typeof existing.project.subdomain === 'string' ? { subdomain: existing.project.subdomain } : {}), fact: cloudflarePreviewsFact(existing.project), reason: 'The project is connected to the repository already.' }
|
|
577
|
+
: refused(`The Pages project ${project} is connected to another repository.`);
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
await cf(['pages', 'source', 'connect', project, '--body', JSON.stringify(cloudflareSourceBody({ owner: owner.login, ownerId: owner.id, name: github.name, id: github.id, defaultBranch }))], { account, signal });
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
return refused(created ? `The Pages project ${project} was created, but Cloudflare refused connecting the repository to it: Cloudflare's GitHub app may not be installed for ${owner.login}.` : `Cloudflare refused connecting the repository to the Pages project ${project}: Cloudflare's GitHub app may not be installed for ${owner.login}.`);
|
|
584
|
+
}
|
|
585
|
+
const after = await readCloudflareProject(cf, project, [account], signal);
|
|
586
|
+
if (!after)
|
|
587
|
+
return refused(`The Pages project ${project} could not be read back.`);
|
|
588
|
+
const fact = cloudflarePreviewsFact(after.project);
|
|
589
|
+
const config = record(record(after.project.source)?.config);
|
|
590
|
+
if (fact.cause === 'source' || typeof config?.repo_name !== 'string' || config.repo_name.toLowerCase() !== repository.repo.toLowerCase())
|
|
591
|
+
return refused(`Cloudflare did not keep the repository on the Pages project ${project}.`);
|
|
592
|
+
return { status: 'connected', project, created, ...(typeof after.project.subdomain === 'string' ? { subdomain: after.project.subdomain } : {}), productionBranch: defaultBranch, fact };
|
|
593
|
+
}
|