@impeccable/reviews 0.1.28 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cloudflare.js +85 -9
- package/dist/fence.js +27 -2
- package/dist/hosted.js +23 -5
- package/dist/hosting.js +76 -0
- package/dist/log.js +2 -1
- package/dist/main.js +2 -0
- package/dist/netlify.js +21 -1
- package/dist/playbook/cases/cloudflare-previews.md +3 -3
- package/dist/playbook/cases/live-app-address.md +1 -1
- package/dist/playbook/cases/netlify-previews.md +1 -1
- package/dist/playbook/cases/pull-requests.md +2 -2
- package/dist/playbook/cases/render-previews.md +2 -1
- package/dist/playbook/cases/vercel-previews.md +1 -1
- package/dist/playbook.js +3 -3
- package/dist/tools.js +157 -33
- package/dist/ui/run-state.js +19 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ The command stops when the sandbox does not start: on macOS inside another tool'
|
|
|
66
66
|
|
|
67
67
|
The fence stays the layer inside the sandbox: it keeps the setup to its own steps and out of `.env` files, and the agent's commands get your environment without the tokens, keys and passwords in it, whether their names say so or their values are shaped like a secret (a GitHub token, a private key, a service account's key, a password in a URL), and what runs as you (`git commit` and `push`, `gh`, the host CLIs) gets it without `GH_REPO` and `GH_HOST`, so `gh` works on this checkout's repository on github.com, whatever repository or host they name. The agent's own file tools (read, write, edit, grep, find and ls) open files in the command's process, as you, so for them the fence judges the path pi opens, not the string the agent typed: spelled the way pi spells it (a curly apostrophe for a straight one, among others) and with its symlinks followed, and a path that starts with `@` or `~` or a URL scheme, which pi would open elsewhere, is refused outright; read, write, edit, grep and ls judge each file once more as they open it (ls each entry too, so a symlink out of the checkout is not listed), find judges the folder it searches before fd walks it and every path fd reports after, and a symlinked folder out of the checkout is neither followed nor listed; the names of `.env` files are listed, as `ls -a` shows them, and only a read of one is refused; a grep glob that names `.env` files is refused, and no line of a `.env` file is in what grep reports, whatever glob it was given. It also reads what goes past it: text shaped like a secret (a cloud, GitHub, Google or SendGrid key, a private key, a token, a Slack webhook's address, a password in an address or on a curl command line) in a file the agent writes, a setting, a commit, a pull request or a message to you is refused, read as written and as the screen would show it once an escape sequence is taken out, a command that names a credential file (`~/.ssh`, `.aws/credentials`, `.netrc`, `.env`) is refused, and what the agent reads gets a line saying it is data when it carries text shaped like instructions; the debug log and `--scan-report` list each match. A commit message, a template and a pull request body come inline (`-m`, `--body`), never from a file: the file the fence read is not surely the file git or gh, outside the sandbox, read later, since a command still running in the sandbox can swap it in between. For the same reason the messages of the commits a push carries are read again as the push runs, all of them (past what the remote has: its branch of that name, the default branch, and one the pushed branch is stacked on, such as an earlier run's setup branch under a new test drive), and so is every line they add to a `.env` file's example, which a command may have written, with the same check as a file the agent writes; a push whose commits cannot be read whole is refused, as is one whose remote cannot be reached, since what it carries cannot be checked then (the push can be tried again once the network is back; the remote is asked with no prompt for a password or a passphrase, which would hold it), and one in the same command as the `git commit` or `git checkout -b` that makes what it carries, which is not there yet when it is read: the push runs as a command of its own, after the commit. A commit carries only files the agent wrote, or named in a `git add`: a file a script staged on its own is refused by name, and `git restore --staged` takes it out; a push whose new commits carry such a file (a script can commit on its own, past the index) is refused the same way. Once `impeccable_sync_workflow` wrote the preview workflow, or removed the retired one, a commit carries that file only as the tool left it: the setup state's bytes, or no file. A status line the agent starts a message with shows in the terminal and on the web page only when its progress tool would take it: in the product voice, short, and holding nothing shaped like a secret.
|
|
68
68
|
|
|
69
|
-
Without asking: `git status|diff|log|show|add|restore --staged|commit|checkout -b|push -u origin <branch>`, `git branch` and `git remote` in their listing forms, `git ls-remote origin` (in the sandbox, over https, and never held on a prompt for a sign-in), `gh pr create`, `gh pr view` for a pull request of this repository, named with `--repo`, by number or by this run's branch name (with `--json` or `--comments`, never `--web`) and `gh pr ready` for the test drive's branch by name (a Netlify draft, marked ready once its preview is proven), `npm|pnpm|yarn|bun install|ci`, the scripts of the package.json where the command runs (`npm run`, `pnpm run`, `bun run`, `yarn <script>`), `npx`, `pnpm exec`, `yarn` and `bunx` for `tsc`, `eslint`, `prettier`, `next`, `vite`, `astro`, `nuxt`, `prisma`, `playwright`, `drizzle-kit`, `sequelize` and `knex`, and `ls`, `cat`, `head`, `grep` and `find` inside the checkout (by relative or absolute path) and the run's scratch folder. A package manager or tool runner may have `KEY=value` settings in front; any of these may start with a `cd` into a folder of the checkout and be chained with `&&`, `||`, `;` or `|` to others on the list.
|
|
69
|
+
Without asking: `git status|diff|log|show|add|restore --staged|commit|checkout -b|push -u origin <branch>`, `git branch` and `git remote` in their listing forms, `git ls-remote origin` (in the sandbox, over https, and never held on a prompt for a sign-in), `gh pr create`, `gh pr view` for a pull request of this repository, named with `--repo`, by number or by this run's branch name (with `--json` or `--comments`, never `--web`), `gh pr list` for this repository's pull requests, named with `--repo` (with `--state`, `--limit`, at most 100, and `--json`, never `--web` or a search) and `gh pr ready` for the test drive's branch by name (a Netlify draft, marked ready once its preview is proven), `npm|pnpm|yarn|bun install|ci`, the scripts of the package.json where the command runs (`npm run`, `pnpm run`, `bun run`, `yarn <script>`), `npx`, `pnpm exec`, `yarn` and `bunx` for `tsc`, `eslint`, `prettier`, `next`, `vite`, `astro`, `nuxt`, `prisma`, `playwright`, `drizzle-kit`, `sequelize` and `knex`, and `ls`, `cat`, `head`, `grep` and `find` inside the checkout (by relative or absolute path) and the run's scratch folder. A package manager or tool runner may have `KEY=value` settings in front; any of these may start with a `cd` into a folder of the checkout and be chained with `&&`, `||`, `;` or `|` to others on the list.
|
|
70
70
|
|
|
71
71
|
`vercel whoami` and `vercel --version`. Once you said yes to the Vercel setup: `vercel link --yes` in the checkout's root alone (never in a folder of it, whose name would pick one of your projects), `vercel project add` and `vercel deploy` for a preview, on the project named after the folder and in the one team the sign-in settled on (the CLI's current scope, your only team, or the one you pick when it asks): no other `--scope`, `--team` or `--project`, never `vercel project ls`, which names your projects, and a global install of the Vercel CLI itself when it is missing, with npm and pinned to the version this release names (`npm install -g vercel@<version>`; another package manager, version or tag is refused). The sign-in (`vercel login`) and the repository's connection (`vercel git connect`, for a project the run created or one connected to the repository already) are the run's own tools, never the agent's commands. Never a production deploy, a project's removal, its environment variables or protection settings (`vercel project protection`, `api` and `curl` print a project's bypass secrets, so the agent's shell never runs them; the lock before a new project's first deployment and the bypass secret are the run's own tools), or a token on the command line.
|
|
72
72
|
|
package/dist/cloudflare.js
CHANGED
|
@@ -14,8 +14,11 @@ import { ownFolder } from './sandbox.js';
|
|
|
14
14
|
* check run on each commit it built and a comment on the pull request, both
|
|
15
15
|
* naming the deployment's address (https://<id>.<project>.pages.dev) and the
|
|
16
16
|
* branch's alias (https://<branch>.<project>.pages.dev), which follows the
|
|
17
|
-
* branch's latest build.
|
|
18
|
-
*
|
|
17
|
+
* branch's latest build. A Worker that Workers Builds builds from the repository
|
|
18
|
+
* gets previews of its branches where its branch builds make them, on workers.dev
|
|
19
|
+
* (https://<version or branch>-<worker>.<subdomain>.workers.dev): its check run
|
|
20
|
+
* names both addresses, or, for a Worker Preview, its comment names the preview's.
|
|
21
|
+
* Nothing here reads a token, and nothing comes back beyond the fields below.
|
|
19
22
|
*/
|
|
20
23
|
/** How many pull requests, the most recently updated, the evidence reads. */
|
|
21
24
|
const NEWEST = 10;
|
|
@@ -23,6 +26,8 @@ const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
|
|
23
26
|
/** A Pages deployment's address, or a branch's alias: one label, then the project, under pages.dev. */
|
|
24
27
|
export const PAGES_ADDRESS = new RegExp(`^https://(${LABEL})\\.(${LABEL})\\.pages\\.dev$`);
|
|
25
28
|
const LINKS = new RegExp(`https://${LABEL}\\.${LABEL}\\.pages\\.dev`, 'g');
|
|
29
|
+
/** A Worker's preview address: one label, the version's id or the branch's alias joined to the Worker's name, then the account's subdomain, under workers.dev. */
|
|
30
|
+
export const WORKERS_ADDRESS = new RegExp(`^https://(${LABEL})\\.(${LABEL})\\.workers\\.dev$`);
|
|
26
31
|
/** Cloudflare's GitHub app, by the slug its check runs carry and the login its comments carry; the earlier Pages-only app posted the same. */
|
|
27
32
|
const APPS = new Set(['cloudflare-workers-and-pages', 'cloudflare-pages']);
|
|
28
33
|
const isApp = (login) => typeof login === 'string' && APPS.has(login.replace(/\[bot\]$/, ''));
|
|
@@ -47,19 +52,67 @@ function addresses(html) {
|
|
|
47
52
|
const links = [...new Set(html.match(LINKS) ?? [])];
|
|
48
53
|
return { url: fits(row('Preview URL')) ?? links[0], alias: fits(row('Branch Preview URL')) ?? (links[1] !== links[0] ? links[1] : undefined) };
|
|
49
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* A preview of the Worker `script` by its alias on workers.dev, `<name>-<script>.<subdomain>` (`name` the preview's where
|
|
57
|
+
* Cloudflare names it, else any branch's), with the pattern that alias stands for, and the version's address,
|
|
58
|
+
* `<version id>-<script>.<subdomain>`, where it is the same Worker's on the same subdomain; none for an alias of another Worker, or none.
|
|
59
|
+
*/
|
|
60
|
+
function workerPreview(script, url, alias, name) {
|
|
61
|
+
const [, label, subdomain] = (alias && WORKERS_ADDRESS.exec(alias)) || [];
|
|
62
|
+
if (!alias || !(name !== undefined ? label === `${name}-${script}` : label?.endsWith(`-${script}`)))
|
|
63
|
+
return undefined;
|
|
64
|
+
const [, version, on] = (url && WORKERS_ADDRESS.exec(url)) || [];
|
|
65
|
+
return { url: url && version?.slice(9) === script && /^[0-9a-f]{8}-/.test(version) && on === subdomain ? url : alias, alias, pattern: `https://{branch}-${script}.${subdomain}.workers.dev` };
|
|
66
|
+
}
|
|
50
67
|
/** What a check run's conclusion, or the words the app's table says, tell of the build. */
|
|
51
68
|
const stateOf = (value) => (typeof value !== 'string' ? 'building' : /succe/i.test(value) ? 'success' : /fail|timed_out|error/i.test(value) ? 'failure' : /cancel/i.test(value) ? 'canceled' : 'building');
|
|
69
|
+
/** When GitHub says a record last changed, as a number to sort by; 0 where it does not say. */
|
|
70
|
+
const when = (value) => (typeof value === 'string' && Number.isFinite(Date.parse(value)) ? Date.parse(value) : 0);
|
|
71
|
+
/**
|
|
72
|
+
* GitHub's record, read from the setup state's hosting record (hosting.ts) instead of GitHub: Cloudflare's check runs
|
|
73
|
+
* and its bot's comments as Impeccable's copy holds them, a Workers Builds check's Worker among them; null where the
|
|
74
|
+
* record lists none for the app, so GitHub is read here.
|
|
75
|
+
*/
|
|
76
|
+
export function cloudflareFromHosting(app) {
|
|
77
|
+
const previews = [];
|
|
78
|
+
const heads = [];
|
|
79
|
+
let worker;
|
|
80
|
+
for (const p of app.previews) {
|
|
81
|
+
if (p.source !== 'check-run' && p.source !== 'comment')
|
|
82
|
+
continue;
|
|
83
|
+
const from = p.source === 'check-run' ? 'check run' : 'bot comment';
|
|
84
|
+
const state = p.state === 'ready' || p.state === 'success' ? 'success' : p.state === 'failed' || p.state === 'failure' ? 'failure' : p.state === 'canceled' ? 'canceled' : 'building';
|
|
85
|
+
const at = when(p.at);
|
|
86
|
+
const head = { ...(p.pull !== null ? { pullRequest: p.pull } : {}), sha: p.sha };
|
|
87
|
+
if (p.site && (p.alias ?? p.url)?.endsWith('.workers.dev')) {
|
|
88
|
+
// A Worker's: the record's Worker and alias stand for the pattern, as a check run's summary does.
|
|
89
|
+
if (!worker || at > worker.at)
|
|
90
|
+
worker = { script: p.site, build: state, at };
|
|
91
|
+
const preview = workerPreview(p.site, p.url ?? undefined, p.alias ?? p.url ?? undefined);
|
|
92
|
+
if (preview)
|
|
93
|
+
previews.push({ ...head, ...preview, state, from, at });
|
|
94
|
+
}
|
|
95
|
+
else if (p.url && PAGES_ADDRESS.test(p.url)) {
|
|
96
|
+
previews.push({ ...head, url: p.url, ...(p.alias && PAGES_ADDRESS.test(p.alias) ? { alias: p.alias } : {}), state, from, at });
|
|
97
|
+
}
|
|
98
|
+
else
|
|
99
|
+
continue;
|
|
100
|
+
if (!heads.some((h) => h.sha === p.sha))
|
|
101
|
+
heads.push({ ...(p.pull !== null ? { number: p.pull } : {}), sha: p.sha, at });
|
|
102
|
+
}
|
|
103
|
+
return previews.length ? { previews, heads, failed: false, ...(worker ? { worker: { script: worker.script, build: worker.build } } : {}) } : null;
|
|
104
|
+
}
|
|
52
105
|
/**
|
|
53
106
|
* GitHub's record: the Cloudflare check run on each head (or, with `only`, that pull request's; with `branch`, the
|
|
54
107
|
* pushed branch's head alone, before its pull request exists), and the app's comment on each pull request when it
|
|
55
|
-
* describes the head.
|
|
108
|
+
* describes the head. A Worker Preview's check run names no address, so its alias is made from `pattern`, the one an
|
|
109
|
+
* earlier read's Worker preview stood for. `failed` when a read did not answer.
|
|
56
110
|
*/
|
|
57
|
-
async function fromGitHub(owner, repo, gh, only, branch) {
|
|
111
|
+
async function fromGitHub(owner, repo, gh, only, branch, pattern) {
|
|
58
112
|
const base = `/repos/${owner}/${repo}`;
|
|
59
113
|
const get = async (route) => JSON.parse(await gh(['api', '--hostname', 'github.com', '--method', 'GET', route]));
|
|
60
114
|
let failed = false;
|
|
61
115
|
const read = async (what, none) => what.catch(() => ((failed = true), none));
|
|
62
|
-
const when = (value) => (typeof value === 'string' && Number.isFinite(Date.parse(value)) ? Date.parse(value) : 0);
|
|
63
116
|
const heads = [];
|
|
64
117
|
if (branch !== undefined) {
|
|
65
118
|
const ref = record(await read(get(`${base}/branches/${encodeURIComponent(branch)}`), null));
|
|
@@ -87,8 +140,16 @@ async function fromGitHub(owner, repo, gh, only, branch) {
|
|
|
87
140
|
continue;
|
|
88
141
|
const script = typeof run.name === 'string' ? /^Workers Builds: (\S+)$/.exec(run.name)?.[1] : undefined;
|
|
89
142
|
if (script) {
|
|
143
|
+
const build = run.status === 'completed' ? stateOf(run.conclusion) : 'building';
|
|
90
144
|
if (!worker || head.at > worker.at)
|
|
91
|
-
worker = { script, build
|
|
145
|
+
worker = { script, build, at: head.at };
|
|
146
|
+
// A branch build's preview: its summary names the version's address and the branch's alias; a Worker Preview's names neither, and its build page's address names the preview.
|
|
147
|
+
const said = Object.fromEntries([...String(record(run.output)?.summary ?? '').matchAll(/^Preview (URL|Alias URL): (\S+)$/gm)].map((m) => [m[1], m[2]]));
|
|
148
|
+
const name = typeof run.details_url === 'string' ? new RegExp(`/previews/(${LABEL})/builds/`).exec(run.details_url)?.[1] : undefined;
|
|
149
|
+
// An earlier read's pattern makes the alias only where it is this Worker's own, never another Worker's whose name ends in this one's.
|
|
150
|
+
const preview = workerPreview(script, said.URL, said['Alias URL'] ?? (name && pattern?.startsWith(`https://{branch}-${script}.`) ? pattern.replace('{branch}', name) : undefined));
|
|
151
|
+
if (preview)
|
|
152
|
+
found.push({ ...(head.number ? { pullRequest: head.number } : {}), ...(head.branch ? { branch: head.branch } : {}), sha: head.sha, ...preview, state: build, from: 'check run', at: when(run.completed_at) || head.at });
|
|
92
153
|
continue;
|
|
93
154
|
}
|
|
94
155
|
const { url, alias } = addresses(String(record(run.output)?.summary ?? ''));
|
|
@@ -105,6 +166,15 @@ async function fromGitHub(owner, repo, gh, only, branch) {
|
|
|
105
166
|
const head = heads.find((h) => h.number === issue);
|
|
106
167
|
if (!head || !isApp(record(comment.user)?.login) || typeof comment.body !== 'string')
|
|
107
168
|
continue;
|
|
169
|
+
// A Worker Preview's comment names the preview's address and the commit it last deployed, `### Preview URL: <address> (commit <sha>)`, and its dashboard link names the Worker.
|
|
170
|
+
const latest = /^### Preview URL: (\S+) \(commit ([0-9a-f]{7,40})\)$/m.exec(comment.body);
|
|
171
|
+
const [, script, name] = new RegExp(`/workers/services/view/(${LABEL})/production/previews/(${LABEL})`).exec(comment.body) ?? [];
|
|
172
|
+
if (latest && script) {
|
|
173
|
+
const preview = head.sha.startsWith(latest[2]) ? workerPreview(script, undefined, latest[1], name) : undefined;
|
|
174
|
+
if (preview)
|
|
175
|
+
found.push({ pullRequest: issue, ...(head.branch ? { branch: head.branch } : {}), sha: head.sha, ...preview, state: 'success', from: 'bot comment', at: when(comment.updated_at) || head.at });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
108
178
|
// The app edits its one comment as the pull request moves on: it describes the head when the commit it names is the head's.
|
|
109
179
|
const commit = /Latest commit:<\/strong>[\s\S]{0,80}?<code>([0-9a-f]{7,40})<\/code>/.exec(comment.body)?.[1];
|
|
110
180
|
if (!commit || !head.sha.startsWith(commit))
|
|
@@ -424,7 +494,8 @@ async function listCloudflareProjects(cf, accounts, signal) {
|
|
|
424
494
|
}
|
|
425
495
|
/**
|
|
426
496
|
* Whether this repository's pull requests get Cloudflare Pages previews: the newest, as GitHub and cf have them;
|
|
427
|
-
* with `pull`, that pull request's only; with `branch`, the pushed branch's head's only, before its pull request exists
|
|
497
|
+
* with `pull`, that pull request's only; with `branch`, the pushed branch's head's only, before its pull request exists;
|
|
498
|
+
* with `pattern`, the one an earlier read's Worker preview stood for, a Worker Preview's build listed too.
|
|
428
499
|
*/
|
|
429
500
|
export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
430
501
|
// cf is not asked here, so whether it is installed is unknown.
|
|
@@ -435,7 +506,7 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
|
435
506
|
return unread();
|
|
436
507
|
const gh = deps.gh ?? commandRunner('gh', cwd, { signal: deps.signal });
|
|
437
508
|
const plain = deps.pull === undefined && deps.branch === undefined;
|
|
438
|
-
const github = await fromGitHub(owner, repo, gh, deps.pull, deps.branch);
|
|
509
|
+
const github = deps.github && plain ? { ...deps.github, previews: [...deps.github.previews] } : await fromGitHub(owner, repo, gh, deps.pull, deps.branch, deps.pattern);
|
|
439
510
|
const config = await wranglerConfig(cwd);
|
|
440
511
|
const readme = plain ? await readmeAddresses(cwd) : { pages: [] };
|
|
441
512
|
// For one app's folder, an address or the root's config names no app's project: only the project's settings do.
|
|
@@ -458,6 +529,9 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
|
458
529
|
else if (app)
|
|
459
530
|
github.previews = github.previews.filter((p) => pagesProject(p.url) === (cf.subdomain ? pagesProject(`https://${cf.subdomain}`) : cf.project));
|
|
460
531
|
const project = cf.project ?? named;
|
|
532
|
+
// Where a Pages project builds the repository, its previews stay the method, and a Worker's beside it are left out.
|
|
533
|
+
if (project)
|
|
534
|
+
github.previews = github.previews.filter((p) => !p.pattern);
|
|
461
535
|
// 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.
|
|
462
536
|
const script = github.worker?.script ?? (config?.kind === 'worker' ? config.name : undefined);
|
|
463
537
|
const worker = github.worker || config?.kind === 'worker' || readme.workers
|
|
@@ -487,8 +561,10 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
|
487
561
|
}
|
|
488
562
|
// The most recently updated pull request's first; a branch with no pull request among them last, as cf lists it.
|
|
489
563
|
previews.sort((a, b) => (b.pullRequest ? 1 : 0) - (a.pullRequest ? 1 : 0) || b.at - a.at);
|
|
564
|
+
// No Pages project, and a Worker's previews listed: the Worker's own address, on the subdomain they name, is the live app.
|
|
565
|
+
const live = previews.find((p) => p.pattern)?.pattern?.replace('{branch}-', '');
|
|
490
566
|
return {
|
|
491
|
-
...(project ? { project, production: `https://${cf.subdomain ?? `${project}.pages.dev`}` } : {}),
|
|
567
|
+
...(project ? { project, production: `https://${cf.subdomain ?? `${project}.pages.dev`}` } : live ? { production: live } : {}),
|
|
492
568
|
previews: previews.slice(0, deps.branch !== undefined ? previews.length : NEWEST).map(({ at, ...preview }) => preview),
|
|
493
569
|
cli: cf.cli,
|
|
494
570
|
...(github.failed || cf.failed
|
package/dist/fence.js
CHANGED
|
@@ -918,6 +918,9 @@ function simpleCommand(seg, where, opts) {
|
|
|
918
918
|
return ghView(args.slice(2)) ? allow() : deny("gh pr view takes a pull request's number or this run's branch, --repo for this repository, --json and --comments");
|
|
919
919
|
if (args[0] === 'pr' && args[1] === 'checks')
|
|
920
920
|
return ghView(args.slice(2), '--required') ? allow() : deny("gh pr checks takes a pull request's number or this run's branch, --repo for this repository, --json and --required");
|
|
921
|
+
// The repository's own pull requests, listed: the earlier ones whose previews are its host's evidence (REN-1108).
|
|
922
|
+
if (args[0] === 'pr' && args[1] === 'list')
|
|
923
|
+
return ghList(args.slice(2)) ? allow() : deny('gh pr list takes --repo for this repository, --state, --limit (100 at most) and --json');
|
|
921
924
|
// The test drive opens as a draft where its preview needs the pull request first, and is marked ready once that preview is proven: the test drive's branch by name, nothing else.
|
|
922
925
|
if (args[0] === 'pr' && args[1] === 'ready')
|
|
923
926
|
return ghReady(args.slice(2)) !== null ? allow() : deny('gh pr ready takes the test drive branch by name, and --repo');
|
|
@@ -1705,6 +1708,28 @@ function ghView(args, flag = '--comments') {
|
|
|
1705
1708
|
const repository = seen.get('--repo');
|
|
1706
1709
|
return repository === undefined ? {} : REPOSITORY.test(repository) ? { repository } : null;
|
|
1707
1710
|
}
|
|
1711
|
+
/** `gh pr list [--repo <owner>/<name>] [--state open|closed|merged|all] [--limit <n>] [--json <fields>]`, each once and nothing else (no --web, no search): the repository named, or null. */
|
|
1712
|
+
function ghList(args) {
|
|
1713
|
+
const seen = new Map();
|
|
1714
|
+
for (let i = 0; i < args.length; i++) {
|
|
1715
|
+
const arg = args[i];
|
|
1716
|
+
const at = arg.startsWith('--') ? arg.indexOf('=') : -1;
|
|
1717
|
+
const given = at < 0 ? arg : arg.slice(0, at);
|
|
1718
|
+
const name = { '-R': '--repo', '-s': '--state', '-L': '--limit' }[given] ?? given;
|
|
1719
|
+
if (!['--repo', '--state', '--limit', '--json'].includes(name) || seen.has(name))
|
|
1720
|
+
return null;
|
|
1721
|
+
const value = (at < 0 ? args[++i] : arg.slice(at + 1)) ?? '';
|
|
1722
|
+
if (!value || value.startsWith('-'))
|
|
1723
|
+
return null;
|
|
1724
|
+
if (name === '--state' && !['open', 'closed', 'merged', 'all'].includes(value))
|
|
1725
|
+
return null;
|
|
1726
|
+
if (name === '--limit' && !(/^[1-9]\d{0,2}$/.test(value) && Number(value) <= 100))
|
|
1727
|
+
return null;
|
|
1728
|
+
seen.set(name, value);
|
|
1729
|
+
}
|
|
1730
|
+
const repository = seen.get('--repo');
|
|
1731
|
+
return repository === undefined ? {} : REPOSITORY.test(repository) ? { repository } : null;
|
|
1732
|
+
}
|
|
1708
1733
|
/** The branch of `push -u origin <branch>`, git's words from its command on, when `literal` takes it. */
|
|
1709
1734
|
const pushBranch = (args, literal = setupBranch) => args.length === 4 && args[0] === 'push'
|
|
1710
1735
|
&& ['-u', '--set-upstream'].includes(args[1]) && args[2] === 'origin' && literal(args[3]) ? args[3] : undefined;
|
|
@@ -1835,8 +1860,8 @@ export function reservePreviewCommand(state, command, root, scratch) {
|
|
|
1835
1860
|
if (!drives.get(parsed.branch)?.created)
|
|
1836
1861
|
return deny('mark ready the test drive this run opened');
|
|
1837
1862
|
}
|
|
1838
|
-
if (words[0] === 'gh' && words[1] === 'pr' && (words[2] === 'view' || words[2] === 'checks')) {
|
|
1839
|
-
const parsed = ghView(words.slice(3), words[2] === 'checks' ? '--required' : '--comments');
|
|
1863
|
+
if (words[0] === 'gh' && words[1] === 'pr' && (words[2] === 'view' || words[2] === 'checks' || words[2] === 'list')) {
|
|
1864
|
+
const parsed = words[2] === 'list' ? ghList(words.slice(3)) : ghView(words.slice(3), words[2] === 'checks' ? '--required' : '--comments');
|
|
1840
1865
|
if (!parsed)
|
|
1841
1866
|
return deny("view only this run's pull requests, by number or branch name");
|
|
1842
1867
|
// Without --repo, gh picks a repository from the checkout's remotes, which may name another one.
|
package/dist/hosted.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { promisify } from 'node:util';
|
|
3
4
|
import { ACCESS_ID_HEADER, ACCESS_SECRET_HEADER } from './cloudflare-access.js';
|
|
4
5
|
import { gitEnvironment } from './sandbox.js';
|
|
@@ -94,15 +95,17 @@ const NETLIFY = new RegExp(`^(?=[^.]{1,63}\\.netlify\\.app$)deploy-preview-[1-9]
|
|
|
94
95
|
const PAGES = new RegExp(`^${LABEL}\\.(${LABEL})\\.pages\\.dev$`);
|
|
95
96
|
/** A Render pull request's preview: a site of its own, the service's name and the pull request's number, under onrender.com. */
|
|
96
97
|
const RENDER = new RegExp(`^(${LABEL})-pr-([1-9]\\d{0,6})\\.onrender\\.com$`);
|
|
98
|
+
/** A Worker's preview: one label (the version's id, or the branch's alias, joined to the Worker's name), then the account's subdomain, under workers.dev. */
|
|
99
|
+
const WORKERS = new RegExp(`^${LABEL}\\.${LABEL}\\.workers\\.dev$`);
|
|
97
100
|
/** A live app's own address on its host: a Vercel alias (one name under vercel.app, the shape its deployments have too), a Netlify site's netlify.app address (a deploy or branch subdomain carries `--`), a Pages project's pages.dev address, a Render service's onrender.com address (a pull request's preview ends in `-pr-<n>`). */
|
|
98
101
|
const LIVE = { vercel: new RegExp(`^${LABEL}\\.vercel\\.app$`), netlify: new RegExp(`^(?!.*--)(${LABEL})\\.netlify\\.app$`), cloudflare: new RegExp(`^(${LABEL})\\.pages\\.dev$`), render: new RegExp(`^(?!.*-pr-[1-9]\\d{0,6}\\.)(${LABEL})\\.onrender\\.com$`) };
|
|
99
102
|
/** https on one name, with no credentials, port, query or fragment. */
|
|
100
103
|
const plainHttps = (url) => url.protocol === 'https:' && !url.username && !url.password && !url.port && !url.search && !url.hash;
|
|
101
|
-
/** A deployment's own address: https on one name, and nothing else. Vercel's is one name under vercel.app, Netlify's a pull request's deploy preview, Cloudflare's a Pages preview, Render's a pull request's preview site. */
|
|
104
|
+
/** A deployment's own address: https on one name, and nothing else. Vercel's is one name under vercel.app, Netlify's a pull request's deploy preview, Cloudflare's a Pages preview or a Worker's, Render's a pull request's preview site. */
|
|
102
105
|
const hostOf = (url) => {
|
|
103
106
|
if (!plainHttps(url))
|
|
104
107
|
return null;
|
|
105
|
-
return LIVE.vercel.test(url.hostname) ? 'vercel' : NETLIFY.test(url.hostname) ? 'netlify' : PAGES.test(url.hostname) ? 'cloudflare' : RENDER.test(url.hostname) ? 'render' : null;
|
|
108
|
+
return LIVE.vercel.test(url.hostname) ? 'vercel' : NETLIFY.test(url.hostname) ? 'netlify' : PAGES.test(url.hostname) || WORKERS.test(url.hostname) ? 'cloudflare' : RENDER.test(url.hostname) ? 'render' : null;
|
|
106
109
|
};
|
|
107
110
|
/** The host a live app's address is on, by its domain, so its lock is known; `other` for any other https address, whose lock no key opens. */
|
|
108
111
|
const liveHostOf = (url) => {
|
|
@@ -133,10 +136,17 @@ export function liveShaped(url) {
|
|
|
133
136
|
return LIVE.render.test(parsed.hostname);
|
|
134
137
|
return false;
|
|
135
138
|
}
|
|
139
|
+
/** The host a live app's address is on, where the address is a live app's on one of them (`liveShaped`); null otherwise. */
|
|
140
|
+
export function liveHost(url) {
|
|
141
|
+
if (!liveShaped(url))
|
|
142
|
+
return null;
|
|
143
|
+
const host = liveHostOf(new URL(url));
|
|
144
|
+
return host === 'other' ? null : host;
|
|
145
|
+
}
|
|
136
146
|
/** The host a preview address belongs to, by name; null for an address of none. */
|
|
137
147
|
export function previewHostName(url) {
|
|
138
148
|
const host = URL.canParse(url) ? hostOf(new URL(url)) : null;
|
|
139
|
-
return host ? HOSTS[host].name : null;
|
|
149
|
+
return host === 'cloudflare' && WORKERS.test(new URL(url).hostname) ? 'Cloudflare Workers' : host ? HOSTS[host].name : null;
|
|
140
150
|
}
|
|
141
151
|
/** The Netlify site a deploy preview address, or the site's own netlify.app address, belongs to, by name; null for any other address. */
|
|
142
152
|
export function netlifySiteOf(url) {
|
|
@@ -348,7 +358,7 @@ export async function provePreview(url, words, root, deps = {}) {
|
|
|
348
358
|
const parsed = URL.canParse(url) ? new URL(url) : null;
|
|
349
359
|
const host = !parsed ? null : !deps.live ? hostOf(parsed) : !plainHttps(parsed) ? null : typeof deps.live === 'string' ? deps.live : liveHostOf(parsed);
|
|
350
360
|
if (!host)
|
|
351
|
-
return unproven(deps.live ? 'Only an https address with no query is opened.' : 'Only a Vercel preview address, https://<name>.vercel.app, a Netlify deploy preview, https://deploy-preview-<n>--<name>.netlify.app, a Cloudflare Pages preview, https://<id or branch>.<project>.pages.dev, or a Render preview, https://<site>-pr-<n>.onrender.com, with no query, is opened.');
|
|
361
|
+
return unproven(deps.live ? 'Only an https address with no query is opened.' : 'Only a Vercel preview address, https://<name>.vercel.app, a Netlify deploy preview, https://deploy-preview-<n>--<name>.netlify.app, a Cloudflare Pages preview, https://<id or branch>.<project>.pages.dev, a Worker\'s, https://<id or branch>-<worker>.<subdomain>.workers.dev, or a Render preview, https://<site>-pr-<n>.onrender.com, with no query, is opened.');
|
|
352
362
|
// Another host's live address has a lock no key opens, so none is looked for: its login is another site, and its 401 the app's own sign-in.
|
|
353
363
|
const { gate, login, lock } = host === 'other' ? { gate: undefined, login: /$^/, lock: 'the app' } : HOSTS[host];
|
|
354
364
|
const phrase = words.trim();
|
|
@@ -372,9 +382,17 @@ export async function provePreview(url, words, root, deps = {}) {
|
|
|
372
382
|
// Where the login stands in front all the same, the bypass secret, the password or the service token did not get past it.
|
|
373
383
|
const still = opened ? ' even with the bypass secret' : withPassword ? ' even with the password' : withToken ? ' even with the service token' : '';
|
|
374
384
|
let target = new URL(url);
|
|
385
|
+
// Render's edge keeps a page it served for five minutes (`s-maxage=300`), and after a deploy goes on serving the old one for a while (REN-1117): each request carries a query value of its own beside any a redirect gave, which the edge caches apart, so the proof reads the build Render serves now. A request's `Cache-Control: no-cache` the edge ignores.
|
|
386
|
+
const fresh = (at) => {
|
|
387
|
+
if (host !== 'render')
|
|
388
|
+
return at;
|
|
389
|
+
const asked = new URL(at);
|
|
390
|
+
asked.searchParams.set('impeccable', randomUUID());
|
|
391
|
+
return asked;
|
|
392
|
+
};
|
|
375
393
|
try {
|
|
376
394
|
for (let hop = 0; hop < 4; hop++) {
|
|
377
|
-
const res = await open(target, { redirect: 'manual', headers, credentials: 'omit', signal });
|
|
395
|
+
const res = await open(fresh(target), { redirect: 'manual', headers, credentials: 'omit', signal });
|
|
378
396
|
if (res.status >= 300 && res.status < 400) {
|
|
379
397
|
await res.body?.cancel().catch(() => { });
|
|
380
398
|
const location = res.headers.get('location') ?? '';
|
package/dist/hosting.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { liveHost, renderSiteOf } from './hosted.js';
|
|
2
|
+
import { parseRenderDeployment } from './render.js';
|
|
3
|
+
const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
|
|
4
|
+
const text = (value) => (typeof value === 'string' && value.length > 0 && value.length <= 300 ? value : null);
|
|
5
|
+
/** An https address, as the record's origin and path, with no slash after it; null for anything else. */
|
|
6
|
+
const address = (value) => {
|
|
7
|
+
if (typeof value !== 'string' || value.length > 2048 || !/^https:\/\/[^\s\\?#@]+$/.test(value) || !URL.canParse(value))
|
|
8
|
+
return null;
|
|
9
|
+
const url = new URL(value);
|
|
10
|
+
return `${url.origin}${url.pathname}`.replace(/\/$/, '');
|
|
11
|
+
};
|
|
12
|
+
const SOURCES = new Set(['deployment', 'commit-status', 'check-run', 'comment']);
|
|
13
|
+
/** The record the state carries for `repo`, as far as it is well formed; null where the state has none (an older setup API). */
|
|
14
|
+
export function hostingOf(state, repo) {
|
|
15
|
+
const repos = record(state)?.repos;
|
|
16
|
+
const own = Array.isArray(repos) ? repos.map(record).find((r) => r?.name === repo) : undefined;
|
|
17
|
+
const hosting = record(record(record(own?.items)?.preview)?.hosting);
|
|
18
|
+
if (!hosting || !Array.isArray(hosting.apps))
|
|
19
|
+
return null;
|
|
20
|
+
const apps = hosting.apps.map(record).flatMap((a) => {
|
|
21
|
+
const key = text(a?.key);
|
|
22
|
+
if (!a || !key)
|
|
23
|
+
return [];
|
|
24
|
+
const previews = (Array.isArray(a.previews) ? a.previews : []).map(record).flatMap((p) => {
|
|
25
|
+
const sha = typeof p?.sha === 'string' && /^[0-9a-f]{40}$/.test(p.sha) ? p.sha : null;
|
|
26
|
+
const source = typeof p?.source === 'string' && SOURCES.has(p.source) ? p.source : null;
|
|
27
|
+
if (!p || !sha || !source)
|
|
28
|
+
return [];
|
|
29
|
+
const pull = typeof p.pull === 'number' && Number.isSafeInteger(p.pull) && p.pull > 0 ? p.pull : null;
|
|
30
|
+
return [{ pull, sha, url: address(p.url), alias: address(p.alias), state: text(p.state), at: text(p.at), source, environment: text(p.environment), site: text(p.site) }];
|
|
31
|
+
});
|
|
32
|
+
return [{ key, liveUrl: address(a.liveUrl), previews }];
|
|
33
|
+
});
|
|
34
|
+
return { apps };
|
|
35
|
+
}
|
|
36
|
+
/** The record's entry for an app: the one named, or, on a repository of one app, its only one. */
|
|
37
|
+
export function hostingApp(hosting, appKey) {
|
|
38
|
+
if (!hosting)
|
|
39
|
+
return undefined;
|
|
40
|
+
return appKey !== undefined ? hosting.apps.find((a) => a.key === appKey) : hosting.apps.length === 1 ? hosting.apps[0] : undefined;
|
|
41
|
+
}
|
|
42
|
+
const STATES = ['error', 'failure', 'inactive', 'in_progress', 'queued', 'pending', 'success'];
|
|
43
|
+
/** A Worker's own address on workers.dev, or a preview's alias: one label joined to the Worker's name, then the account's subdomain. The shape tells them apart no better than the record's previews do. */
|
|
44
|
+
const WORKERS_ADDRESS = /^https:\/\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.workers\.dev$/;
|
|
45
|
+
/**
|
|
46
|
+
* The host the record's live address is on, where the proof may open it as the live app: by its domain (hosted.ts
|
|
47
|
+
* `liveHost`), or a Worker's own address on workers.dev where none of the record's previews is at that address, since
|
|
48
|
+
* a preview's alias has the same shape; null for an address elsewhere, or none.
|
|
49
|
+
*/
|
|
50
|
+
export function liveHostOf(app) {
|
|
51
|
+
if (!app.liveUrl)
|
|
52
|
+
return null;
|
|
53
|
+
const host = liveHost(app.liveUrl);
|
|
54
|
+
if (host)
|
|
55
|
+
return host;
|
|
56
|
+
return WORKERS_ADDRESS.test(app.liveUrl) && !app.previews.some((p) => p.url === app.liveUrl || p.alias === app.liveUrl) ? 'cloudflare' : null;
|
|
57
|
+
}
|
|
58
|
+
/** The deployments GitHub recorded for the app, in the shape impeccable_hosted_previews reads, for Render's read (Vercel's keeps GitHub's own read, which covers more than the record: production deployments, older pull requests): so none is Vercel's here. */
|
|
59
|
+
export function deploymentsOf(app) {
|
|
60
|
+
return app.previews.filter((p) => p.source === 'deployment').map((p) => ({
|
|
61
|
+
sha: p.sha,
|
|
62
|
+
environment: p.environment,
|
|
63
|
+
state: STATES.find((s) => s === p.state) ?? null,
|
|
64
|
+
url: p.url,
|
|
65
|
+
vercel: false,
|
|
66
|
+
pullRequests: p.pull !== null ? [p.pull] : [],
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
/** The Render sites the record names for the app: its deployments' (environment and address naming the same site, as parseRenderDeployment checks) and its live address's. */
|
|
70
|
+
export function renderSitesOf(app) {
|
|
71
|
+
const sites = deploymentsOf(app).flatMap((d) => parseRenderDeployment(d.environment, d.url)?.site ?? []);
|
|
72
|
+
const live = app.liveUrl ? renderSiteOf(app.liveUrl) : null;
|
|
73
|
+
if (live && live.pull === undefined)
|
|
74
|
+
sites.push(live.site);
|
|
75
|
+
return [...new Set(sites)];
|
|
76
|
+
}
|
package/dist/log.js
CHANGED
|
@@ -31,7 +31,8 @@ export function createLog(debug, dir = tmpdir()) {
|
|
|
31
31
|
const secret = (value) => void secrets.add(value);
|
|
32
32
|
if (!debug)
|
|
33
33
|
return { secret, write: () => { } };
|
|
34
|
-
|
|
34
|
+
// The process id keeps two runs started in the same millisecond out of one file.
|
|
35
|
+
const path = join(dir, `impeccable-reviews-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}.log`);
|
|
35
36
|
let tightened = false;
|
|
36
37
|
return {
|
|
37
38
|
path,
|
package/dist/main.js
CHANGED
|
@@ -7,6 +7,7 @@ import { runAgent } from './agent.js';
|
|
|
7
7
|
import { runSkillCommand } from './skill.js';
|
|
8
8
|
import { fenceExtension, guardedFileOperations, hostsAgreed, placeOf, previewRetryState } from './fence.js';
|
|
9
9
|
import { accountsSaved, focusInstructions, focusState, severalApps, signInItem } from './focus.js';
|
|
10
|
+
import { hostingOf } from './hosting.js';
|
|
10
11
|
import { requestJson } from './http.js';
|
|
11
12
|
import { copyText, letTabsHear, openBrowser, progressPage, screenPrompt, setupAuth, signIn } from './login.js';
|
|
12
13
|
import { checkoutJournal } from './leftovers.js';
|
|
@@ -394,6 +395,7 @@ export async function main(argv) {
|
|
|
394
395
|
sandbox,
|
|
395
396
|
pushAccess: push.outcome,
|
|
396
397
|
severalApps: severalApps(state.data),
|
|
398
|
+
hosting: hostingOf(state.data, token.repository.name),
|
|
397
399
|
...(flags.app ? { app: flags.app } : {}),
|
|
398
400
|
start: checkout.start,
|
|
399
401
|
...(flags.signedInTestDrive ? { signedInTestDrive: { accountsSaved: accountsSaved(state.data, token.repository.name) } } : {}),
|
package/dist/netlify.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { folderOf, netlifyPreviewsFact, pickPreviewsFact } from './host-previews.js';
|
|
7
|
+
import { netlifySiteOf } from './hosted.js';
|
|
7
8
|
import { gitEnvironment } from './sandbox.js';
|
|
8
9
|
/**
|
|
9
10
|
* Netlify: the deploy previews this repository's pull requests get (from
|
|
@@ -123,6 +124,25 @@ async function fromGitHub(owner, repo, gh, only) {
|
|
|
123
124
|
}
|
|
124
125
|
return { previews: found, heads: new Map(heads.map((head) => [head.number, head.sha])), failed };
|
|
125
126
|
}
|
|
127
|
+
/** What GitHub records, read from the setup state's hosting record (hosting.ts) instead of GitHub: Netlify's commit statuses and its bot's comments as Impeccable's copy holds them; null where the record lists none for the app, so GitHub is read here. */
|
|
128
|
+
export function netlifyFromHosting(app) {
|
|
129
|
+
const previews = [];
|
|
130
|
+
const heads = new Map();
|
|
131
|
+
// A commit status names the head and carries the build's state; a comment proves nothing of the current build, so it stands only where no status says (as from GitHub's record).
|
|
132
|
+
const ranked = [...app.previews.filter((p) => p.source === 'commit-status'), ...app.previews.filter((p) => p.source === 'comment')];
|
|
133
|
+
for (const p of ranked) {
|
|
134
|
+
if (p.pull === null || !p.site || !p.url || netlifySiteOf(p.url) !== p.site)
|
|
135
|
+
continue;
|
|
136
|
+
heads.set(p.pull, p.sha);
|
|
137
|
+
// The address is built from the pull request and the site, as from GitHub's record: never the record's own.
|
|
138
|
+
const url = previewAddress(p.pull, p.site);
|
|
139
|
+
if (!url || previews.some((q) => q.pullRequest === p.pull && q.site === p.site))
|
|
140
|
+
continue;
|
|
141
|
+
// A bot's comment posts once the preview is ready, as from GitHub's record; a status carries its own state.
|
|
142
|
+
previews.push(p.source === 'comment' ? { pullRequest: p.pull, site: p.site, url, state: 'ready', from: 'bot comment', at: when(p.at) } : { pullRequest: p.pull, site: p.site, url, state: STATES.find((s) => s === p.state) ?? null, from: 'commit status', at: when(p.at) });
|
|
143
|
+
}
|
|
144
|
+
return previews.length ? { previews, heads, failed: false } : null;
|
|
145
|
+
}
|
|
126
146
|
/** A page of the person's site list, and how many pages are read at most: what `netlify sites:list` reads. */
|
|
127
147
|
const SITES_PER_PAGE = 100;
|
|
128
148
|
const SITE_PAGES = 10;
|
|
@@ -198,7 +218,7 @@ export async function readNetlifyPreviews(owner, repo, cwd, deps = {}) {
|
|
|
198
218
|
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(owner) || !/^[A-Za-z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..') {
|
|
199
219
|
return { sites: [], previews: [], cli: 'missing', error: "Netlify's previews could not be read." };
|
|
200
220
|
}
|
|
201
|
-
const [read, cli] = await Promise.all([fromGitHub(owner, repo, deps.gh ?? commandRunner('gh', cwd, { signal: deps.signal }), deps.pull), fromCli(owner, repo, deps.netlify ?? commandRunner('netlify', cwd, { signal: deps.signal }), deps.pull, deps.directory)]);
|
|
221
|
+
const [read, cli] = await Promise.all([deps.github && deps.pull === undefined ? deps.github : fromGitHub(owner, repo, deps.gh ?? commandRunner('gh', cwd, { signal: deps.signal }), deps.pull), fromCli(owner, repo, deps.netlify ?? commandRunner('netlify', cwd, { signal: deps.signal }), deps.pull, deps.directory)]);
|
|
202
222
|
// For one app's folder, GitHub's record counts only for the site the CLI read as building it: a site's name says nothing of its folder.
|
|
203
223
|
const where = deps.directory ? `the folder ${deps.directory}` : "the repository's root";
|
|
204
224
|
const unmatched = deps.directory === undefined ? undefined
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
### When this applies
|
|
2
2
|
|
|
3
|
-
Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_previews lists a built preview of a pull request (`state` success), with its address and the branch's alias. Ask it only where the repository is set up for Cloudflare (`wrangler.toml`, `wrangler.json` or `wrangler.jsonc`, a `wrangler` or `@cloudflare/*` package, a `.pages.dev` or `.workers.dev` address in the README or the workflows), but its record decides, not the checkout: Cloudflare's GitHub app posts a check run on each commit it built and a comment on the pull request, and cf, Cloudflare's CLI, lists the project's deployments when it is signed in here. It applies as well where the checkout is set up for Cloudflare and no pull request got a preview: the answer then says why (`pullRequestPreviews`, `worker`, `githubApp`), and "No pull-request previews" below says what the run does about each cause, in that order, before any other preview is set up. Cloudflare builds and serves every preview, so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request.
|
|
3
|
+
Cloudflare Pages deploys previews of the pull requests, or a Worker Cloudflare builds (Workers Builds) does where its branch builds make previews: impeccable_cloudflare_previews lists a built preview of a pull request (`state` success), with its address and the branch's alias (a Worker's, on workers.dev, with the `pattern` it stands for). Ask it only where the state's `hosting` names Cloudflare for the app, or the repository is set up for Cloudflare (`wrangler.toml`, `wrangler.json` or `wrangler.jsonc`, a `wrangler` or `@cloudflare/*` package, a `.pages.dev` or `.workers.dev` address in the README or the workflows), but its record decides, not the checkout: Cloudflare's GitHub app posts a check run on each commit it built and a comment on the pull request, and cf, Cloudflare's CLI, lists the project's deployments when it is signed in here. It applies as well where the checkout is set up for Cloudflare and no pull request got a preview: the answer then says why (`pullRequestPreviews`, `worker`, `githubApp`), and "No pull-request previews" below says what the run does about each cause, in that order, before any other preview is set up. Cloudflare builds and serves every preview, so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request.
|
|
4
4
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
@@ -9,13 +9,13 @@ Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_pr
|
|
|
9
9
|
|
|
10
10
|
### Reference
|
|
11
11
|
|
|
12
|
-
- **The live app**: `production` in the evidence is the project's own address, `https://<project>.pages.dev`; live-app-address says how it becomes the before, in step 2's write.
|
|
12
|
+
- **The live app**: `production` in the evidence is the project's own address, `https://<project>.pages.dev`, or, with no Pages project and a Worker's previews listed, the Worker's own, `https://<worker>.<subdomain>.workers.dev`; live-app-address says how it becomes the before, in step 2's write. A workers.dev address the README names is not the before on its own: its shape cannot tell the Worker's own address from a preview's.
|
|
13
13
|
- **Behind Cloudflare Access** (`gate: "cloudflare-access"`): Cloudflare's login stands in front of the previews. Get past it as preview-protection says, which proves the preview, and go on with step 2; when nothing got past it, that case says how the run ends.
|
|
14
14
|
- **Another app, or no answer from any of the three**: Cloudflare's previews do not show this app. Set the preview up as server-previews says instead, and say under "What is not verified" what the Cloudflare address answered.
|
|
15
15
|
- **No pull-request previews**: the checkout is set up for Cloudflare and nothing is listed, or a project's settings build none. The aim holds: find out why from the answer, in this order, and for each cause ask once with the tool that fixes it, before anything is pushed and never a second time; with a preview listed, after step 1's proof and preview-protection's steps, so the person sees the lock's question and this one back to back, at most one each. Each tool shows its one screen and does the change as the person, or opens the page where it cannot. After `enabled` or `connected`, nothing is listed yet, so the test drive's build is the first preview: take pull-requests' step 4 the Cloudflare way and save the method on its alias as step 2 says, no setup pull request.
|
|
16
16
|
1. **Previews off** (`pullRequestPreviews` with `enabled: false` and `cause` `previews` or `deployments`, with its `reason`): cf, signed in, found the Pages project built from this repository, and its settings build no previews of pull requests. impeccable_enable_previews (`host: "cloudflare"`) turns the preview branch control on; deployments off altogether is the person's change, on the page it opens.
|
|
17
17
|
2. **Not connected** (`cause: "source"`): the Pages project the checkout names (its wrangler config, or the README's pages.dev address) exists with no Git repository connected, so no push builds. impeccable_cloudflare_connect connects it to this repository.
|
|
18
|
-
3. **A Worker, not a Pages project** (`worker` in the answer, and no
|
|
18
|
+
3. **A Worker, not a Pages project** (`worker` in the answer, no `pullRequestPreviews`, and no Worker preview listed): Cloudflare builds the repository as a Worker (Workers Builds: its check runs name the script, or the wrangler config or the README's workers.dev address does), whose branch builds made no preview on record, and a Worker with static assets no branch build at all. impeccable_cloudflare_connect, with the build command and output folder the checkout runs, creates a Pages project for the repository and connects it; the Worker stays as it is. The same tool creates the project where the config names a Pages project cf finds nowhere.
|
|
19
19
|
4. **Cloudflare's GitHub app cannot see the repository** (`githubApp` in the answer: the app's installation covers selected repositories, or `installed: false`): a project connected with previews on still gets no build, since the app builds only the repositories its installation names. Nothing in the account shows it, so the wait for the test drive's build tells: when none comes, the wait's own screen opens the installation page (`githubApp.page`) and waits again on the person's yes. Its `reason` says when they skipped it or no build came after the retries.
|
|
20
20
|
- **`skipped` or `refused` from any of these**: push no test drive on Cloudflare (pull-requests' "No preview, no test drive" where a preview was proven; else set the preview up as server-previews says, never without saying so) and say under "What was skipped, and why" which cause stopped it, with the `reason`, and name the `page` where there is one; the person was asked in this run, so `left` stays empty. `skipped`, or the GitHub app's access skipped on the wait's screen, with no method saved, is a no that ends the setup, as the playbook says: no other preview is set up.
|
|
21
21
|
- **No `pullRequestPreviews`, no `worker`** (`cli` signed out or unknown, or no Pages project built from this repository among the accounts cf reaches): the check could not be made. Say so in one line under "What is not verified", naming why, and go on as the aim says: nothing listed, as "Nothing listed" says; a preview listed, as the steps say, where the wait for the test drive's build then tells.
|
|
@@ -4,7 +4,7 @@ Every run that saves a preview method, for the state's `before` item: the addres
|
|
|
4
4
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
7
|
-
1. **Find the address**: the `production` address a host tool answered (impeccable_netlify_previews, the Netlify site's own; impeccable_cloudflare_previews, the Pages project's, `https://<project>.pages.dev`; impeccable_render_previews, the Render site's, `https://<site>.onrender.com`; impeccable_hosted_previews, the linked Vercel project's own domain where GitHub records a production deployment of it; impeccable_vercel_connect's `productionUrl` where production exists), else the live-site line the item's `proposal` read in the README, checked against the file, when it is the site's or project's own address on one of the hosts (an address elsewhere is only the host tools' to name). A project or site created in this run has no live app yet. Done when you have one address, or know there is none.
|
|
7
|
+
1. **Find the address**: the `production` address a host tool answered (impeccable_netlify_previews, the Netlify site's own; impeccable_cloudflare_previews, the Pages project's, `https://<project>.pages.dev`, or with no Pages project a Worker's own, `https://<worker>.<subdomain>.workers.dev`; impeccable_render_previews, the Render site's, `https://<site>.onrender.com`; impeccable_hosted_previews, the linked Vercel project's own domain where GitHub records a production deployment of it; impeccable_vercel_connect's `productionUrl` where production exists), else the `liveUrl` the state's `hosting` names for the app (the saved before, or the address Impeccable read in the repository), else the live-site line the item's `proposal` read in the README, checked against the file, when it is the site's or project's own address on one of the hosts (an address elsewhere is only the host tools' to name). A project or site created in this run has no live app yet. Done when you have one address, or know there is none.
|
|
8
8
|
2. **Prove it** with impeccable_prove_preview, under the status line *"Opening the live app"*: that address, or a page on it a visitor opens without signing in, and words the page shows that the checkout holds. A key the run already got in place (Vercel's bypass secret, Netlify's site password, Cloudflare's service token) goes with it where the lock is the same project's or site's. Done when it answers `proven: true`. **An address no key opens is not set**: a `gate`, the app's own sign-in on every page, or no answer leaves the item as it is, and the report says what the address answered.
|
|
9
9
|
3. **Save it** with impeccable_write to `preview`: `beforeUrl` the proven address's root, `https://<host>` with no path, whatever page the proof opened (a login page is one role's, and the review signs in as another; impeccable_write saves the root either way), in the same write as the method where the proof came before it (`{"intendedMethod": "preview-deploy", "beforeUrl": "https://…"}`), else in a write of its own; `appKey` the app's on a repository of several apps, one address per app. Done when the answer carries `beforeUrl`; its `beforeCheck.access` says how Impeccable's own check opened it: `ok`; `auth-wall`, the app's sign-in, which the saved test accounts get past; anything else goes under "What is not verified".
|
|
10
10
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
### When this applies
|
|
2
2
|
|
|
3
|
-
Netlify deploys previews of the pull requests: impeccable_netlify_previews lists a built preview of a pull request (`state` success or ready), with its address. Ask it only where the repository is set up for Netlify (`netlify.toml`, `.netlify/`, `netlify-cli` or an `@netlify/*` package), but its record decides, not the checkout. Netlify builds and serves every preview, so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request.
|
|
3
|
+
Netlify deploys previews of the pull requests: impeccable_netlify_previews lists a built preview of a pull request (`state` success or ready), with its address. Ask it only where the state's `hosting` names Netlify for the app, or the repository is set up for Netlify (`netlify.toml`, `.netlify/`, `netlify-cli` or an `@netlify/*` package), but its record decides, not the checkout. Netlify builds and serves every preview, so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request.
|
|
4
4
|
|
|
5
5
|
It applies as well **before the first pull request**: a site built from this repository has Deploy Previews on (impeccable_netlify_previews lists none, and its `pullRequestPreviews` says `enabled: true`; on a repository with several apps, read with the app's `appKey`), so its deploy previews start with the test drive's draft, the site's first. Then there is nothing to prove in step 1 yet: impeccable_ask with `netlify-setup` is refused, since there is no sign-in, site or connection to make. Run impeccable_netlify_connect (with the app's `appKey`) first: it changes nothing on that site, and checks that Netlify's GitHub app still reads the repository, asking the person once for its access on GitHub when it does not (a connection left undone ends as netlify-setup's "A step fails before the test drive is pushed" says). Then go on to pull-requests' step 4, which opens the test drive as a draft, waits for its deploy preview (impeccable_netlify_previews with `pull`), proves the test drive's page there, past Netlify's lock as preview-protection says, and then step 2 saves the method.
|
|
6
6
|
|
|
@@ -5,11 +5,11 @@ Every run, once the settings are saved. The person merges the setup pull request
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
7
7
|
1. **The setup commit**, when the repository needs a change to keep previews working: the workflow and any fix the build needed, nothing else. With the workflow current on the default branch and no fix, there is none. Check access as workflow-as-person says, branch `impeccable/setup` from `origin/<defaultBranch>`, and bring in the workflow and commit as that case says. Push it in step 7, after the test drive. Done when that case's steps are done.
|
|
8
|
-
2. **The test drive page**, always. The test drive shows the person a whole review: pages to capture, a finding for each measure, and a new page, which is what starts the review's visitor sessions. Branch `impeccable/test-drive` from the setup commit, so it carries the same workflow and its preview runs from its own head (from the default branch when there is no setup commit). Add one small, self-contained page in the repository's own framework, such as `/impeccable-test-drive`: a file where the routing finds pages, its styles in the page's own `<style>` element or imported the way the framework imports them, never a stylesheet linked by a relative path (a host may serve the page at its address without the trailing slash, where `./page.css` misses and the review sees it unstyled), with a heading, a paragraph and a few elements in the app's own words (so the review judges it as one of the app's pages), and the flaws below. Every word a visitor sees, the page's title, heading and copy and the link to it, reads as a plausible real page of the site, such as a short feature or about page in the product's voice: none says it is a test, a demo or a test drive, names Impeccable or a review, or calls its content placeholder or not real, since a visitor who reads that marks the page down. The flaws' comments stay in the code, never on the page. Link it from a page **a visitor opens without signing in**, the landing page when it is one, and keep the new page open without signing in too: no guard, on the server or in the browser, sends it to the sign-in. Done when the new page carries every flaw below with its comment, and the page you picked links to it.
|
|
8
|
+
2. **The test drive page**, always. The test drive shows the person a whole review: pages to capture, a finding for each measure, and a new page, which is what starts the review's visitor sessions. Branch `impeccable/test-drive` from the setup commit, so it carries the same workflow and its preview runs from its own head (from the default branch when there is no setup commit). Add one small, self-contained page in the repository's own framework, such as `/impeccable-test-drive`: a file where the routing finds pages, its styles in the page's own `<style>` element or imported the way the framework imports them, never a stylesheet linked by a relative path (a host may serve the page at its address without the trailing slash, where `./page.css` misses and the review sees it unstyled), with a heading, a paragraph and a few elements in the app's own words (so the review judges it as one of the app's pages), and the flaws below. Every element holds real content, a table its rows, a card its figures, a list its items: no empty container and no note standing in for data, which the review marks down ahead of the flaws. Every word a visitor sees, the page's title, heading and copy and the link to it, reads as a plausible real page of the site, such as a short feature or about page in the product's voice: none says it is a test, a demo or a test drive, names Impeccable or a review, or calls its content placeholder or not real, since a visitor who reads that marks the page down. The flaws' comments stay in the code, never on the page. Link it from a page **a visitor opens without signing in**, the landing page when it is one, and keep the new page open without signing in too: no guard, on the server or in the browser, sends it to the sign-in. Done when the new page carries every flaw below with its comment, and the page you picked links to it.
|
|
9
9
|
3. **Build the test drive branch here**, as the host's case says in place of this step (vercel-previews, netlify-previews, cloudflare-previews, render-previews), so a page that does not build is never pushed; its preview is proven on the host's own build in step 4. Done when the build passed.
|
|
10
10
|
4. **Push the test drive and prove its preview**: a host whose evidence said `pullRequestPreviews.enabled: false` is asked first, once, as its case says (impeccable_enable_previews; skipped or refused, push nothing: "No preview, no test drive" below); on Vercel, impeccable_vercel_bypass with `beforePush: true` (and each app's `appKey`, one call per app) asks for the bypass secret first where Vercel's login will stand in front of the preview, as preview-protection's "Before the test drive's push" says (`skipped`, push nothing); then commit the new page **and** the page that links to it, each staged by name, `git push -u origin impeccable/test-drive`, then the way its preview comes:
|
|
11
11
|
- **Vercel** (vercel-previews, vercel-setup) builds every push: impeccable_hosted_previews with `branch: "impeccable/test-drive"` waits up to five minutes for its deployment of the pushed head, under a status line with its timer. Prove the test drive's page on that address with impeccable_prove_preview (past Vercel's login as preview-protection says), and save the method as the case says. Then step 5.
|
|
12
|
-
- **Netlify** (netlify-previews, netlify-setup) and **Render** (render-previews) build a preview only for a pull request: open the test drive now as a **draft**, step 5's `gh pr create` with `--draft`, which Impeccable leaves unreviewed. impeccable_netlify_previews (on Render, impeccable_render_previews) with `pull` the number it printed waits up to five minutes for the deploy preview, under a status line with its timer. Prove the test drive's page on it, save the method as the case says, then `gh pr ready impeccable/test-drive --repo <owner>/<repo>` marks it ready, which starts the review. Then step 5's recording.
|
|
12
|
+
- **Netlify** (netlify-previews, netlify-setup) and **Render** (render-previews) build a preview only for a pull request: open the test drive now as a **draft**, step 5's `gh pr create` with `--draft`, which Impeccable leaves unreviewed. impeccable_netlify_previews (on Render, impeccable_render_previews) with `pull` the number it printed waits up to five minutes for the deploy preview, under a status line with its timer. Prove the test drive's page on it (on Render, a 404 is render-previews' to handle, never a guessed push), save the method as the case says, then `gh pr ready impeccable/test-drive --repo <owner>/<repo>` marks it ready, which starts the review. Then step 5's recording.
|
|
13
13
|
|
|
14
14
|
A host's wait that ran out shows its own screen, which opens the host's page where its builds are turned on and waits again on the person's yes. Done when the test drive's page is proven on its preview, and a host's method saved; when nothing proved it, the reference below says how the run goes on.
|
|
15
15
|
5. **Open the test drive and record it**: `gh pr create --repo <owner>/<repo> --head impeccable/test-drive --base <defaultBranch>` with the title "Impeccable test drive (safe to close)" and a body that starts "This pull request only shows Impeccable reviewing a change. Close it when you are done; nothing here needs to be merged." and then names the pages its diff holds, no more, and lists the flaws planted for the review to find, one line each (a Netlify draft opened this way in step 4, and was marked ready there). Record it right away with impeccable_test_pr, so the web page follows its review; it refuses a draft, and a branch that changes no existing page. Done when impeccable_test_pr answered "Recorded".
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
### When this applies
|
|
2
2
|
|
|
3
|
-
Render deploys the app: impeccable_render_previews (with the app's `appKey` on a repository with several apps) answers a `site`. Read it wherever the checkout names an `onrender.com` address or a `render.yaml`, impeccable_hosted_previews lists a deployment at an `onrender.com` address, or no other host's tool showed this app, before server-previews. Render builds and serves every preview, a site of its own for each pull request (`https://<site>-pr-<n>.onrender.com`), so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request. The tool finds the site from the render CLI, Render's deployments on GitHub and the checkout's files, and shows the person its one screen itself: **the person never types an address**, and impeccable_ask refuses a question asking for one.
|
|
3
|
+
Render deploys the app: impeccable_render_previews (with the app's `appKey` on a repository with several apps) answers a `site`. Read it wherever the state's `hosting` names Render for the app, the checkout names an `onrender.com` address or a `render.yaml`, impeccable_hosted_previews lists a deployment at an `onrender.com` address, or no other host's tool showed this app, before server-previews. Render builds and serves every preview, a site of its own for each pull request (`https://<site>-pr-<n>.onrender.com`), so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request. The tool finds the site from the render CLI, the state's `hosting`, Render's deployments on GitHub and the checkout's files, and shows the person its one screen itself: **the person never types an address**, and impeccable_ask refuses a question asking for one.
|
|
4
4
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
@@ -15,6 +15,7 @@ Render deploys the app: impeccable_render_previews (with the app's `appKey` on a
|
|
|
15
15
|
|
|
16
16
|
- **The live app**: `production` is the site's own address; live-app-address says how it becomes the before, in step 3's write.
|
|
17
17
|
- **The pull requests**: no setup pull request; the test drive branches from the default branch. Render builds a preview only for a pull request, at once for a draft, so the test drive opens as a draft, impeccable_render_previews with `pull` waits for its preview, the test drive's page is proven on it, the method saved, and then the draft is marked ready: pull-requests' step 4. In place of pull-requests' step 3, build the test drive branch once with the build the service runs (`render.yaml`'s `buildCommand`, else the package's `build` script), so a page that does not build is never pushed.
|
|
18
|
+
- **A 404 on the test drive's page**: Render reports the deploy before its edge serves it, so impeccable_prove_preview asks again itself, for up to 90 seconds. Never push a guessed change to the test drive (a link, an address, a route) to explain a 404, and never wait with a command. Where the proof still answers 404, the page is not in the preview's build: pull-requests' "No preview, no test drive".
|
|
18
19
|
- `pullRequestPreviews` with `enabled: 'unknown'` (no answer from the CLI): say so in one line under "What is not verified" and go on; the wait for the test drive's preview opens Render's page when none comes.
|
|
19
20
|
- The evidence is read only: change nothing on Render beyond what impeccable_render_previews does on the person's yes. Save the pattern the proof answered, and no other.
|
|
20
21
|
- The report names the address that answered with this app under "What we found", and the pattern saved under "What was configured, and by whom". The test drive's own preview, which Render builds after the run, goes under "What is not verified".
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
### When this applies
|
|
2
2
|
|
|
3
|
-
Vercel deploys previews of the pull requests: impeccable_hosted_previews lists a successful Preview deployment that Vercel made (`vercel: true`) of a pull request, with its address. The
|
|
3
|
+
Vercel deploys previews of the pull requests: impeccable_hosted_previews lists a successful Preview deployment that Vercel made (`vercel: true`) of a pull request, with its address. The state's `hosting` naming Vercel for the app, and the checkout (`vercel.json`, `.vercel/project.json`, a `vercel` dependency), say to read it; GitHub's record decides. Vercel builds and serves every preview, so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request.
|
|
4
4
|
|
|
5
5
|
It applies as well **before the first pull request**: Vercel deploys the repository already (impeccable_hosted_previews lists deployments Vercel made, production ones or of pushes, and none of a pull request; on a repository with several apps, those of the project that builds the app, named in `project`), so its previews start with the test drive's push. Then there is nothing to prove in step 1 yet: impeccable_ask with `vercel-setup` is refused, since there is no sign-in, project or connection to make; go on to pull-requests' step 4, which asks for Vercel's bypass secret where the project's previews are protected, pushes the test drive, waits for Vercel's deployment of the pushed branch (impeccable_hosted_previews with `branch`, and the app's `appKey`), proves the test drive's page there, past Vercel's login as preview-protection says, and then step 2 saves the method.
|
|
6
6
|
|
package/dist/playbook.js
CHANGED
|
@@ -31,9 +31,9 @@ Set the repository up with whatever the host's CLI can do (Netlify's, Vercel's,
|
|
|
31
31
|
|
|
32
32
|
# Steps
|
|
33
33
|
|
|
34
|
-
1. **Read the state.** The setup API's instructions and state came with the first message, trimmed to this setup; impeccable_state reads the state again. Done when you know the preview item's status, \`file\` and \`
|
|
34
|
+
1. **Read the state.** The setup API's instructions and state came with the first message, trimmed to this setup; impeccable_state reads the state again. Done when you know the preview item's status, \`file\`, \`workflowStanding\` and \`hosting\` (each app's host, live address and the previews GitHub recorded, as Impeccable already holds them: a start, which the host tools take and the proofs still prove; absent from an older setup API), whether there is a \`signin\` item, whether there is an \`apps\` item (the monorepo case comes first then), and the titles in \`notPartOfThisSetup\`.
|
|
35
35
|
2. **Read the repository**: manifests, the lockfile, scripts, framework config, CI workflows, env examples, the routes, the auth code and the seed. Done when you can name the package manager, the install, build and start commands, whether the pages need a server, how people sign in, and which pages a visitor opens without signing in.
|
|
36
|
-
3. **Set up the preview
|
|
36
|
+
3. **Set up the preview**, starting with the host \`hosting\` names for the app (its case and its tool first; GitHub's record decides, so a named host whose tool lists nothing sends you on to the next): as the vercel-previews case says when impeccable_hosted_previews lists Vercel's previews of the pull requests, as the netlify-previews case says when impeccable_netlify_previews lists Netlify's, or a site of this repository with Deploy Previews on (and as preview-protection says when the host's login or password stands in front of either), as the cloudflare-previews case says when impeccable_cloudflare_previews lists Cloudflare's (a Pages project's, or a Worker's), as the render-previews case says when impeccable_render_previews answers a Render site, as the vercel-setup case says when the checkout is set up for Vercel and Vercel has never deployed it, as the netlify-setup case says when the checkout is set up for Netlify and it lists none, else as the server-previews case says; then the live app's address as the live-app-address case says, in the method's write where it can be. Done when those cases' steps are done.
|
|
37
37
|
4. **Set up the sign-in** as the sign-in case says, when the app has its own. Done when that case's steps are done.
|
|
38
38
|
5. **Run impeccable_checkup**, do what \`next\` says, and run it again. Done when \`status\` is complete, or all that is left is in \`forPerson\` or \`waitingFor\`.
|
|
39
39
|
6. **Open the pull requests** as the pull-requests case says. What waits on the person holds neither back. Done when that case's steps are done.
|
|
@@ -82,7 +82,7 @@ The person reads your [STATUS] lines, task titles and questions, here and on the
|
|
|
82
82
|
- **Questions**: one sentence, at most one hint line, and a title that names the subject.
|
|
83
83
|
|
|
84
84
|
## The report
|
|
85
|
-
impeccable_report ends the run: no tool or model call works after it, so call it once, alone, last. Open the report with the pull requests, one line each with its link: the test drive (each app's, named, on a repository with several apps), which Impeccable is reviewing (its review had not posted when the run ended; the web page shows it) and which the person closes when done, or, when none opened, what stopped it in a few words; then the setup pull request, when there is one, which they merge to keep previews working, or, when a host builds the previews and the repository needed no change, this one line with the host's name: *Nothing to merge: Vercel hosts your previews, so the repository needed no change.* Then these sections:
|
|
85
|
+
impeccable_report ends the run: no tool or model call works after it, so call it once, alone, last. Open the report with the pull requests, one line each with its link: the test drive (each app's, named, on a repository with several apps), which Impeccable is reviewing (its review had not posted when the run ended; the web page shows it) and which the person closes when done, or, when none opened, what stopped it in a few words; then the setup pull request, when there is one, which they merge to keep previews working, or, when a host builds the previews and the repository needed no change, this one line with the host's name: *Nothing to merge: Vercel hosts your previews, so the repository needed no change.* (on a repository with several apps, only when every chosen app is set up on a host's previews, naming each host). Where no preview method was saved for an app, the report says what stopped it and what the person can do, and never that a later pull request will be reviewed: nothing reviews it until a method is saved. impeccable_report's \`reason\` (on a repository with several apps, each such app's in \`apps\`) says that in one line, which the last screen shows. Then these sections:
|
|
86
86
|
|
|
87
87
|
\`\`\`markdown
|
|
88
88
|
## What we found
|
package/dist/tools.js
CHANGED
|
@@ -13,10 +13,11 @@ import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps }
|
|
|
13
13
|
import { APPS_QUESTION, cliInstallScreen, cliManualScreen, cloudflareAccessConsent, cloudflareAccessPaste, cloudflareAccessReusable, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, NETLIFY_PASSWORD_WHERE, netlifyPasswordReplace, renderConfirm, sentTo, startedIn, vercelBypassConsent, vercelBypassPaste } from './host-questions.js';
|
|
14
14
|
import { enableNetlifyPreviews, folderOf } from './host-previews.js';
|
|
15
15
|
import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews, readmeNamesApp, renderSiteOf, vercelDeploymentOf } from './hosted.js';
|
|
16
|
+
import { deploymentsOf, hostingApp, liveHostOf, renderSitesOf } from './hosting.js';
|
|
16
17
|
import { enableRenderPreviews, parseRenderDeployment, readRenderService, RENDER_DASHBOARD, renderSitesNamed } from './render.js';
|
|
17
|
-
import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
|
|
18
|
+
import { cloudflareFromHosting, cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
|
|
18
19
|
import { passCloudflareAccess } from './cloudflare-access.js';
|
|
19
|
-
import { commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
|
|
20
|
+
import { netlifyFromHosting, commandRunner, connectNetlifySite, liveSite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
|
|
20
21
|
import { netlifyToken, passNetlifyLock, setSitePassword } from './netlify-password.js';
|
|
21
22
|
import { connectVercelProject, lockedDeploymentHosts, prepareFirstDeployment, projectMadeDeployment, readLinkedProject, readOwnProject, vercelDashboardScope, vercelPreviewsFact, vercelProjectFor, vercelProjectPage, vercelRepoProject, vercelStatus, vercelTeams, waitForVercelAccess, waitForVercelLock } from './vercel.js';
|
|
22
23
|
import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
|
|
@@ -28,6 +29,7 @@ import { openBrowser } from './login.js';
|
|
|
28
29
|
import { STATUS_MAX, statusVoiceError, TASK_STATUSES } from './sync.js';
|
|
29
30
|
import { askPerson, reportDone, reportStatus, reportTasks, reportWait } from './ui/bridge.js';
|
|
30
31
|
import { SEE_THE_REVIEW } from './ui/look.js';
|
|
32
|
+
import { TaskOrder } from './ui/run-state.js';
|
|
31
33
|
import { noteSensitiveAnswer, plainText } from './ui/sensitive.js';
|
|
32
34
|
export { STATUS_MAX, statusVoiceError } from './sync.js';
|
|
33
35
|
/** The first way `value` breaks `schema`, or null when it fits. */
|
|
@@ -129,6 +131,8 @@ export const SETUP_ENDS = {
|
|
|
129
131
|
};
|
|
130
132
|
/** How many times a preview check the setup API turned away for its rate (one a repository every 30 seconds) is tried again (#338). */
|
|
131
133
|
const CHECK_RETRIES = 3;
|
|
134
|
+
/** The setup API signs a repository's saved accounts in once a minute: a save sooner is stored but not tried (#249). */
|
|
135
|
+
const SIGN_IN_CHECK_MS = 60_000;
|
|
132
136
|
/** The last screen's words for a run on several apps: the picked apps not set up, by name, the line naming which are and which are not, and a line for each that is not. */
|
|
133
137
|
export function appsSummary(apps) {
|
|
134
138
|
const picked = apps.filter((a) => a.state !== 'unpicked');
|
|
@@ -161,10 +165,12 @@ function stageError(status, tasks) {
|
|
|
161
165
|
}
|
|
162
166
|
/** 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). */
|
|
163
167
|
export function resultLine(report) {
|
|
168
|
+
// The run's own words for why no method was saved, where it gave them (REN-1108).
|
|
169
|
+
const why = report.reason && !report.methodSaved ? ` Why: ${report.reason.replace(/[.\s]+$/, '')}.` : '';
|
|
164
170
|
if (report.ended)
|
|
165
|
-
return SETUP_ENDS[report.ended].line
|
|
171
|
+
return `${SETUP_ENDS[report.ended].line}${why}`;
|
|
166
172
|
const lead = report.walled ? `Impeccable's own check of the preview met a wall (${report.walled}), so the test drive's review can't see its pages yet. The report, printed here when this screen closes, says what the run found.`
|
|
167
|
-
: !report.testPr ? `${report.unhonoured ?? noTestDrive(report)} The report, printed here when this screen closes, says what the run found.` : report.setupPr ? 'The setup pull request adds the workflow that lets Impeccable run on your previews. Merge it when you are ready.' : SEE_THE_REVIEW;
|
|
173
|
+
: !report.testPr ? `${report.unhonoured ?? noTestDrive(report)}${why} 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;
|
|
168
174
|
// --signed-in-test-drive left an app without a test drive: said beside another app's drive or a wall too (#410).
|
|
169
175
|
const said = report.unhonoured && (report.walled || report.testPr) ? `${lead} ${report.unhonoured}` : lead;
|
|
170
176
|
return report.setupPr || !report.hostedBy ? said : `${said} ${nothingToMerge(report.hostedBy)}`;
|
|
@@ -198,7 +204,13 @@ export function ending(report) {
|
|
|
198
204
|
return { outcome, title, todo, code: 1 };
|
|
199
205
|
}
|
|
200
206
|
/** Said once, here and in the report, when the host builds the previews and the repository needed no change. */
|
|
201
|
-
export const nothingToMerge = (host) => `Nothing to merge: ${host} hosts your previews, so the repository needed no change.`;
|
|
207
|
+
export const nothingToMerge = (host) => `Nothing to merge: ${host} ${host.includes(' and ') ? 'host' : 'hosts'} your previews, so the repository needed no change.`;
|
|
208
|
+
/** The agent's words for why an app was not set up, kept only as plain words of at most 200 characters, with no address (a scheme, or a host with a path or query) and nothing shaped like a secret. */
|
|
209
|
+
function plainReason(text) {
|
|
210
|
+
const plain = text.trim();
|
|
211
|
+
const address = /[a-z][a-z0-9+.-]*:\/\/|[a-z0-9-]+(?:\.[a-z0-9-]+)+[/?#]/i.test(plain);
|
|
212
|
+
return plain && plain.length <= 200 && plainText(plain) === plain && !address && !scan('output', plain).length ? plain : undefined;
|
|
213
|
+
}
|
|
202
214
|
/** Names as a reader lists them: "a", "a and b", "a, b and c". */
|
|
203
215
|
const andList = (names) => (names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names.join(''));
|
|
204
216
|
/** The end screen's one line for the Netlify sites whose password the run set and saved, set but did not save, or saved as it was: every site in one line (#419). */
|
|
@@ -220,7 +232,7 @@ export function signedInLine(saved, app) {
|
|
|
220
232
|
const signed = accounts.filter((a) => a.outcome === 'signed-in' && typeof a.role === 'string').map(role);
|
|
221
233
|
const not = accounts.filter((a) => a.outcome !== 'signed-in' && typeof a.role === 'string').map(role);
|
|
222
234
|
if (!signed.length)
|
|
223
|
-
return undefined;
|
|
235
|
+
return not.length ? `${app ? `${app}: ` : ''}${andList(not)} did not sign in` : undefined;
|
|
224
236
|
return `${app ? `${app}: signed` : 'Signed'} in as ${andList(signed)}${not.length ? `; ${andList(not)} did not` : ''}`;
|
|
225
237
|
}
|
|
226
238
|
/** The last screen's line for each host, in the order first met: "Cloudflare: Acme's Account · project crewline", "Netlify: team Acme · site crewline", "Vercel: team acme · project crewline" (#360). */
|
|
@@ -268,9 +280,10 @@ const REPORT = {
|
|
|
268
280
|
description: `What waits on the person besides merging the setup pull request and closing the test drive: one line each, at most ${STATUS_MAX} characters, such as "Add the admin test account on the setup page". The terminal counts them. Leave it out when nothing does.`,
|
|
269
281
|
items: { type: 'string', minLength: 1 },
|
|
270
282
|
},
|
|
283
|
+
reason: { type: 'string', minLength: 1, description: 'On a repository of one app where no preview method was saved: why, in plain words of at most 200 characters, such as "Render\'s pull request previews are off, and the person skipped turning them on". The last screen shows it. Names only, never an address or a key.' },
|
|
271
284
|
apps: {
|
|
272
285
|
type: 'array',
|
|
273
|
-
description: 'On a repository with several apps: for each picked app whose preview was not proven or keyed, why, in plain words of at most 200 characters, such as "not checked: the person stopped after the first app". Names only, never an address or a key.',
|
|
286
|
+
description: 'On a repository with several apps: for each picked app whose preview was not proven or keyed, why, in plain words of at most 200 characters, such as "not checked: the person stopped after the first app". The last screen shows it on that app\'s line. Names only, never an address or a key.',
|
|
274
287
|
items: { type: 'object', additionalProperties: false, required: ['appKey', 'reason'], properties: { appKey: { type: 'string', minLength: 1 }, reason: { type: 'string', minLength: 1 } } },
|
|
275
288
|
},
|
|
276
289
|
},
|
|
@@ -1632,6 +1645,8 @@ export function createTools(ctx) {
|
|
|
1632
1645
|
const budget = () => ({ used: retry.used, pushReservations: retry.pushReservations, remaining: Math.max(0, 3 - retry.used), historyKnown: retry.historyKnown });
|
|
1633
1646
|
const attemptTasks = () => attempts().map((a, i) => ({ id: `preview-attempt-${a.runId}-${a.attempt}`,
|
|
1634
1647
|
title: `Preview job attempt ${i + 1}`, status: a.status === 'success' ? 'completed' : a.status === 'running' ? 'in_progress' : 'failed' }));
|
|
1648
|
+
/** The task list's order, the terminal's and the web page's alike (REN-1119). */
|
|
1649
|
+
const taskOrder = new TaskOrder();
|
|
1635
1650
|
const withAttempts = (tasks) => [...tasks.filter((t) => !t.id.startsWith('preview-attempt-')), ...attemptTasks()];
|
|
1636
1651
|
async function observe(signal) {
|
|
1637
1652
|
const pr = retry.pr;
|
|
@@ -1663,6 +1678,8 @@ export function createTools(ctx) {
|
|
|
1663
1678
|
/** The origins of Production deployments Vercel made of a pushed branch that no locked project of this folder's made: served at a project's own address without a login, so no proof opens them. */
|
|
1664
1679
|
const publicProduction = new Set();
|
|
1665
1680
|
const cloudflareListed = new Set();
|
|
1681
|
+
/** The address pattern of each Worker preview's origin Cloudflare's evidence listed, which the address alone cannot tell (#265), and of the Worker's own address it answered as production, the token scope they share: kept past a later read, for the last screen. */
|
|
1682
|
+
const workerPatterns = new Map();
|
|
1666
1683
|
/** The origins of the successful Render previews impeccable_render_previews listed, of the app's own site. */
|
|
1667
1684
|
const renderListed = new Set();
|
|
1668
1685
|
let hostedPreviewProven = false;
|
|
@@ -1705,6 +1722,9 @@ export function createTools(ctx) {
|
|
|
1705
1722
|
const label = pagesProject(origin);
|
|
1706
1723
|
if (label)
|
|
1707
1724
|
return [{ host: 'Cloudflare', project: label, ...(accountOf.has(`cloudflare ${label}`) ? { account: accountOf.get(`cloudflare ${label}`) } : {}) }];
|
|
1725
|
+
const worker = /^https:\/\/\{branch\}-([^.]+)\./.exec(workerPatterns.get(origin) ?? '')?.[1];
|
|
1726
|
+
if (worker)
|
|
1727
|
+
return [{ host: 'Cloudflare', project: worker }];
|
|
1708
1728
|
const site = netlifySiteOf(url);
|
|
1709
1729
|
if (site)
|
|
1710
1730
|
return [{ host: 'Netlify', project: site, ...(accountOf.has(`netlify ${site}`) ? { account: accountOf.get(`netlify ${site}`) } : {}) }];
|
|
@@ -2371,8 +2391,22 @@ export function createTools(ctx) {
|
|
|
2371
2391
|
return proof;
|
|
2372
2392
|
};
|
|
2373
2393
|
/** A proof opened the app's preview at `url`, so its method may be saved. */
|
|
2394
|
+
/** On a repository with several apps, a Preview method task done while a picked app has no method saved names whose it is, so its tick does not read as every app's (REN-1108). */
|
|
2395
|
+
const labelled = (tasks) => {
|
|
2396
|
+
if (!appsRepo)
|
|
2397
|
+
return tasks;
|
|
2398
|
+
const saved = [...(pickedApps ?? [])].filter((key) => appMethods.has(key));
|
|
2399
|
+
const some = saved.length > 0 && saved.length < pickedApps.size;
|
|
2400
|
+
// A label of an earlier list goes once every picked app has its method.
|
|
2401
|
+
return tasks.map((t) => (t.status !== 'completed' || !stagesOf(t.title).includes('Preview method') ? t : { ...t, title: some ? `Preview method: ${andList(saved.map(appName))} only` : t.title.replace(/^Preview method: .* only$/, 'Preview method') }));
|
|
2402
|
+
};
|
|
2403
|
+
/** On a repository with several apps, the host whose preview a proof opened for each app: the last screen names them all, or none where an app is not set up (REN-1108). */
|
|
2404
|
+
const appHosts = new Map();
|
|
2374
2405
|
const proveApp = (key, url) => {
|
|
2375
2406
|
provenApps.add(key);
|
|
2407
|
+
const host = previewHostName(url);
|
|
2408
|
+
if (host)
|
|
2409
|
+
appHosts.set(key, host);
|
|
2376
2410
|
noteApp(key, { preview_proven: true, preview_host: new URL(url).hostname });
|
|
2377
2411
|
};
|
|
2378
2412
|
/** Each app's folder, by key, as the state names it: what its host project or site must build. */
|
|
@@ -2421,6 +2455,14 @@ export function createTools(ctx) {
|
|
|
2421
2455
|
};
|
|
2422
2456
|
/** An app's folder, read from the state the first time it is asked for. */
|
|
2423
2457
|
const appFolder = async (pi, key) => (appFolders.has(key) ? appFolders.get(key) : (await stateApps(pi), appFolders.get(key)));
|
|
2458
|
+
/** The setup state's record of an app's hosting (hosting.ts), as the state read at the start carried it; none from an older setup API, and every host tool then reads GitHub itself. */
|
|
2459
|
+
const hostingFor = (appKey) => hostingApp(ctx.hosting, appKey);
|
|
2460
|
+
// The live address the record names is a candidate for the before, proven as a host tool's production address is, on the host its domain says.
|
|
2461
|
+
for (const a of ctx.hosting?.apps ?? []) {
|
|
2462
|
+
const host = liveHostOf(a);
|
|
2463
|
+
if (host)
|
|
2464
|
+
noteProduction(a.liveUrl, host, undefined, appsRepo ? a.key : undefined);
|
|
2465
|
+
}
|
|
2424
2466
|
/**
|
|
2425
2467
|
* On a repository with several apps, why a step for `appKey` may not run yet: the apps question comes first, and only
|
|
2426
2468
|
* a picked app is set up. Null where it may: one app, or a picked one.
|
|
@@ -2671,7 +2713,7 @@ export function createTools(ctx) {
|
|
|
2671
2713
|
}
|
|
2672
2714
|
if (recordedChanges)
|
|
2673
2715
|
previews.changes.clear();
|
|
2674
|
-
const tasks = withAttempts(ctx.sync.payload.tasks);
|
|
2716
|
+
const tasks = taskOrder.sort(withAttempts(ctx.sync.payload.tasks));
|
|
2675
2717
|
ctx.sync.update({ tasks });
|
|
2676
2718
|
reportTasks(tasks);
|
|
2677
2719
|
return JSON.stringify({ ...observation, attempts: attempts(), budget: budget() });
|
|
@@ -2699,7 +2741,7 @@ export function createTools(ctx) {
|
|
|
2699
2741
|
}
|
|
2700
2742
|
const saved = object(p.body);
|
|
2701
2743
|
if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !== provenPatterns.get(appsRepo ? String(saved.appKey) : ''))) {
|
|
2702
|
-
return `Refused: the only address pattern saved is the one impeccable_prove_preview answered for ${appsRepo ? `this app's (${String(saved.appKey)})` : 'a'} Netlify, Cloudflare
|
|
2744
|
+
return `Refused: the only address pattern saved is the one impeccable_prove_preview answered for ${appsRepo ? `this app's (${String(saved.appKey)})` : 'a'} Netlify, Cloudflare or Render preview. Nothing was saved.`;
|
|
2703
2745
|
}
|
|
2704
2746
|
// 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.
|
|
2705
2747
|
if (target.url.endsWith('/preview') && saved && 'beforeUrl' in saved && (typeof saved.beforeUrl !== 'string' || !URL.canParse(saved.beforeUrl) || !provenLive.has(new URL(saved.beforeUrl).origin))) {
|
|
@@ -2734,14 +2776,48 @@ export function createTools(ctx) {
|
|
|
2734
2776
|
let response = await call(pi, target.url, { method: target.method, body });
|
|
2735
2777
|
// The setup API checks a repository's preview once per 30 seconds, whichever app asks: the next app's check waits its turn here, a bounded wait in place of the sleep the fence refuses (#338).
|
|
2736
2778
|
const check = /\/(connection-check|latest-pull-check)$/.test(target.url);
|
|
2779
|
+
const pause = ctx.checkWait ?? ((ms, stop) => sleep(ms, undefined, { signal: stop }));
|
|
2737
2780
|
for (let tries = 0; check && response.status === 429 && tries < CHECK_RETRIES && !signal?.aborted; tries++) {
|
|
2738
2781
|
const seconds = Math.min(Math.max(Math.ceil(response.retryAfter ?? 30), 1), 60);
|
|
2739
2782
|
said(`Waiting ${seconds} s for the next preview check`);
|
|
2740
|
-
await (
|
|
2783
|
+
await pause(seconds * 1000, signal).catch(() => undefined);
|
|
2741
2784
|
if (signal?.aborted)
|
|
2742
2785
|
break;
|
|
2743
2786
|
response = await call(pi, target.url, { method: target.method, body });
|
|
2744
2787
|
}
|
|
2788
|
+
// A password the save's sign-in turned away is asked for once more, on the same account screen, and the list saved again once the setup API can sign in again (#249).
|
|
2789
|
+
const tried = object(object(response.data)?.signInCheck)?.accounts;
|
|
2790
|
+
const turnedAway = new Set(testAccounts && response.ok && Array.isArray(tried) ? tried.filter((a) => object(a)?.outcome === 'wrong-credentials').map((a) => String(object(a)?.role)) : []);
|
|
2791
|
+
if (turnedAway.size) {
|
|
2792
|
+
const checkedAt = Date.now();
|
|
2793
|
+
let retyped = false;
|
|
2794
|
+
for (const account of object(body)?.accounts) {
|
|
2795
|
+
const role = String(account.role ?? 'default');
|
|
2796
|
+
if (!turnedAway.has(role))
|
|
2797
|
+
continue;
|
|
2798
|
+
const answer = await ask({ id: `${role}-account`, kind: 'account', title: role === 'default' ? 'Test account' : `${role.charAt(0).toUpperCase()}${role.slice(1)} account`, prompt: `Add the ${role === 'default' ? '' : `${role} `}test account.`, hint: `That password did not sign in${typeof account.loginPage === 'string' ? ` at ${account.loginPage}` : ''}. Type it again, or skip.`, prefill: String(account.username ?? '') });
|
|
2799
|
+
// A stop closes the screen: no other account is asked for, and nothing is saved again.
|
|
2800
|
+
if (answer.closed || signal?.aborted) {
|
|
2801
|
+
retyped = false;
|
|
2802
|
+
break;
|
|
2803
|
+
}
|
|
2804
|
+
if (answer.skipped)
|
|
2805
|
+
continue;
|
|
2806
|
+
// Kept with what the person typed, so the answer never carries it back to the model.
|
|
2807
|
+
typed.set(`secret:${randomBytes(6).toString('hex')}`, answer.password ?? '');
|
|
2808
|
+
Object.assign(account, { username: answer.username, password: answer.password });
|
|
2809
|
+
retyped = true;
|
|
2810
|
+
}
|
|
2811
|
+
const wait = SIGN_IN_CHECK_MS - (Date.now() - checkedAt);
|
|
2812
|
+
if (retyped && wait > 0) {
|
|
2813
|
+
said(`Waiting ${Math.ceil(wait / 1000)} s for the next sign-in check`);
|
|
2814
|
+
await pause(wait, signal).catch(() => undefined);
|
|
2815
|
+
}
|
|
2816
|
+
// A second save that fails leaves the first one's answer: those accounts are saved.
|
|
2817
|
+
const again = retyped && !signal?.aborted ? await call(pi, target.url, { method: target.method, body }) : undefined;
|
|
2818
|
+
if (again?.ok)
|
|
2819
|
+
response = again;
|
|
2820
|
+
}
|
|
2745
2821
|
if (testAccounts && response.ok)
|
|
2746
2822
|
retry.signedIn?.saved.add(appsRepo ? String(object(p.body)?.appKey) : '');
|
|
2747
2823
|
// The accounts the save signed in with, said on screen: the tasks show no status line (#417).
|
|
@@ -2888,7 +2964,6 @@ export function createTools(ctx) {
|
|
|
2888
2964
|
}),
|
|
2889
2965
|
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) => {
|
|
2890
2966
|
const status = p.status.trim();
|
|
2891
|
-
const tasks = withAttempts(p.tasks);
|
|
2892
2967
|
const own = p.tasks.filter((t) => !t.id.startsWith('preview-attempt-'));
|
|
2893
2968
|
const voice = statusVoiceError(status) ?? stageError(status, own);
|
|
2894
2969
|
if (voice)
|
|
@@ -2903,6 +2978,8 @@ export function createTools(ctx) {
|
|
|
2903
2978
|
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')) : [];
|
|
2904
2979
|
if (unneeded.length)
|
|
2905
2980
|
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.`;
|
|
2981
|
+
// Ordered only once taken, so a refused list leaves the order as it was.
|
|
2982
|
+
const tasks = taskOrder.sort(labelled(withAttempts(p.tasks)));
|
|
2906
2983
|
ctx.sync.update({ phase: 'running', status, tasks });
|
|
2907
2984
|
reportStatus(status);
|
|
2908
2985
|
reportTasks(tasks);
|
|
@@ -3052,13 +3129,24 @@ export function createTools(ctx) {
|
|
|
3052
3129
|
if (a.ended)
|
|
3053
3130
|
noteApp(a.key, { reason: `The setup ended for it: the person said no to ${SETUP_ENDS[a.ended].said}.` });
|
|
3054
3131
|
for (const { appKey, reason } of p.apps ?? []) {
|
|
3055
|
-
const plain = reason
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3132
|
+
const plain = plainReason(reason);
|
|
3133
|
+
if (!plain)
|
|
3134
|
+
continue;
|
|
3135
|
+
noteApp(appKey, { reason: plain });
|
|
3136
|
+
// The last screen says why on the app's own line, rather than sending the person to the report (REN-1108).
|
|
3137
|
+
const app = apps?.find((a) => a.key === appKey && a.state === 'unset' && !a.ended);
|
|
3138
|
+
if (app)
|
|
3139
|
+
app.left = `${app.name}: ${plain.replace(/[.\s]+$/, '')}.`;
|
|
3059
3140
|
}
|
|
3141
|
+
const reason = !appsRepo && typeof p.reason === 'string' ? plainReason(p.reason) : undefined;
|
|
3142
|
+
// The hosts build every preview only where every picked app is set up with a host's previews: on a repository where one app is not, no host speaks for it (REN-1108).
|
|
3143
|
+
const hostsOf = picked?.every((a) => a.state === 'ready' && appMethods.get(a.key) === 'preview-deploy' && appHosts.has(a.key)) ? [...new Set(picked.map((a) => appHosts.get(a.key)))] : [];
|
|
3144
|
+
const hostedBy = apps ? (hostsOf.length ? andList(hostsOf) : undefined) : savedHost;
|
|
3060
3145
|
// The web page lists what is left too, within the Worker's 20 lines (REPORT and the check above keep each line non-empty and short).
|
|
3061
|
-
|
|
3146
|
+
// The list as the run left the apps: a Preview method done for some apps only says whose (REN-1108).
|
|
3147
|
+
const tasks = taskOrder.sort(labelled(ctx.sync.payload.tasks));
|
|
3148
|
+
reportTasks(tasks);
|
|
3149
|
+
ctx.sync.update({ tasks, phase: 'completed', status: ended ? SETUP_ENDS[ended].title : 'Setup finished', question: null, report_markdown: markdown, test_pr: drive, ...(several ? { test_prs: Object.fromEntries(kept) } : {}), left: left.slice(0, 20) });
|
|
3062
3150
|
const res = await ctx.sync.flush();
|
|
3063
3151
|
if (res && res.status >= 400 && res.status < 500) {
|
|
3064
3152
|
// A definite no: the run is still open on the server, so the session goes back to what it was and the agent can send the report again.
|
|
@@ -3073,7 +3161,7 @@ export function createTools(ctx) {
|
|
|
3073
3161
|
}
|
|
3074
3162
|
// Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
|
|
3075
3163
|
const hosts = hostLines(hostsUsed());
|
|
3076
|
-
ctx.onReport({ markdown, left, testPr: drive, ...(several ? { testPrs: kept.map(([key, pr]) => ({ appKey: key, name: appName(key), ...pr })) } : {}), setupPr: ctx.sync.payload.setup_pr, ...(unhonoured ? { unhonoured } : {}), ...(
|
|
3164
|
+
ctx.onReport({ markdown, left, testPr: drive, ...(several ? { testPrs: kept.map(([key, pr]) => ({ appKey: key, name: appName(key), ...pr })) } : {}), setupPr: ctx.sync.payload.setup_pr, ...(unhonoured ? { unhonoured } : {}), ...(hostedBy ? { hostedBy } : {}), ...(reason ? { reason } : {}), ...(apps ? { apps } : {}), methodSaved, ...(lastLock && !hostedPreviewProven ? { lock: lastLock === 'netlify-protection' && lastTeamLogin ? 'netlify-login' : lastLock } : {}), ...(!apps && checkWalls.size ? { walled: [...checkWalls.values()].at(-1) } : {}), ...(hosts.length ? { hosts } : {}), ...(ended ? { ended } : {}) });
|
|
3077
3165
|
pi.shutdown();
|
|
3078
3166
|
const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
|
|
3079
3167
|
return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
|
|
@@ -3185,7 +3273,7 @@ export function createTools(ctx) {
|
|
|
3185
3273
|
noteApp(appKey, { host_project: app.name });
|
|
3186
3274
|
return JSON.stringify({ ...(appKey !== undefined ? { appKey, project: app.name } : {}), ...evidence, ...(production ? { production } : {}) });
|
|
3187
3275
|
}),
|
|
3188
|
-
tool('impeccable_netlify_previews', 'Netlify previews', "Read whether this repository's pull requests get Netlify deploy previews: the 10 newest, each with its site, its address, its state and where that was read: GitHub's commit status or Netlify's bot comment on the pull request, or the Netlify CLI on this machine when it is signed in (read only: it never signs in). production is the live site's own address, for the before. cli says whether that CLI is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent. With pull (a pull request of this run, a draft included: Netlify builds a deploy preview only for a pull request), only that pull request's, waiting up to five minutes for one to finish building, under a status line with its timer, and again on the person's yes when the time ran out. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
|
|
3276
|
+
tool('impeccable_netlify_previews', 'Netlify previews', "Read whether this repository's pull requests get Netlify deploy previews: the 10 newest, each with its site, its address, its state and where that was read: GitHub's commit status or Netlify's bot comment on the pull request (from the setup state's hosting record where it holds them, else read from GitHub here), or the Netlify CLI on this machine when it is signed in (read only: it never signs in). production is the live site's own address, for the before. cli says whether that CLI is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent. With pull (a pull request of this run, a draft included: Netlify builds a deploy preview only for a pull request), only that pull request's, waiting up to five minutes for one to finish building, under a status line with its timer, and again on the person's yes when the time ran out. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
|
|
3189
3277
|
const pull = p.pull;
|
|
3190
3278
|
const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
|
|
3191
3279
|
if (refused)
|
|
@@ -3197,7 +3285,10 @@ export function createTools(ctx) {
|
|
|
3197
3285
|
return JSON.stringify({ appKey, sites: [], previews: [], unmatched: `The setup state names no folder for the app ${appKey}.` });
|
|
3198
3286
|
// Each site's team is for the last screen alone.
|
|
3199
3287
|
const onTeams = (teams) => Object.entries(teams).forEach(([name, team]) => accountOf.set(`netlify ${name}`, team));
|
|
3200
|
-
|
|
3288
|
+
// GitHub's record as the setup state holds it, where it lists Netlify's previews of this app; a wait on a pull request reads GitHub, since its preview is newer than the record.
|
|
3289
|
+
const known = pull === undefined ? hostingFor(appKey) : undefined;
|
|
3290
|
+
const github = known ? netlifyFromHosting(known) : null;
|
|
3291
|
+
const read = () => (ctx.netlifyPreviewReader ?? readNetlifyPreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, onTeams, ...(directory !== undefined ? { directory } : {}), ...(github ? { github } : {}) });
|
|
3201
3292
|
// A pull request's preview is still building until Netlify's deploy of it is built or has failed.
|
|
3202
3293
|
const states = ['success', 'ready', 'failure', 'error'];
|
|
3203
3294
|
const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
|
|
@@ -3234,7 +3325,7 @@ export function createTools(ctx) {
|
|
|
3234
3325
|
noteApp(appKey, { host_project: site });
|
|
3235
3326
|
return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
|
|
3236
3327
|
}),
|
|
3237
|
-
tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts, or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in; where cf is not installed here, the person is asked to install it first, on its own screen). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds),
|
|
3328
|
+
tool('impeccable_cloudflare_previews', 'Cloudflare Pages previews', "Read whether this repository's pull requests get Cloudflare Pages previews: the 10 newest, each with its address, the branch's alias (https://<branch>.<project>.pages.dev, which follows the branch's latest build), its state and where that was read: the check run or the pull request comment Cloudflare's GitHub app posts (from the setup state's hosting record where it holds them, else read from GitHub here), or cf, Cloudflare's CLI, on this machine when it is signed in (read only: it never signs in; where cf is not installed here, the person is asked to install it first, on its own screen). production is the project's own address, https://<project>.pages.dev, the live app, for the before. cli says whether cf is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent, and unread says whose: GitHub or cf. pullRequestPreviews says whether the Pages project built from this repository builds previews of pull requests at all (enabled false, with the cause: source, deployments or previews); worker says Cloudflare builds a Worker from the repository (Workers Builds); where its branch builds make previews, they are listed too, on workers.dev (the alias https://<branch>-<worker>.<subdomain>.workers.dev, with the pattern it stands for), and with no Pages project production is the Worker's own address; githubApp says what gh reads of Cloudflare's GitHub app on the owner, and the page where it gets access. With pull (a pull request of this run) or branch (a branch of this run, pushed, before its pull request exists), only that one's, waiting up to five minutes for its build to finish, under a status line with its timer, and again on the person's yes when the time ran out; where the project's settings build every branch and no build came, the wait's screen opens the GitHub page where Cloudflare's app gets access to the repository instead, and waits again. It changes nothing on Cloudflare and proves nothing about the page.", CLOUDFLARE_PREVIEWS, async (p, pi, signal) => {
|
|
3238
3329
|
const pull = p.pull;
|
|
3239
3330
|
const branch = p.branch;
|
|
3240
3331
|
if (pull !== undefined && branch !== undefined)
|
|
@@ -3252,7 +3343,12 @@ export function createTools(ctx) {
|
|
|
3252
3343
|
return `Refused: read impeccable_cloudflare_previews with appKey ${appKey} and no pull or branch first: it tells which Pages project builds that app.`;
|
|
3253
3344
|
// The name of the account the project was found in is for the last screen alone.
|
|
3254
3345
|
let accountName;
|
|
3255
|
-
|
|
3346
|
+
// A Worker Preview's build names no address: its alias is made from the pattern the plain read's Worker preview stood for.
|
|
3347
|
+
const pattern = cloudflareEvidence?.previews.find((preview) => preview.pattern)?.pattern;
|
|
3348
|
+
// GitHub's record as the setup state holds it, where it lists Cloudflare's previews of this app; a wait on a pull request or a pushed branch reads GitHub, since its build is newer than the record.
|
|
3349
|
+
const known = pull === undefined && branch === undefined ? hostingFor(appKey) : undefined;
|
|
3350
|
+
const github = known ? cloudflareFromHosting(known) : null;
|
|
3351
|
+
const readAll = () => (ctx.cloudflarePreviewReader ?? readCloudflarePreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull, branch, onAccountName: (name) => (accountName = name), ...(directory !== undefined && pull === undefined && branch === undefined ? { directory } : {}), ...(appProject ? { project: appProject.project } : {}), ...(pattern ? { pattern } : {}), ...(github ? { github } : {}) });
|
|
3256
3352
|
const read = async () => {
|
|
3257
3353
|
const evidence = await readAll();
|
|
3258
3354
|
return appProject ? { ...evidence, previews: evidence.previews.filter((preview) => pagesProject(preview.url) === appProject.label) } : evidence;
|
|
@@ -3265,7 +3361,7 @@ export function createTools(ctx) {
|
|
|
3265
3361
|
// unless it covers them all, or Cloudflare built a preview of the repository already, which it could not without the app.
|
|
3266
3362
|
const app = cloudflareEvidence?.githubApp;
|
|
3267
3363
|
const access = branch !== undefined && previewFacts.get('cloudflare')?.enabled === true && app?.repositories !== 'all' && !cloudflareEvidence?.previews.length ? { page: app?.page ?? CLOUDFLARE_APP_PAGE, now: app?.installed === false, ...(typeof p.appKey === 'string' ? { app: p.appKey } : {}) } : undefined;
|
|
3268
|
-
let waited = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare Pages to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal, access) : await read();
|
|
3364
|
+
let waited = subject ? await awaitBuild('Cloudflare', `Waiting for Cloudflare ${pattern ? 'Workers' : 'Pages'} to build ${subject}`, read, final, () => 'https://dash.cloudflare.com/?to=/:account/workers-and-pages', signal, access) : await read();
|
|
3269
3365
|
// cf not on this machine (#396): its install is asked before anything installs it, and the evidence read again with it.
|
|
3270
3366
|
if (!subject && waited.cli === 'missing') {
|
|
3271
3367
|
await installCf(pi, signal);
|
|
@@ -3292,10 +3388,16 @@ export function createTools(ctx) {
|
|
|
3292
3388
|
continue;
|
|
3293
3389
|
for (const address of [preview.url, ...(preview.alias ? [preview.alias] : [])]) {
|
|
3294
3390
|
cloudflareListed.add(new URL(address).origin);
|
|
3391
|
+
if (preview.pattern)
|
|
3392
|
+
workerPatterns.set(new URL(address).origin, preview.pattern);
|
|
3295
3393
|
if (appKey !== undefined)
|
|
3296
3394
|
listedApps.set(new URL(address).origin, { appKey });
|
|
3297
3395
|
}
|
|
3298
3396
|
}
|
|
3397
|
+
// The Worker's own address, the live app with no Pages project, is in its previews' scope: Access's token goes there as it goes to a Pages project's own address.
|
|
3398
|
+
const workerPattern = evidence.previews.find((preview) => preview.pattern)?.pattern;
|
|
3399
|
+
if (workerPattern && !evidence.project && evidence.production)
|
|
3400
|
+
workerPatterns.set(new URL(evidence.production).origin, workerPattern);
|
|
3299
3401
|
if (evidence.pullRequestPreviews)
|
|
3300
3402
|
previewFacts.set('cloudflare', evidence.pullRequestPreviews);
|
|
3301
3403
|
const named = evidence.pullRequestPreviews?.project || evidence.project;
|
|
@@ -3304,7 +3406,7 @@ export function createTools(ctx) {
|
|
|
3304
3406
|
noteProduction(evidence.unmatched ? undefined : evidence.production, 'cloudflare', undefined, appKey);
|
|
3305
3407
|
return JSON.stringify(appKey !== undefined ? { appKey, ...evidence } : evidence);
|
|
3306
3408
|
}),
|
|
3307
|
-
tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews, impeccable_netlify_previews, impeccable_cloudflare_previews or impeccable_render_previews listed for a pull request or a pushed branch (a successful Preview deployment, a deploy preview that is ready, a Pages build that succeeded, or a Render preview), or a page on it, as a visitor signed in nowhere, and prove it: it answers, and the page shows words a file of this checkout holds. proven true lets impeccable_write save intendedMethod "preview-deploy"; for Netlify, Cloudflare Pages and Render it answers the address pattern to save beside it. gate "vercel-protection", "netlify-protection" or "cloudflare-access" means the host\'s protection stands in front of it. Or open the live app\'s address, the production one of those tools answered or one a file of this checkout names (the README\'s live-site line), or a page on it, the same way: proven true lets impeccable_write save it as beforeUrl, and nothing else. A key the run got in place for the same project or site goes with it. Nothing of the page comes back.', PROVE_PREVIEW, async (p, pi, signal) => {
|
|
3409
|
+
tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews, impeccable_netlify_previews, impeccable_cloudflare_previews or impeccable_render_previews listed for a pull request or a pushed branch (a successful Preview deployment, a deploy preview that is ready, a Pages build that succeeded, or a Render preview), or a page on it, as a visitor signed in nowhere, and prove it: it answers, and the page shows words a file of this checkout holds. proven true lets impeccable_write save intendedMethod "preview-deploy"; for Netlify, Cloudflare (Pages, or a Worker\'s previews) and Render it answers the address pattern to save beside it. gate "vercel-protection", "netlify-protection" or "cloudflare-access" means the host\'s protection stands in front of it. Or open the live app\'s address, the production one of those tools answered, the one the setup state\'s hosting record names for the app (liveUrl) or one a file of this checkout names (the README\'s live-site line), or a page on it, the same way: proven true lets impeccable_write save it as beforeUrl, and nothing else. A key the run got in place for the same project or site goes with it. Nothing of the page comes back.', PROVE_PREVIEW, async (p, pi, signal) => {
|
|
3308
3410
|
const url = p.url;
|
|
3309
3411
|
const origin = URL.canParse(url) ? new URL(url).origin : null;
|
|
3310
3412
|
if (origin && publicProduction.has(origin))
|
|
@@ -3314,19 +3416,34 @@ export function createTools(ctx) {
|
|
|
3314
3416
|
const known = origin !== null ? productionListed.get(origin) : undefined;
|
|
3315
3417
|
const live = origin !== null && !listed && (known !== undefined || (liveShaped(url) && (await checkoutNames(pi.cwd, origin))));
|
|
3316
3418
|
if (!origin || (!listed && !live))
|
|
3317
|
-
return "Refused: open the address of a preview that impeccable_hosted_previews, impeccable_netlify_previews, impeccable_cloudflare_previews or impeccable_render_previews listed for a pull request or a pushed branch, or a page on it; or, for the before, the live app's address one of them answered as production, or the site's or project's own address on one of the hosts that a file of this checkout names.";
|
|
3419
|
+
return "Refused: open the address of a preview that impeccable_hosted_previews, impeccable_netlify_previews, impeccable_cloudflare_previews or impeccable_render_previews listed for a pull request or a pushed branch, or a page on it; or, for the before, the live app's address one of them answered as production, the one the setup state's hosting record names for the app, or the site's or project's own address on one of the hosts that a file of this checkout names.";
|
|
3318
3420
|
// The Netlify site, by the address's shape or, for the site's custom domain, as its tool named it: its password goes to the site's own addresses.
|
|
3319
3421
|
const site = netlifySiteOf(url) ?? known?.site;
|
|
3320
|
-
// A saved service token opens its Pages project's previews: Access admits it wherever the application's policy covers.
|
|
3321
|
-
const project = pagesProject(origin);
|
|
3422
|
+
// A saved service token opens its Pages project's previews, or its Worker's: Access admits it wherever the application's policy covers.
|
|
3423
|
+
const project = pagesProject(origin) ?? workerPatterns.get(origin);
|
|
3322
3424
|
const token = accessToken && accessStored && project && accessProjects.has(project) ? accessToken : undefined;
|
|
3323
|
-
const
|
|
3425
|
+
const prove = () => provePreview(url, p.expect, pi.cwd, {
|
|
3324
3426
|
fetch: ctx.previewFetch, signal, live: live ? (known?.host ?? true) : false,
|
|
3325
3427
|
// The project's bypass secret opens the project's own addresses: the previews it proved, and the production address read from the same project.
|
|
3326
3428
|
...(bypassFor(origin, live) ? { bypass: bypassFor(origin, live) } : {}),
|
|
3327
3429
|
...(site && sitePasswords.has(site) ? { password: sitePasswords.get(site).value } : {}),
|
|
3328
3430
|
...(token ? { access: token } : {}),
|
|
3329
3431
|
});
|
|
3432
|
+
let proved = await prove();
|
|
3433
|
+
// Render reports a preview's deploy successful before its edge serves that build (REN-1117): a page the push added answers 404 for a while, so it is asked for again here, within a bounded wait, the way a command's sleep would but the fence refuses.
|
|
3434
|
+
if (!live && renderListed.has(origin) && proved.status === 404) {
|
|
3435
|
+
const wait = ctx.hostWait?.serve ?? { totalMs: 90_000, everyMs: 5_000 };
|
|
3436
|
+
said('Waiting for Render to serve the new page');
|
|
3437
|
+
for (const end = Date.now() + wait.totalMs; proved.status === 404 && Date.now() < end && !signal?.aborted;) {
|
|
3438
|
+
await sleep(wait.everyMs, undefined, { signal }).catch(() => undefined);
|
|
3439
|
+
if (!signal?.aborted)
|
|
3440
|
+
proved = await prove();
|
|
3441
|
+
}
|
|
3442
|
+
if (proved.status === 404 && !signal?.aborted)
|
|
3443
|
+
proved = { ...proved, reason: `The address still answered 404 ${Math.round(wait.totalMs / 1000)} s after Render reported the preview built: the page is not in the preview's build.` };
|
|
3444
|
+
}
|
|
3445
|
+
// A Worker's preview proven answers the pattern its evidence listed.
|
|
3446
|
+
const proof = proved.proven && !live && workerPatterns.has(origin) ? { ...proved, pattern: workerPatterns.get(origin) } : proved;
|
|
3330
3447
|
// A live address proven is the before, and nothing else: no method, no pattern and no lock to get past follow from it.
|
|
3331
3448
|
if (live) {
|
|
3332
3449
|
if (proof.proven)
|
|
@@ -3379,7 +3496,7 @@ export function createTools(ctx) {
|
|
|
3379
3496
|
return JSON.stringify({ ...proof, appKey: app.appKey });
|
|
3380
3497
|
return JSON.stringify(proof);
|
|
3381
3498
|
}),
|
|
3382
|
-
tool('impeccable_render_previews', 'Render previews', "Read whether Render builds this repository's pull requests, on a repository with several apps for the app's own folder (appKey): the Render site, from the render CLI signed in on this machine (the service built from this repository, in the app's folder by its Root Directory), else the one site Render's deployments on GitHub and the checkout's files name (on a repository with several apps, a file in the app's own folder, or a line of the root README naming that app and no other); its previews as GitHub records them, each with its pull request, its address and its state (a closed pull request's address too, read from its earlier successful status, though Render took that preview down); production, the site's own address, for the before; pattern, the address every pull request's preview has; and pullRequestPreviews (enabled false, with the cause, where the service's previews are off or manual; 'unknown' without the CLI's answer). cli says whether the render CLI answered (signed-in), is missing, could not list the services (unread, with the reason), or lists none of this repository's (none). Once a site is found, it shows the person one screen naming Render, with the site's address to click, asked once for each app: confirmed true, or false when the person skipped it, which is a no that ends the setup for that app. Where the CLI says the previews are off, that screen is also the consent to turning them on, and on the yes the CLI turns them on and reads them back, with a retry screen on Render's page where it could not: turnedOn in the answer says how that ended (enabled, by run or person; skipped; refused, with the reason and the page). With pull (a pull request of this run, a draft included: Render builds the preview as soon as a pull request opens), only that pull request's preview of the site the plain read found, waiting up to five minutes for it to finish building, under a status line with its timer, and again on the person's yes when the time ran out. Never asks for an address.", RENDER_PREVIEWS, async (p, pi, signal) => {
|
|
3499
|
+
tool('impeccable_render_previews', 'Render previews', "Read whether Render builds this repository's pull requests, on a repository with several apps for the app's own folder (appKey): the Render site, from the render CLI signed in on this machine (the service built from this repository, in the app's folder by its Root Directory), else the one site the setup state's hosting record names for the app (Render's deployments, its live address), else the one Render's deployments on GitHub and the checkout's files name (on a repository with several apps, a file in the app's own folder, or a line of the root README naming that app and no other); its previews as GitHub records them (from the state's record where it holds them, else read from GitHub here), each with its pull request, its address and its state (a closed pull request's address too, read from its earlier successful status, though Render took that preview down); production, the site's own address, for the before; pattern, the address every pull request's preview has; and pullRequestPreviews (enabled false, with the cause, where the service's previews are off or manual; 'unknown' without the CLI's answer). cli says whether the render CLI answered (signed-in), is missing, could not list the services (unread, with the reason), or lists none of this repository's (none). Once a site is found, it shows the person one screen naming Render, with the site's address to click, asked once for each app: confirmed true, or false when the person skipped it, which is a no that ends the setup for that app. Where the CLI says the previews are off, that screen is also the consent to turning them on, and on the yes the CLI turns them on and reads them back, with a retry screen on Render's page where it could not: turnedOn in the answer says how that ended (enabled, by run or person; skipped; refused, with the reason and the page). With pull (a pull request of this run, a draft included: Render builds the preview as soon as a pull request opens), only that pull request's preview of the site the plain read found, waiting up to five minutes for it to finish building, under a status line with its timer, and again on the person's yes when the time ran out. Never asks for an address.", RENDER_PREVIEWS, async (p, pi, signal) => {
|
|
3383
3500
|
const pull = p.pull;
|
|
3384
3501
|
const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
|
|
3385
3502
|
if (refused)
|
|
@@ -3428,9 +3545,16 @@ export function createTools(ctx) {
|
|
|
3428
3545
|
const render = (ctx.hostRunner ?? commandRunner)('render', pi.cwd, { signal });
|
|
3429
3546
|
const read = await readRenderService(render, repository, folder);
|
|
3430
3547
|
const service = 'service' in read ? read.service : undefined;
|
|
3431
|
-
|
|
3548
|
+
// GitHub's record as the setup state holds it, where it lists Render's deployments of this app; else read here.
|
|
3549
|
+
const record = hostingFor(appKey);
|
|
3550
|
+
const recorded = record ? deploymentsOf(record).filter((d) => parseRenderDeployment(d.environment, d.url)) : [];
|
|
3551
|
+
const evidence = recorded.length ? { deployments: recorded } : await readEvidence();
|
|
3432
3552
|
let site = service?.site;
|
|
3433
|
-
// Without the CLI's answer, the site the repository names: on a repository of one app, the one Render site its deployments and files name; on one of several, the one a file in the app's own folder names, else the one a line of the root README names beside that app and no other.
|
|
3553
|
+
// Without the CLI's answer, the one site the setup state's record names for the app (its deployments, its live address); else the one the repository names: on a repository of one app, the one Render site its deployments and files name; on one of several, the one a file in the app's own folder names, else the one a line of the root README names beside that app and no other.
|
|
3554
|
+
if (!site && record) {
|
|
3555
|
+
const named = renderSitesOf(record);
|
|
3556
|
+
site = named.length === 1 ? named[0] : undefined;
|
|
3557
|
+
}
|
|
3434
3558
|
if (!site) {
|
|
3435
3559
|
const recorded = evidence.deployments.flatMap((d) => parseRenderDeployment(d.environment, d.url)?.site ?? []);
|
|
3436
3560
|
if (appKey === undefined) {
|
|
@@ -4012,7 +4136,7 @@ export function createTools(ctx) {
|
|
|
4012
4136
|
return null;
|
|
4013
4137
|
};
|
|
4014
4138
|
let answer;
|
|
4015
|
-
const project = pagesProject(new URL(at.url).origin);
|
|
4139
|
+
const project = pagesProject(new URL(at.url).origin) ?? workerPatterns.get(new URL(at.url).origin);
|
|
4016
4140
|
// The token in hand goes only to a project it opened already, as the proofs send it: any other address is read for an application of the person's first.
|
|
4017
4141
|
if (accessToken && project && accessProjects.has(project) && (await prove(accessToken)).proven) {
|
|
4018
4142
|
// The token in hand opens this address too: stored already, or stored now.
|
|
@@ -4065,14 +4189,14 @@ export function createTools(ctx) {
|
|
|
4065
4189
|
if (project)
|
|
4066
4190
|
accessProjects.add(project);
|
|
4067
4191
|
}
|
|
4068
|
-
const pattern = told.status === 'saved' ? proof?.pattern : undefined;
|
|
4192
|
+
const pattern = told.status === 'saved' ? (proof?.pattern ?? workerPatterns.get(new URL(at.url).origin)) : undefined;
|
|
4069
4193
|
accessOutcome = { ...told, ...(told.status === 'saved' ? { proven: true, ...(pattern ? { pattern } : {}) } : {}) };
|
|
4070
4194
|
// The person's no to the service token leaves the previews behind Cloudflare Access: the setup ends there for the app (#413).
|
|
4071
4195
|
if (told.status === 'skipped')
|
|
4072
4196
|
decline('no-access', at.app?.appKey);
|
|
4073
4197
|
if (told.status === 'saved') {
|
|
4074
4198
|
hostedPreviewProven = true;
|
|
4075
|
-
provenHost = 'Cloudflare Pages';
|
|
4199
|
+
provenHost = previewHostName(at.url) ?? 'Cloudflare Pages';
|
|
4076
4200
|
usedPreviews.add(at.url);
|
|
4077
4201
|
if (at.app)
|
|
4078
4202
|
proveApp(at.app.appKey, at.url);
|
package/dist/ui/run-state.js
CHANGED
|
@@ -3,6 +3,22 @@ import { statusLine } from '../sync.js';
|
|
|
3
3
|
export function statusFromEvent(event) {
|
|
4
4
|
return event.type === 'message_end' ? statusLine(event.message) : null;
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* The order a task list reads in, in the terminal and on the web page alike (REN-1119): the tasks that left pending
|
|
8
|
+
* (skipped too), in the order they first did, then those still waiting, each group in the order given. A task that
|
|
9
|
+
* waits under the task in progress (Preview method while Test drive runs) never shows above it unticked (#419).
|
|
10
|
+
*/
|
|
11
|
+
export class TaskOrder {
|
|
12
|
+
moved = new Set();
|
|
13
|
+
sort(tasks) {
|
|
14
|
+
for (const t of tasks)
|
|
15
|
+
if (t.status !== 'pending')
|
|
16
|
+
this.moved.add(t.id);
|
|
17
|
+
const order = [...this.moved];
|
|
18
|
+
const rank = (t) => (this.moved.has(t.id) ? order.indexOf(t.id) : order.length);
|
|
19
|
+
return tasks.map((t, i) => ({ t, i })).sort((a, b) => rank(a.t) - rank(b.t) || a.i - b.i).map(({ t }) => t);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
6
22
|
/** After this long with nothing new on screen, the status band says the run is still working. */
|
|
7
23
|
const SILENCE_MS = 60_000;
|
|
8
24
|
/** How long something has taken, the way the screen says it: "45 s", "2 min", "1 h 5 min". */
|
|
@@ -30,8 +46,8 @@ export class RunState {
|
|
|
30
46
|
waiting;
|
|
31
47
|
/** The tasks that have been in progress, done or failed in this run. */
|
|
32
48
|
started = new Set();
|
|
33
|
-
/** The
|
|
34
|
-
|
|
49
|
+
/** The order the list shows the tasks in. */
|
|
50
|
+
order = new TaskOrder();
|
|
35
51
|
/** When something last changed on screen: a status, the task list, an answered question. */
|
|
36
52
|
changedAt;
|
|
37
53
|
constructor(now = Date.now, first) {
|
|
@@ -68,15 +84,9 @@ export class RunState {
|
|
|
68
84
|
for (const t of tasks)
|
|
69
85
|
if (t.status === 'in_progress' || t.status === 'completed' || t.status === 'failed')
|
|
70
86
|
this.started.add(t.id);
|
|
71
|
-
for (const t of tasks)
|
|
72
|
-
if (t.status !== 'pending')
|
|
73
|
-
this.moved.add(t.id);
|
|
74
87
|
// A task that has started never reads as not started again (#390).
|
|
75
88
|
const shown = tasks.map((t) => ({ ...t, ...(t.status === 'pending' && this.started.has(t.id) ? { started: true } : {}) }));
|
|
76
|
-
|
|
77
|
-
const order = [...this.moved];
|
|
78
|
-
const rank = (t) => (this.moved.has(t.id) ? order.indexOf(t.id) : order.length);
|
|
79
|
-
this.tasks = shown.map((t, i) => ({ t, i })).sort((a, b) => rank(a.t) - rank(b.t) || a.i - b.i).map(({ t }) => t);
|
|
89
|
+
this.tasks = this.order.sort(shown);
|
|
80
90
|
this.touch();
|
|
81
91
|
}
|
|
82
92
|
/** Something happened that the person saw, so the silence starts again. */
|