@impeccable/reviews 0.1.0 → 0.1.2

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/dist/focus.js CHANGED
@@ -6,54 +6,82 @@
6
6
  */
7
7
  /** The state items and checkup checks this setup works on. */
8
8
  const ITEMS = new Set(['scan', 'preview', 'signin']);
9
- /** The one lock in front of the previews this setup gets past, Vercel's, as the state's item, card and check name it. Where the lock is Vercel's they are part of the setup; for any other (Cloudflare Access) they are left alone. */
10
- const LOCK = { item: 'preview-protection', card: 'protection', host: 'Vercel' };
9
+ /** The locks in front of the previews this setup gets past, Vercel's login, Netlify's password and Cloudflare Access, by the host the state's item, card and check name. Where the lock is one of theirs they are part of the setup; for any other they are left alone. */
10
+ const LOCK = { item: 'preview-protection', card: 'protection', hosts: ['Vercel', 'Netlify', 'Cloudflare Access'] };
11
+ /** The apps question, as the state's item and card and the write name it: part of the setup only on a repository whose scan proposed several apps, which is where the state has the item (the monorepo case). */
12
+ const APPS = { item: 'apps', card: 'apps', write: 'apps' };
11
13
  /** The setup page's cards this setup covers, by their key in the state's `steps`: every other card is not part of it. */
12
14
  const CARDS = new Set(['installed', 'preview', 'signin', 'done']);
13
15
  /** The writes this setup makes, by their key in the instructions' `endpoints` and `writes`; `previewUrlPattern` saves the address of a host's previews that the host does not report to GitHub. */
14
16
  const WRITES = new Set(['preview', 'testAccounts', 'connectionCheck', 'latestPullCheck', 'previewUrlPattern']);
15
17
  /** What the model reads of the instructions; `steps`, `rules`, `endpoints` and `writes` are trimmed further. */
16
18
  const INSTRUCTIONS = ['product', 'goal', 'signIn', 'buildSettings', 'endpoints', 'writes', 'steps', 'rules'];
17
- /** A step or rule about a card this setup leaves alone: several apps, the design contract or profile, personas, review modules and settings, exclusions, preview protection (which the case reads from the state's item), preview credentials, the address pattern. */
18
- const OTHER_CARDS = /\bapps\b|\bprofile\b|\bcontract\b|persona|\bmodules?\b|exclu|protect|bypass|Cloudflare Access|review.settings|reviewSettings|preview-auth|previewAuth|pattern/i;
19
+ /** The words of a step or rule about a card this setup leaves alone: the design contract or profile, personas, review modules and settings, exclusions, preview protection (which the case reads from the state's item), preview credentials, the address pattern; and several apps, except in the monorepo case. */
20
+ const OTHER_CARD_WORDS = [String.raw `\bprofile\b`, String.raw `\bcontract\b`, 'persona', String.raw `\bmodules?\b`, 'exclu', 'protect', 'bypass', 'Cloudflare Access', 'review.settings', 'reviewSettings', 'preview-auth', 'previewAuth', 'pattern'];
21
+ const otherCards = (apps) => new RegExp([...(apps ? [] : [String.raw `\bapps\b`]), ...OTHER_CARD_WORDS].join('|'), 'i');
19
22
  const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
20
23
  const only = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => keys.has(key)));
21
24
  const without = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key)));
22
- /** GET /api/setup/instructions, trimmed to this setup. */
23
- export function focusInstructions(instructions) {
25
+ const withApps = (keys, key, apps) => (apps ? new Set([...keys, key]) : keys);
26
+ const records = (value) => (Array.isArray(value) ? value.map(record).filter((item) => item !== undefined) : []);
27
+ const reposOf = (answer) => records(record(answer)?.repos);
28
+ /** A repository of the state or the checkup whose scan proposed several apps: it has the apps item, or the apps check, to answer or answered. */
29
+ const hasApps = (repo) => Boolean(record(repo.items)?.[APPS.item]) || records(repo.checks).some((check) => check.key === APPS.item);
30
+ /** Whether a repository in the state or the checkup has several apps. */
31
+ export function severalApps(answer) {
32
+ return reposOf(answer).some(hasApps);
33
+ }
34
+ /**
35
+ * Whether the state or the checkup shows a card of this setup `not-selected` on `repo`, a repository with several apps.
36
+ * The command's token covers the apps there were when the person signed in, so this is an app the scan proposed later.
37
+ */
38
+ export function appsLeftOut(answer, repo) {
39
+ const out = (status) => status === 'not-selected';
40
+ return reposOf(answer).some((r) => String(r.name).toLowerCase() === repo.toLowerCase() && hasApps(r)
41
+ && (Object.entries(record(r.items) ?? {}).some(([key, item]) => ITEMS.has(key) && (out(record(item)?.status) || records(record(item)?.perApp).some((app) => out(app.status))))
42
+ || records(r.checks).some((check) => ITEMS.has(String(check.key)) && out(check.state))));
43
+ }
44
+ /** GET /api/setup/instructions, trimmed to this setup; with `apps`, for a state with several apps, the apps write and the lines about several apps stay. */
45
+ export function focusInstructions(instructions, { apps = false } = {}) {
24
46
  const all = record(instructions);
25
47
  if (!all)
26
48
  return instructions;
49
+ const writes = withApps(WRITES, APPS.write, apps);
50
+ const other = otherCards(apps);
27
51
  const trim = (key, value) => {
28
52
  if (key === 'endpoints' || key === 'writes')
29
- return record(value) ? only(record(value), WRITES) : value;
53
+ return record(value) ? only(record(value), writes) : value;
30
54
  if (key === 'steps' || key === 'rules')
31
- return Array.isArray(value) ? value.filter((line) => typeof line !== 'string' || !OTHER_CARDS.test(line)) : value;
55
+ return Array.isArray(value) ? value.filter((line) => typeof line !== 'string' || !other.test(line)) : value;
32
56
  return value;
33
57
  };
34
58
  return Object.fromEntries(INSTRUCTIONS.filter((key) => key in all).map((key) => [key, trim(key, all[key])]));
35
59
  }
36
- /** Whether a state item is the lock this setup gets past. */
37
- const vercelLock = (item) => record(item)?.host === LOCK.host;
38
- /** GET /api/setup/state, trimmed to this setup: each repository's scan, preview and sign-in, the lock in front of its previews when it is Vercel's, and the titles of the cards left alone. */
60
+ /** Whether a state item is a lock this setup gets past. */
61
+ const hostLock = (item) => LOCK.hosts.includes(String(record(item)?.host));
62
+ /** GET /api/setup/state, trimmed to this setup: each repository's scan, preview and sign-in, its apps item where the scan proposed several apps, the lock in front of its previews when it is one this setup gets past, and the titles of the cards left alone. */
39
63
  export function focusState(state) {
40
64
  const all = record(state);
41
65
  if (!all)
42
66
  return state;
43
67
  const steps = Array.isArray(all.steps) ? all.steps.map(record) : [];
44
- // The lock's card is part of the setup only when every lock the scan read is Vercel's: another host's keeps it among the cards left alone.
68
+ // The lock's card is part of the setup only when every lock the scan read is one this setup gets past: another host's keeps it among the cards left alone.
45
69
  const locks = Array.isArray(all.repos) ? all.repos.map((r) => record(record(r)?.items)?.[LOCK.item]).filter(Boolean) : [];
46
- const vercelOnly = locks.length > 0 && locks.every(vercelLock);
47
- const outside = steps.filter((step) => step && !CARDS.has(String(step.key)) && !(vercelOnly && step.key === LOCK.card) && typeof step.title === 'string').map((step) => step.title);
70
+ const hostsOnly = locks.length > 0 && locks.every(hostLock);
71
+ const apps = severalApps(all);
72
+ const cards = withApps(CARDS, APPS.card, apps);
73
+ const items = withApps(ITEMS, APPS.item, apps);
74
+ const writes = withApps(WRITES, APPS.write, apps);
75
+ const outside = steps.filter((step) => step && !cards.has(String(step.key)) && !(hostsOnly && step.key === LOCK.card) && typeof step.title === 'string').map((step) => step.title);
48
76
  const repo = (value) => {
49
77
  const r = record(value);
50
78
  if (!r)
51
79
  return value;
52
- const items = record(r.items);
80
+ const own = record(r.items);
53
81
  return {
54
82
  ...r,
55
- ...(items ? { items: Object.fromEntries(Object.entries(items).filter(([key, item]) => ITEMS.has(key) || (key === LOCK.item && vercelLock(item)))) } : {}),
56
- ...(record(r.endpoints) ? { endpoints: only(record(r.endpoints), WRITES) } : {}),
83
+ ...(own ? { items: Object.fromEntries(Object.entries(own).filter(([key, item]) => items.has(key) || (key === LOCK.item && hostLock(item)))) } : {}),
84
+ ...(record(r.endpoints) ? { endpoints: only(record(r.endpoints), writes) } : {}),
57
85
  };
58
86
  };
59
87
  return {
@@ -64,9 +92,9 @@ export function focusState(state) {
64
92
  }
65
93
  /**
66
94
  * GET /api/setup/checkup, trimmed the same way: each repository's checks of the
67
- * scan, the preview and the sign-in, the lists with only those checks' entries,
68
- * and the status those lists make. Its summary and advice counted every card, so
69
- * they go.
95
+ * scan, the preview, the sign-in and the apps (a check only a repository with
96
+ * several apps has), the lists with only those checks' entries, and the status
97
+ * those lists make. Its summary and advice counted every card, so they go.
70
98
  */
71
99
  export function focusCheckup(checkup) {
72
100
  const all = record(checkup);
@@ -80,7 +108,7 @@ export function focusCheckup(checkup) {
80
108
  if (!r || !Array.isArray(r.checks))
81
109
  return value;
82
110
  // The lock's check names its host in what it says while it asks for a key; once the key is saved it says nothing of the host, and the lists no longer carry it.
83
- const checks = r.checks.filter((c) => ITEMS.has(String(record(c)?.key)) || (record(c)?.key === LOCK.item && new RegExp(`\\b${LOCK.host}\\b`).test(String(record(c)?.says))));
111
+ const checks = r.checks.filter((c) => ITEMS.has(String(record(c)?.key)) || record(c)?.key === APPS.item || (record(c)?.key === LOCK.item && new RegExp(`\\b(${LOCK.hosts.join('|')})\\b`).test(String(record(c)?.says))));
84
112
  for (const says of checks.map((c) => record(c)?.says))
85
113
  if (typeof r.name === 'string' && typeof says === 'string' && says)
86
114
  kept.push({ repo: r.name, says });
@@ -19,7 +19,9 @@ export const HOST_QUESTIONS = {
19
19
  },
20
20
  'netlify-team': { kind: 'single', title: 'Netlify team', prompt: 'Which Netlify team should own the new site?' },
21
21
  };
22
- /** The screens a tool shows on its own: a sign-in's retry, the one-time access the host's GitHub app needs, and the consent to a bypass secret for Vercel's login, with the paste when the CLI cannot make one. */
22
+ /** Where a person reads or changes a site's password in Netlify, as Netlify's docs name the page on every plan. */
23
+ export const NETLIFY_PASSWORD_WHERE = 'Project configuration → General → Visitor access';
24
+ /** The screens a tool shows on its own: a sign-in's retry, the one-time access the host's GitHub app needs, and the consent to a key for a host's lock (a bypass secret for Vercel's login, the site password for Netlify's, a service token for Cloudflare Access), with the paste where the run does not get one. */
23
25
  export const HOST_SCREENS = {
24
26
  'netlify-signin-retry': { id: 'netlify-signin-retry', kind: 'confirm', title: 'Sign in to Netlify', prompt: 'The sign-in did not complete.', confirmLabel: 'Try again', skipLabel: 'Skip' },
25
27
  'netlify-access': {
@@ -61,6 +63,50 @@ export const HOST_SCREENS = {
61
63
  prompt: 'Vercel keeps your previews behind a login, and the Vercel CLI here could not make a bypass secret for them: that takes your Vercel sign-in, this folder linked to the project, and the Member role. Make one yourself: your project → Settings → Deployment Protection → Protection Bypass for Automation → Create, named "Impeccable reviews". It is stored encrypted and used only to open previews.',
62
64
  hint: 'Paste the secret',
63
65
  },
66
+ // Netlify's site password in front of the previews: the one consent to storing the password the run reads from the site as the person, and the paste when it cannot read it.
67
+ 'netlify-password': {
68
+ id: 'netlify-password',
69
+ kind: 'confirm',
70
+ title: 'Your Netlify previews are protected',
71
+ hint: `Impeccable reviews each pull request on its preview, and Netlify keeps yours behind the site password. The run reads that password from your site as you and stores it encrypted, used only to open previews. Change it any time in ${NETLIFY_PASSWORD_WHERE}.`,
72
+ prompt: 'Store the site password for Impeccable?',
73
+ confirmLabel: 'Store',
74
+ skipLabel: 'Skip',
75
+ },
76
+ 'netlify-password-key': {
77
+ id: 'netlify-password-key',
78
+ kind: 'text',
79
+ sensitive: true,
80
+ title: 'Your Netlify previews are protected',
81
+ prompt: `Netlify keeps your previews behind the site password, and the run could not read it: the Netlify CLI here is signed out, the site is in none of your teams, or Netlify does not give the password back. Find it, or set a new one, in ${NETLIFY_PASSWORD_WHERE}. It is stored encrypted and used only to open previews.`,
82
+ hint: 'Paste the password',
83
+ },
84
+ // Cloudflare Access in front of the previews: the one consent to a service token the run creates as the person through cf, the retry of cf's sign-in, the API token where that sign-in cannot create it, and the paste where the run does not create one.
85
+ 'cloudflare-access': {
86
+ id: 'cloudflare-access',
87
+ kind: 'confirm',
88
+ title: 'Your Cloudflare previews are protected',
89
+ hint: 'Impeccable reviews each pull request on its preview, and Cloudflare Access keeps yours behind a login. Cloudflare\'s way in for tools is a service token made for one tool: with your Cloudflare sign-in, the run creates one named "Impeccable reviews" and allows it in your Access application\'s policy. It is stored encrypted, used only to open previews, and you can revoke it any time in Zero Trust → Access controls → Service credentials.',
90
+ prompt: 'Create the token for Impeccable?',
91
+ confirmLabel: 'Create',
92
+ skipLabel: 'Skip',
93
+ },
94
+ 'cloudflare-signin-retry': { id: 'cloudflare-signin-retry', kind: 'confirm', title: 'Sign in to Cloudflare', prompt: 'The sign-in did not complete.', confirmLabel: 'Try again', skipLabel: 'Skip' },
95
+ 'cloudflare-access-api': {
96
+ id: 'cloudflare-access-api',
97
+ kind: 'text',
98
+ sensitive: true,
99
+ title: 'One Cloudflare API token',
100
+ prompt: 'The Cloudflare CLI here could not create the token, so creating it takes a Cloudflare API token, used for this run only and stored nowhere. Make one at dash.cloudflare.com/profile/api-tokens → Create Token → Create Custom Token, with two permissions: Access: Apps and Policies → Edit, and Access: Service Tokens → Edit.',
101
+ hint: 'Paste the API token',
102
+ },
103
+ 'cloudflare-access-key': {
104
+ id: 'cloudflare-access-key',
105
+ kind: 'account',
106
+ fields: ['Client ID', 'Client secret'],
107
+ title: 'Your Cloudflare previews are protected',
108
+ prompt: 'Cloudflare Access keeps your previews behind a login. Paste a service token for Impeccable, or make one: Zero Trust → Access controls → Service credentials → Service Tokens → Create Service Token, named "Impeccable reviews", then allow it in your Access application with a Service Auth policy. It is stored encrypted and used only to open previews.',
109
+ },
64
110
  };
65
111
  /** The ids whose screens the tool puts up itself, when it needs the person, and takes down: the web page follows those, not the model's call. */
66
112
  export const TOOL_SHOWN = new Set(['netlify-setup', 'vercel-setup']);
package/dist/hosted.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import { ACCESS_ID_HEADER, ACCESS_SECRET_HEADER } from './cloudflare-access.js';
4
+ import { basicCredentials } from './netlify-password.js';
5
+ import { gitEnvironment } from './sandbox.js';
3
6
  import { BYPASS_HEADER } from './vercel-bypass.js';
4
7
  /**
5
8
  * Previews a host already deploys: what GitHub has on record of the
@@ -80,19 +83,34 @@ export async function readHostedPreviews(owner, repo, deps = {}) {
80
83
  const HOSTS = {
81
84
  vercel: { name: 'Vercel', gate: 'vercel-protection', login: /(^|\.)vercel\.com$/ },
82
85
  netlify: { name: 'Netlify', gate: 'netlify-protection', login: /(^|\.)netlify\.com$/ },
86
+ cloudflare: { name: 'Cloudflare Pages', gate: 'cloudflare-access', login: /(^|\.)cloudflareaccess\.com$/ },
83
87
  };
84
88
  const NETLIFY = /^(?=[^.]{1,63}\.netlify\.app$)deploy-preview-[1-9]\d{0,6}--([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.netlify\.app$/;
85
- /** 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. */
89
+ /** A Pages preview: one label (the deployment's id, or the branch's alias), then the project, under pages.dev. The project's own address, with no label, is its production. */
90
+ const PAGES = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.pages\.dev$/;
91
+ /** 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. */
86
92
  const hostOf = (url) => {
87
93
  if (url.protocol !== 'https:' || url.username || url.password || url.port || url.search || url.hash)
88
94
  return null;
89
- return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.vercel\.app$/.test(url.hostname) ? 'vercel' : NETLIFY.test(url.hostname) ? 'netlify' : null;
95
+ return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.vercel\.app$/.test(url.hostname) ? 'vercel' : NETLIFY.test(url.hostname) ? 'netlify' : PAGES.test(url.hostname) ? 'cloudflare' : null;
90
96
  };
91
- /** The host a preview address belongs to, by name; null for an address of neither. */
97
+ /** The host a preview address belongs to, by name; null for an address of none. */
92
98
  export function previewHostName(url) {
93
99
  const host = URL.canParse(url) ? hostOf(new URL(url)) : null;
94
100
  return host ? HOSTS[host].name : null;
95
101
  }
102
+ /** The Netlify site a deploy preview address belongs to, by name; null for any other address. */
103
+ export function netlifySiteOf(url) {
104
+ return URL.canParse(url) && hostOf(new URL(url)) === 'netlify' ? NETLIFY.exec(new URL(url).hostname)[1] : null;
105
+ }
106
+ /** The address pattern a proven preview stands for, where the host reports none to GitHub: Netlify's by pull request, Cloudflare's by the branch's alias. */
107
+ function patternOf(host, hostname) {
108
+ if (host === 'netlify')
109
+ return { pattern: `https://deploy-preview-{pr}--${NETLIFY.exec(hostname)[1]}.netlify.app` };
110
+ if (host === 'cloudflare')
111
+ return { pattern: `https://{branch}.${PAGES.exec(hostname)[1]}.pages.dev` };
112
+ return {};
113
+ }
96
114
  const ENTITIES = {
97
115
  amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”',
98
116
  hellip: '…', middot: '·', bull: '•', laquo: '«', raquo: '»', copy: '©', reg: '®', trade: '™', times: '×',
@@ -107,15 +125,33 @@ function plain(text) {
107
125
  /** Whether a file the checkout tracks holds the words; .env files are never read. */
108
126
  async function inCheckout(root, words) {
109
127
  try {
110
- await execFileAsync('git', ['grep', '--quiet', '--fixed-strings', '--ignore-case', '-e', words, '--', '.', ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000 });
128
+ await execFileAsync('git', ['grep', '--quiet', '--fixed-strings', '--ignore-case', '-e', words, '--', '.', ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000, env: gitEnvironment() });
111
129
  return true;
112
130
  }
113
131
  catch {
114
132
  return false;
115
133
  }
116
134
  }
135
+ /** What a visitor never sees of a page: comments, scripts, styles and templates. */
136
+ const UNSEEN = /<!--[\s\S]*?-->|<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
117
137
  /** The words a visitor sees: the title and the text, without comments, scripts, styles, tags or attributes. */
118
- const visible = (html) => plain(html.replace(/<!--[\s\S]*?-->|<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ').replace(/<[^>]*>/g, ' '));
138
+ const visible = (html) => plain(html.replace(UNSEEN, ' ').replace(/<[^>]*>/g, ' '));
139
+ /**
140
+ * Netlify's own lock, never the status alone, as Netlify documents it: the basic challenge (`WWW-Authenticate: Basic`)
141
+ * its Basic-Auth rules send, which "trigger the built-in basic browser authentication"
142
+ * (https://docs.netlify.com/manage/security/secure-access-to-sites/basic-authentication-with-custom-http-headers/); the
143
+ * "generic password prompt" of its Password Protection, where a visitor enters a shared password and nothing else
144
+ * (https://docs.netlify.com/manage/security/secure-access-to-sites/password-protection/); or the "Netlify-branded page"
145
+ * its team login shows a visitor (https://docs.netlify.com/manage/security/secure-access-to-sites/project-visibility/),
146
+ * whose title names Netlify other than in the site's own address: a mention in an app's own page, such as a footer, is
147
+ * not one.
148
+ */
149
+ function netlifyLock(challenge, html) {
150
+ const seen = html.replace(UNSEEN, ' ');
151
+ const fields = [...seen.matchAll(/<input\b[^>]*>/gi)].map(([tag]) => /\stype\s*=\s*["']?([\w-]+)/i.exec(tag)?.[1]?.toLowerCase() ?? 'text').filter((type) => !['hidden', 'submit', 'button', 'reset', 'image'].includes(type));
152
+ const title = plain(/<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(seen)?.[1] ?? '');
153
+ return /^basic\b/i.test(challenge ?? '') || (/<form\b/i.test(seen) && fields.join() === 'password') || /\bnetlify\b/.test(title.replace(/\S*\.netlify\.app\S*/g, ' '));
154
+ }
119
155
  /** The first `max` bytes of a page, as text. */
120
156
  async function pageText(res, max = 1_000_000) {
121
157
  const reader = res.body?.getReader();
@@ -136,16 +172,44 @@ async function pageText(res, max = 1_000_000) {
136
172
  }
137
173
  return Buffer.concat(chunks).toString('utf8');
138
174
  }
175
+ /**
176
+ * Whether Netlify's own password form opens the address: the password posted as that form posts it (a `password` field
177
+ * to the address itself), then the page read with the session cookie Netlify set. Says whether a password is right
178
+ * where the header did not open the address; a review cannot fill a form, so this proves nothing for one.
179
+ */
180
+ async function formOpens(target, password, phrase, open, signal) {
181
+ try {
182
+ const posted = await open(target, { method: 'POST', redirect: 'manual', credentials: 'omit', signal, headers: { accept: 'text/html', 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ password }).toString() });
183
+ await posted.body?.cancel().catch(() => { });
184
+ const cookies = posted.headers.getSetCookie().map((cookie) => cookie.split(';')[0]).filter((pair) => pair.includes('='));
185
+ if (posted.status >= 400 || !cookies.length)
186
+ return false;
187
+ const res = await open(target, { redirect: 'manual', credentials: 'omit', signal, headers: { accept: 'text/html', cookie: cookies.join('; ') } });
188
+ if (res.status < 200 || res.status >= 300) {
189
+ await res.body?.cancel().catch(() => { });
190
+ return false;
191
+ }
192
+ return visible(await pageText(res)).includes(plain(phrase));
193
+ }
194
+ catch {
195
+ return false;
196
+ }
197
+ }
139
198
  /**
140
199
  * Opens a preview address the way a visitor does, signed in nowhere, and proves it: it answers, and its page shows
141
200
  * `words` that a file of the checkout at `root` holds, so the page is this app's and not another site's. With `bypass`, a
142
- * Vercel bypass secret, a Vercel address is opened with it in the header Vercel reads it from, as reviews do; no other host's is.
201
+ * Vercel bypass secret, a Vercel address is opened with it in the header Vercel reads it from, as reviews do; with
202
+ * `password`, a Netlify site password, a Netlify address is opened with it as basic credentials in the Authorization
203
+ * header, the one way reviews can send it, and with `form` posted to Netlify's form too where the header did not get past
204
+ * the lock (only for a site whose settings show a password: an app's own password form looks the same); with `access`, a
205
+ * Cloudflare Access service token, a Pages address is opened with it in the two headers Access reads it from. None goes
206
+ * to another host.
143
207
  */
144
208
  export async function provePreview(url, words, root, deps = {}) {
145
209
  const unproven = (reason, status = null) => ({ proven: false, status, reason });
146
210
  const host = URL.canParse(url) ? hostOf(new URL(url)) : null;
147
211
  if (!host)
148
- return unproven('Only a Vercel preview address, https://<name>.vercel.app, or a Netlify deploy preview, https://deploy-preview-<n>--<name>.netlify.app, with no query, is opened.');
212
+ return unproven('Only a Vercel preview address, https://<name>.vercel.app, a Netlify deploy preview, https://deploy-preview-<n>--<name>.netlify.app, or a Cloudflare Pages preview, https://<id or branch>.<project>.pages.dev, with no query, is opened.');
149
213
  const { name, gate, login } = HOSTS[host];
150
214
  const phrase = words.trim();
151
215
  if (phrase.length < 3 || phrase.length > 120 || /[\r\n]/.test(phrase))
@@ -153,14 +217,22 @@ export async function provePreview(url, words, root, deps = {}) {
153
217
  if (!(await inCheckout(root, phrase)))
154
218
  return unproven("No file of this checkout holds those words: give words the app's pages show, from its source.");
155
219
  const signal = AbortSignal.any([AbortSignal.timeout(15_000), ...(deps.signal ? [deps.signal] : [])]);
220
+ const open = deps.fetch ?? fetch;
156
221
  const opened = deps.bypass && host === 'vercel';
157
- const headers = { accept: 'text/html', ...(opened ? { [BYPASS_HEADER]: deps.bypass } : {}) };
158
- // Where the login stands in front all the same, the bypass secret did not get past it.
159
- const still = opened ? ' even with the bypass secret' : '';
222
+ const withPassword = deps.password !== undefined && host === 'netlify';
223
+ const withToken = deps.access !== undefined && host === 'cloudflare';
224
+ const headers = {
225
+ accept: 'text/html',
226
+ ...(opened ? { [BYPASS_HEADER]: deps.bypass } : {}),
227
+ ...(withPassword ? { authorization: basicCredentials(deps.password) } : {}),
228
+ ...(withToken ? { [ACCESS_ID_HEADER]: deps.access.id, [ACCESS_SECRET_HEADER]: deps.access.secret } : {}),
229
+ };
230
+ // Where the login stands in front all the same, the bypass secret, the password or the service token did not get past it.
231
+ const still = opened ? ' even with the bypass secret' : withPassword ? ' even with the password' : withToken ? ' even with the service token' : '';
160
232
  let target = new URL(url);
161
233
  try {
162
234
  for (let hop = 0; hop < 4; hop++) {
163
- const res = await (deps.fetch ?? fetch)(target, { redirect: 'manual', headers, credentials: 'omit', signal });
235
+ const res = await open(target, { redirect: 'manual', headers, credentials: 'omit', signal });
164
236
  if (res.status >= 300 && res.status < 400) {
165
237
  await res.body?.cancel().catch(() => { });
166
238
  const location = res.headers.get('location') ?? '';
@@ -172,19 +244,26 @@ export async function provePreview(url, words, root, deps = {}) {
172
244
  target = next;
173
245
  continue;
174
246
  }
175
- if (res.status === 401 || (host === 'netlify' && res.status === 403)) {
247
+ if (res.status === 401 || (host !== 'vercel' && res.status === 403)) {
176
248
  // Vercel's own page names Vercel; any other 401 there is the app's own sign-in, which a page a visitor opens without signing in gets past.
177
- // A password or a login in front of a Netlify preview answers 401 or 403 on every page of it, and its page names nothing to go by.
249
+ // Netlify's lock shows itself; the app's own 401 or 403 does not, so it gets no site password.
250
+ // Cloudflare Access names itself on the 401 or 403 it answers a request it will not let in (a service token it does not admit), where a browser gets its login.
178
251
  const page = await pageText(res, 64_000);
179
- return host === 'netlify' || /vercel/i.test(page) ? { ...unproven(`The address asked for ${name}'s login or password${still}.`, res.status), gate }
180
- : unproven("The address asked for the app's own sign-in: try a page a visitor opens without signing in.", 401);
252
+ const named = (host === 'netlify' && netlifyLock(res.headers.get('www-authenticate'), page)) || (host === 'vercel' && /vercel/i.test(page)) || (host === 'cloudflare' && (/^cloudflare-access\b/i.test(res.headers.get('www-authenticate') ?? '') || /cloudflareaccess\.com|cloudflare access/i.test(page)));
253
+ if (!named)
254
+ return unproven(res.status === 401 ? "The address asked for the app's own sign-in: try a page a visitor opens without signing in." : `The address answered ${res.status}.`, res.status);
255
+ // A password the header did not get past: Netlify's own form says whether it is the site's password at all.
256
+ if (withPassword && deps.form && (await formOpens(target, deps.password, phrase, open, signal))) {
257
+ return { ...unproven("The password opens the address on Netlify's own form, but not in the Authorization header, the one way reviews can send it.", res.status), gate, formOpened: true };
258
+ }
259
+ return { ...unproven(`The address asked for ${name}'s login or password${still}.`, res.status), gate };
181
260
  }
182
261
  if (res.status < 200 || res.status >= 300) {
183
262
  await res.body?.cancel().catch(() => { });
184
263
  return unproven(`The address answered ${res.status}.`, res.status);
185
264
  }
186
265
  return visible(await pageText(res)).includes(plain(phrase))
187
- ? { proven: true, status: res.status, ...(host === 'netlify' ? { pattern: `https://deploy-preview-{pr}--${NETLIFY.exec(new URL(url).hostname)[1]}.netlify.app` } : {}) }
266
+ ? { proven: true, status: res.status, ...patternOf(host, new URL(url).hostname) }
188
267
  : unproven('The page does not show those words: it is another app, or another page.', res.status);
189
268
  }
190
269
  return unproven('The address redirected too many times.');
package/dist/log.js CHANGED
@@ -1,8 +1,13 @@
1
1
  import { appendFileSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
- /** An error whose message is written for the person at the terminal. */
4
+ /** An error whose message is written for the person at the terminal; `step` is the onboarding step it ends, which the command reports (report.ts). */
5
5
  export class UserError extends Error {
6
+ step;
7
+ constructor(message, step) {
8
+ super(message);
9
+ this.step = step;
10
+ }
6
11
  }
7
12
  const TOKEN = /\b(pst|phr)_[A-Za-z0-9._~+/=-]+/g;
8
13
  const NAMES = 'access_token|run_token|code_verifier|code|authorization|x-api-key';
package/dist/login.js CHANGED
@@ -130,7 +130,7 @@ export async function startCallbackServer(state, ports = CALLBACK_PORTS) {
130
130
  },
131
131
  };
132
132
  }
133
- throw new UserError(`Ports ${ports.join(', ')} are all taken on this machine, and sign-in needs one. Close what uses one of them and run it again.`);
133
+ throw new UserError(`Ports ${ports.join(', ')} are all taken on this machine, and sign-in needs one. Close what uses one of them and run it again.`, { event: 'setup_command_sign_in_ended', outcome: 'ports_busy' });
134
134
  }
135
135
  /** The code from what the person pasted: the whole callback address, or the code alone. Null when it is neither, or from another sign-in. */
136
136
  export function parsePasted(text, state) {
@@ -215,18 +215,20 @@ export async function exchangeCode(host, input, log) {
215
215
  }
216
216
  /**
217
217
  * The browser sign-in: callback server, PKCE and state, the paste fallback,
218
- * then the token exchange, once per code. `check` stops a token the command
218
+ * then the token exchange, once per code. `repo` is the checkout's owner/name:
219
+ * the consent page names it, so the person approves and picks nothing (an
220
+ * older Impeccable ignores it and asks). `check` stops a token the command
219
221
  * cannot use; otherwise the tab that brought the code goes on to the page
220
222
  * `landing` resolves to (the web page that mirrors the run, once the command
221
223
  * has what it needs to show a run there), or says why it will not.
222
224
  */
223
- export async function signIn(host, log, prompt, check = (t) => t, landing = async (t) => progressPage(host, t)) {
225
+ export async function signIn(host, repo, log, prompt, check = (t) => t, landing = async (t) => progressPage(host, t)) {
224
226
  const state = randomBytes(24).toString('base64url');
225
227
  const { verifier, challenge } = pkcePair();
226
228
  log.secret(verifier);
227
229
  const server = await startCallbackServer(state);
228
230
  const authorize = new URL(`${host}/app/cli/authorize`);
229
- authorize.search = new URLSearchParams({ redirect_uri: server.redirectUri, state, code_challenge: challenge, code_challenge_method: 'S256' }).toString();
231
+ authorize.search = new URLSearchParams({ redirect_uri: server.redirectUri, state, code_challenge: challenge, code_challenge_method: 'S256', repo }).toString();
230
232
  log.write(`callback server at ${server.redirectUri}`);
231
233
  prompt.show(authorize.href);
232
234
  openBrowser(authorize.href);
@@ -235,8 +237,8 @@ export async function signIn(host, log, prompt, check = (t) => t, landing = asyn
235
237
  try {
236
238
  const code = await new Promise((resolve, reject) => {
237
239
  server.code.then(resolve, reject);
238
- stopPaste = prompt.listen(state, resolve, () => reject(new UserError('Sign-in cancelled.')));
239
- timer = setTimeout(() => reject(new UserError('Sign-in timed out after 30 minutes. Run impeccable-reviews again.')), LOGIN_TIMEOUT_MS);
240
+ stopPaste = prompt.listen(state, resolve, () => reject(new UserError('Sign-in cancelled.', { event: 'setup_command_sign_in_ended', outcome: 'cancelled' })));
241
+ timer = setTimeout(() => reject(new UserError('Sign-in timed out after 30 minutes. Run impeccable-reviews again.', { event: 'setup_command_sign_in_ended', outcome: 'timed_out' })), LOGIN_TIMEOUT_MS);
240
242
  });
241
243
  log.secret(code);
242
244
  const token = check(await exchangeCode(host, { code, verifier, redirectUri: server.redirectUri }, log));
@@ -261,7 +263,7 @@ export function setupAuth(first, signInAgain, now = Date.now) {
261
263
  const until = (t) => now() + (Number.isFinite(t.expires_in) ? t.expires_in : 3600) * 1000;
262
264
  let current = { token: first.access_token, expiresAt: until(first) };
263
265
  let pending;
264
- const renew = (pi) => (pending ??= signInAgain(pi)
266
+ const renew = (pi, why = 'ran-out') => (pending ??= signInAgain(pi, why)
265
267
  .then((t) => {
266
268
  current = { token: t.access_token, expiresAt: until(t) };
267
269
  return t.access_token;