@impeccable/reviews 0.1.6 → 0.1.8

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.
Files changed (41) hide show
  1. package/README.md +22 -20
  2. package/dist/agent.js +9 -4
  3. package/dist/cloudflare-access.js +77 -47
  4. package/dist/cloudflare.js +347 -23
  5. package/dist/fence.js +939 -143
  6. package/dist/focus.js +5 -4
  7. package/dist/host-previews.js +123 -0
  8. package/dist/host-questions.js +101 -21
  9. package/dist/hosted.js +72 -21
  10. package/dist/http.js +2 -0
  11. package/dist/log.js +7 -1
  12. package/dist/login.js +45 -13
  13. package/dist/main.js +37 -14
  14. package/dist/netlify-password.js +8 -6
  15. package/dist/netlify.js +58 -16
  16. package/dist/pi-paths.js +69 -0
  17. package/dist/playbook/cases/cloudflare-previews.md +12 -4
  18. package/dist/playbook/cases/first-preview-failure.md +1 -1
  19. package/dist/playbook/cases/live-app-address.md +15 -0
  20. package/dist/playbook/cases/monorepo.md +8 -8
  21. package/dist/playbook/cases/netlify-previews.md +3 -1
  22. package/dist/playbook/cases/netlify-setup.md +7 -6
  23. package/dist/playbook/cases/preview-protection.md +3 -2
  24. package/dist/playbook/cases/pull-requests.md +3 -3
  25. package/dist/playbook/cases/static-site.md +2 -2
  26. package/dist/playbook/cases/vercel-previews.md +3 -1
  27. package/dist/playbook/cases/vercel-setup.md +9 -5
  28. package/dist/playbook.js +3 -3
  29. package/dist/preflight.js +43 -8
  30. package/dist/report.js +1 -0
  31. package/dist/sandbox.js +364 -50
  32. package/dist/scan.js +134 -0
  33. package/dist/sync.js +44 -7
  34. package/dist/tools.js +682 -140
  35. package/dist/ui/bridge.js +18 -4
  36. package/dist/ui/look.js +1 -1
  37. package/dist/ui/screens.js +5 -3
  38. package/dist/ui/sensitive.js +10 -0
  39. package/dist/vercel-bypass.js +21 -12
  40. package/dist/vercel.js +147 -17
  41. package/package.json +1 -1
package/dist/focus.js CHANGED
@@ -1,17 +1,18 @@
1
1
  /**
2
2
  * The setup covers one situation: a web front end previewed by the host that
3
3
  * already deploys its pull requests or by Impeccable itself, with at most the
4
- * app's own sign-in. These trim what the setup API says to that situation
4
+ * app's own sign-in, and the live app's address as the before of every review
5
+ * where one is known. These trim what the setup API says to that situation
5
6
  * before the model reads it, so the setup page's other cards never reach the run.
6
7
  */
7
- /** The state items and checkup checks this setup works on. */
8
- const ITEMS = new Set(['scan', 'preview', 'signin']);
8
+ /** The state items and checkup checks this setup works on; `before` is the live app's address, which the run sets from what the host or the repository says (the live-app-address case). */
9
+ const ITEMS = new Set(['scan', 'preview', 'before', 'signin']);
9
10
  /** 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
11
  const LOCK = { item: 'preview-protection', card: 'protection', hosts: ['Vercel', 'Netlify', 'Cloudflare Access'] };
11
12
  /** 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
13
  const APPS = { item: 'apps', card: 'apps', write: 'apps' };
13
14
  /** The setup page's cards this setup covers, by their key in the state's `steps`: every other card is not part of it. */
14
- const CARDS = new Set(['installed', 'preview', 'signin', 'done']);
15
+ const CARDS = new Set(['installed', 'preview', 'before', 'signin', 'done']);
15
16
  /** 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. */
16
17
  const WRITES = new Set(['preview', 'testAccounts', 'connectionCheck', 'latestPullCheck', 'previewUrlPattern']);
17
18
  /** What the model reads of the instructions; `steps`, `rules`, `endpoints` and `writes` are trimmed further. */
@@ -0,0 +1,123 @@
1
+ import { readSiteCall } from './netlify-password.js';
2
+ /**
3
+ * Whether a host builds previews of the repository's pull requests at all, as
4
+ * its project settings say, read beside the evidence of the previews it built,
5
+ * and the one change that turns them on as the person through the host's CLI
6
+ * (Netlify: `netlify api updateSite` with `build_settings.skip_prs` false;
7
+ * Cloudflare Pages: `cf pages edit` with `preview_deployment_setting` all, or
8
+ * the test drive's branch among the custom includes). Only the pull-request
9
+ * setting is ever changed: a project whose builds are stopped altogether
10
+ * (Netlify `stop_builds`, Cloudflare `deployments_enabled` false) is reported
11
+ * and left as it is. Vercel builds every branch once Git is connected, so its
12
+ * fact is read from vercel.json and the project (vercel.ts) and never written.
13
+ */
14
+ /** The branch the test drive pushes, whose build is the preview the setting must allow. */
15
+ export const TEST_DRIVE_BRANCH = 'impeccable/test-drive';
16
+ /**
17
+ * A folder of the repository as a host's project names the one it builds (Vercel's `rootDirectory`, Netlify's `base`,
18
+ * Cloudflare Pages' `root_dir`) or the setup state names an app's (`directory`): relative to the repository's root, no
19
+ * leading `./` or `/` and no trailing `/`, the root itself being '' (none, `.` and `/` alike). Undefined for anything
20
+ * else, which matches no app.
21
+ */
22
+ export function folderOf(value) {
23
+ if (value === null || value === undefined)
24
+ return '';
25
+ if (typeof value !== 'string' || value.length > 400 || /[\\\0]/.test(value))
26
+ return undefined;
27
+ const folder = value.trim().replace(/^(\.\/|\/)+/, '').replace(/\/+$/, '');
28
+ if (folder === '.' || folder === '')
29
+ return '';
30
+ return folder.split('/').every((part) => part && part !== '.' && part !== '..') ? folder : undefined;
31
+ }
32
+ const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
33
+ const name = (value) => (typeof value === 'string' && /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(value) ? value : undefined);
34
+ const rules = (value) => (Array.isArray(value) ? value.filter((v) => typeof v === 'string') : []);
35
+ /**
36
+ * A branch rule as the hosts write them: Cloudflare's `*` stands for any run of characters, so `impeccable/*` and `*`
37
+ * both name the test drive's branch; Vercel's rules are minimatch (`minimatch`), where `*` stops at a slash, `**`
38
+ * does not, and a double star before a slash stands for any number of leading folders, none included.
39
+ */
40
+ export function matchesBranch(rule, branch, minimatch = false) {
41
+ const escaped = (part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
42
+ const stars = (span) => span.split('*').map(escaped).join('[^/]*');
43
+ const pattern = minimatch
44
+ ? rule.split('**/').map((piece) => piece.split('**').map(stars).join('.*')).join('(?:.*/)?')
45
+ : rule.split('*').map(escaped).join('.*');
46
+ return new RegExp(`^${pattern}$`).test(branch);
47
+ }
48
+ /** What a Pages project's source config says of pull-request previews: Cloudflare's preview branch control, excludes before includes, and the switch for every automatic deployment. */
49
+ export function cloudflarePreviewsFact(project) {
50
+ const config = record(record(project.source)?.config);
51
+ const fact = { project: name(project.name) ?? '', enabled: true, ...(typeof config?.production_branch === 'string' ? { productionBranch: config.production_branch } : {}) };
52
+ if (!config)
53
+ return { ...fact, enabled: false, cause: 'source', reason: 'The project is not connected to a Git repository, so no push builds.' };
54
+ if (config.deployments_enabled === false)
55
+ return { ...fact, enabled: false, cause: 'deployments', reason: 'Automatic deployments are off for the project, production and previews alike (Settings → Builds & deployments).' };
56
+ const setting = config.preview_deployment_setting;
57
+ if (setting === 'none')
58
+ return { ...fact, enabled: false, cause: 'previews', reason: 'Preview branch control is set to None: no branch but production builds.' };
59
+ if (setting === 'custom') {
60
+ const excluded = rules(config.preview_branch_excludes).find((rule) => matchesBranch(rule, TEST_DRIVE_BRANCH));
61
+ if (excluded)
62
+ return { ...fact, enabled: false, cause: 'previews', reason: `Preview branch control is set to Custom branches, and its exclude rule "${excluded}" leaves ${TEST_DRIVE_BRANCH} out.` };
63
+ if (!rules(config.preview_branch_includes).some((rule) => matchesBranch(rule, TEST_DRIVE_BRANCH)))
64
+ return { ...fact, enabled: false, cause: 'previews', reason: `Preview branch control is set to Custom branches, and no include rule names ${TEST_DRIVE_BRANCH}.` };
65
+ }
66
+ return fact;
67
+ }
68
+ /** What a Netlify site's build settings say of Deploy Previews: `skip_prs` turns them off, and `stop_builds` stops every build. */
69
+ export function netlifyPreviewsFact(site) {
70
+ const siteName = name(site.name);
71
+ if (!siteName)
72
+ return undefined;
73
+ const settings = record(site.build_settings);
74
+ const fact = { project: siteName, enabled: true, ...(typeof settings?.repo_branch === 'string' ? { productionBranch: settings.repo_branch } : {}) };
75
+ if (!settings || typeof settings.repo_path !== 'string')
76
+ return { ...fact, enabled: false, cause: 'source', reason: 'The site is not connected to a Git repository, so no pull request builds.' };
77
+ if (settings.stop_builds === true)
78
+ return { ...fact, enabled: false, cause: 'deployments', reason: "Builds are stopped for the site, production and Deploy Previews alike (Build settings → Build status)." };
79
+ if (settings.skip_prs === true)
80
+ return { ...fact, enabled: false, cause: 'previews', reason: 'Deploy Previews are off for the site (Branches and deploy contexts).' };
81
+ return fact;
82
+ }
83
+ /** The one fact of several sites' built from the repository: a pull request gets a preview once any site builds it, so the one whose previews are on, else the first. */
84
+ export function pickPreviewsFact(facts) {
85
+ return facts.find((fact) => fact.enabled === true) ?? facts[0];
86
+ }
87
+ /** Netlify's page where Deploy Previews are turned on, as the wait for a build names it too. */
88
+ export const netlifyPreviewsPage = (site) => `https://app.netlify.com/projects/${encodeURIComponent(site)}/configuration/deploys#deploy-previews`;
89
+ /** Cloudflare's page where a Pages project's preview branch control is set. */
90
+ export const cloudflarePreviewsPage = (account, project) => `https://dash.cloudflare.com/${account}/pages/view/${encodeURIComponent(project)}/settings/builds`;
91
+ /** The CLI's change of a site's Deploy Previews to on. */
92
+ export const netlifyPreviewsCall = (id) => ['api', 'updateSite', '--data', JSON.stringify({ site_id: id, body: { build_settings: { skip_prs: false } } })];
93
+ /** The site's Deploy Previews as the CLI reads them by the site's name; null when it could not say. */
94
+ export async function readNetlifyPreviews(run, site) {
95
+ try {
96
+ const read = record(JSON.parse(await run(readSiteCall(site))));
97
+ const fact = read && netlifyPreviewsFact(read);
98
+ return fact && typeof read.id === 'string' && /^[A-Za-z0-9-]{1,64}$/.test(read.id) ? { id: read.id, fact } : null;
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ /** Turns a site's Deploy Previews on through the Netlify CLI signed in as the person, where that is the one setting in the way, and reads the site back. */
105
+ export async function enableNetlifyPreviews(run, site) {
106
+ const page = netlifyPreviewsPage(site);
107
+ const unread = { project: site, enabled: 'unknown', reason: "The Netlify CLI could not read the site: it is signed out, or the site is in no team of the person's." };
108
+ const read = await readNetlifyPreviews(run, site);
109
+ if (!read)
110
+ return { fact: unread, page, changed: false, reason: unread.reason };
111
+ if (read.fact.enabled !== false)
112
+ return { fact: read.fact, page, changed: false };
113
+ if (read.fact.cause !== 'previews')
114
+ return { fact: read.fact, page, changed: false, reason: `The run changes the Deploy Previews setting alone. ${read.fact.reason}` };
115
+ try {
116
+ await run(netlifyPreviewsCall(read.id));
117
+ }
118
+ catch {
119
+ return { fact: read.fact, page, changed: false, reason: 'Netlify refused the change: the role may not allow it.' };
120
+ }
121
+ const after = await readNetlifyPreviews(run, site);
122
+ return { fact: after?.fact ?? read.fact, page, changed: after?.fact.enabled === true };
123
+ }
@@ -19,6 +19,32 @@ 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
+ /** A host's GitHub app that cannot see the repository: the one screen opening the app's installation page on GitHub, and the retry once the person gave it access there. */
23
+ const appAccess = (id, name) => ({
24
+ [`${id}-access`]: {
25
+ id: `${id}-access`,
26
+ kind: 'confirm',
27
+ title: `${name} needs access on GitHub`,
28
+ hint: `${name}'s GitHub app can't see this repository yet. Give it access on the GitHub page that opens. The run carries on by itself once it can.`,
29
+ prompt: `Open GitHub to give ${name} access?`,
30
+ confirmLabel: 'Open GitHub',
31
+ skipLabel: 'Skip',
32
+ },
33
+ [`${id}-access-retry`]: { id: `${id}-access-retry`, kind: 'confirm', title: `${name} needs access on GitHub`, prompt: `${name} still can't see the repository.`, confirmLabel: 'Try again', skipLabel: 'Skip' },
34
+ });
35
+ /** The same screens when the host's CLI on this machine is signed in already: no login to run, so the yes is to the setup as that account, and it is still asked. */
36
+ export const HOST_SIGNED_IN = {
37
+ 'vercel-setup': {
38
+ hint: 'Impeccable reviews each pull request on a preview. Vercel can host those previews: the run sets it up with the Vercel CLI on this machine, which is signed in already; what is live stays as it is.',
39
+ prompt: 'Set Vercel up with that sign-in?',
40
+ confirmLabel: 'Set up',
41
+ },
42
+ 'netlify-setup': {
43
+ hint: 'Impeccable reviews each pull request on a preview. Netlify can host those previews: the run sets it up with the Netlify CLI on this machine, which is signed in already; what is live stays as it is.',
44
+ prompt: 'Set Netlify up with that sign-in?',
45
+ confirmLabel: 'Set up',
46
+ },
47
+ };
22
48
  /** Where a person reads or changes a site's password in Netlify, as Netlify's docs name the page on every plan. */
23
49
  export const NETLIFY_PASSWORD_WHERE = 'Project configuration → General → Visitor access';
24
50
  /** The wait for a host's build that ran out: the page where the host's builds of pull requests are turned on, and the wait again. */
@@ -34,30 +60,35 @@ const buildWait = (id, name, where) => ({
34
60
  },
35
61
  [`${id}-build-retry`]: { id: `${id}-build-retry`, kind: 'confirm', title: `Waiting for ${name}`, prompt: `Still no preview from ${name}.`, confirmLabel: 'Keep waiting', skipLabel: 'Skip' },
36
62
  });
37
- /** 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, and to making a site behind Netlify's team login public. */
38
- export const HOST_SCREENS = {
39
- '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' },
40
- 'netlify-access': {
41
- id: 'netlify-access',
63
+ /** A host that builds the repository but not its pull requests: the one consent to turning pull-request previews on as the person (the tool names the project in the prompt), and the retry once they changed it on the host's page where the run could not. */
64
+ const previewsOff = (id, name, where) => ({
65
+ [`${id}-previews`]: {
66
+ id: `${id}-previews`,
42
67
  kind: 'confirm',
43
- title: 'Netlify needs access on GitHub',
44
- hint: "Netlify's GitHub app can't see this repository yet. Give it access on the GitHub page that opens. The run carries on by itself once it can.",
45
- prompt: 'Open GitHub to give Netlify access?',
46
- confirmLabel: 'Open GitHub',
68
+ title: `${name}, but no pull-request previews`,
69
+ hint: `Impeccable reviews pull requests on their previews, and ${name} isn't building previews for this repository's pull requests. The run turns them on as you; what is live stays as it is.`,
70
+ prompt: 'Turn on pull-request previews?',
71
+ confirmLabel: 'Turn on',
47
72
  skipLabel: 'Skip',
48
73
  },
49
- 'netlify-access-retry': { id: 'netlify-access-retry', kind: 'confirm', title: 'Netlify needs access on GitHub', prompt: "Netlify still can't see the repository.", confirmLabel: 'Try again', skipLabel: 'Skip' },
50
- 'vercel-signin-retry': { id: 'vercel-signin-retry', kind: 'confirm', title: 'Sign in to Vercel', prompt: 'The sign-in did not complete.', confirmLabel: 'Try again', skipLabel: 'Skip' },
51
- 'vercel-access': {
52
- id: 'vercel-access',
74
+ [`${id}-previews-retry`]: {
75
+ id: `${id}-previews-retry`,
53
76
  kind: 'confirm',
54
- title: 'Vercel needs access on GitHub',
55
- hint: "Vercel's GitHub app can't see this repository yet. Give it access on the GitHub page that opens. The run carries on by itself once it can.",
56
- prompt: 'Open GitHub to give Vercel access?',
57
- confirmLabel: 'Open GitHub',
77
+ title: `${name}, but no pull-request previews`,
78
+ hint: `${name} still builds no previews for pull requests. On the page that opened, ${where}.`,
79
+ prompt: 'Changed it?',
80
+ confirmLabel: 'Try again',
58
81
  skipLabel: 'Skip',
59
82
  },
60
- 'vercel-access-retry': { id: 'vercel-access-retry', kind: 'confirm', title: 'Vercel needs access on GitHub', prompt: "Vercel still can't see the repository.", confirmLabel: 'Try again', skipLabel: 'Skip' },
83
+ });
84
+ /** The screens a tool shows on its own: a sign-in's retry, the one-time access the host's GitHub app needs, the consent to turning a host's pull-request previews on, 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, and to making a site behind Netlify's team login public. */
85
+ export const HOST_SCREENS = {
86
+ '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' },
87
+ ...appAccess('netlify', 'Netlify'),
88
+ 'vercel-signin-retry': { id: 'vercel-signin-retry', kind: 'confirm', title: 'Sign in to Vercel', prompt: 'The sign-in did not complete.', confirmLabel: 'Try again', skipLabel: 'Skip' },
89
+ ...appAccess('vercel', 'Vercel'),
90
+ // Cloudflare's GitHub app covers only the repositories its installation names: a connected Pages project with previews on, and no build of a pushed branch, is one it cannot see.
91
+ ...appAccess('cloudflare-app', 'Cloudflare'),
61
92
  // Vercel refused the run's lock on a new project: the person turns it on, since the test drive's push is the project's first deployment.
62
93
  'vercel-lock': {
63
94
  id: 'vercel-lock',
@@ -72,6 +103,36 @@ export const HOST_SCREENS = {
72
103
  ...buildWait('vercel', 'Vercel', 'connect the repository in Settings → Git'),
73
104
  ...buildWait('netlify', 'Netlify', 'turn Deploy Previews on'),
74
105
  ...buildWait('cloudflare', 'Cloudflare', 'connect the repository to the Pages project'),
106
+ ...previewsOff('netlify', 'Netlify', 'turn Deploy Previews on under Branches and deploy contexts → Configure'),
107
+ ...previewsOff('cloudflare', 'Cloudflare', 'set Preview branch control to All non-Production branches under Builds & deployments'),
108
+ // No Pages project builds the repository's pull requests: the one consent to connecting the repository to the project the checkout names, or to creating one for it where Cloudflare builds a Worker instead (the tool names the project in the prompt), and the sign-in cf needs for either.
109
+ 'cloudflare-setup': {
110
+ id: 'cloudflare-setup',
111
+ kind: 'confirm',
112
+ title: 'Cloudflare, but no pull-request previews',
113
+ hint: 'Setting up Cloudflare Pages takes your Cloudflare sign-in. What is live stays as it is.',
114
+ prompt: 'Sign in to Cloudflare in your browser?',
115
+ confirmLabel: 'Sign in',
116
+ skipLabel: 'Skip',
117
+ },
118
+ 'cloudflare-connect': {
119
+ id: 'cloudflare-connect',
120
+ kind: 'confirm',
121
+ title: 'Cloudflare, but no pull-request previews',
122
+ hint: "Impeccable reviews pull requests on their previews, and the Pages project isn't connected to this repository, so nothing builds. The run connects it as you: every branch but the default one gets a preview.",
123
+ prompt: 'Connect the repository to the Pages project?',
124
+ confirmLabel: 'Connect',
125
+ skipLabel: 'Skip',
126
+ },
127
+ 'cloudflare-create': {
128
+ id: 'cloudflare-create',
129
+ kind: 'confirm',
130
+ title: 'Cloudflare, but no pull-request previews',
131
+ hint: 'Impeccable reviews pull requests on their previews, and Cloudflare builds this repository as a Worker, which gives them none. The run creates a Pages project connected to it as you; the Worker stays as it is.',
132
+ prompt: 'Create the Pages project?',
133
+ confirmLabel: 'Create',
134
+ skipLabel: 'Skip',
135
+ },
75
136
  // Vercel's login in front of the previews: the one consent to a bypass secret the run creates as the person, and the paste when the CLI cannot.
76
137
  'vercel-bypass': {
77
138
  id: 'vercel-bypass',
@@ -87,7 +148,7 @@ export const HOST_SCREENS = {
87
148
  kind: 'text',
88
149
  sensitive: true,
89
150
  title: 'Your Vercel previews are protected',
90
- 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.',
151
+ prompt: 'Vercel keeps your previews behind a login, and the Vercel CLI here could not make a bypass secret for them: that takes the Member role, or Project Administrator. 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.',
91
152
  hint: 'Paste the secret',
92
153
  },
93
154
  // 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.
@@ -105,7 +166,7 @@ export const HOST_SCREENS = {
105
166
  kind: 'text',
106
167
  sensitive: true,
107
168
  title: 'Your Netlify previews are protected',
108
- 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.`,
169
+ prompt: `Netlify keeps your previews behind the site password, and Netlify does not give it back to the run. Find it, or set a new one, in ${NETLIFY_PASSWORD_WHERE}. It is stored encrypted and used only to open previews.`,
109
170
  hint: 'Paste the password',
110
171
  },
111
172
  // Netlify's team login in front of the previews: the one consent to making the site public as the person, and the retry once they changed it on Netlify's page where Netlify refused.
@@ -154,8 +215,27 @@ export const HOST_SCREENS = {
154
215
  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.',
155
216
  },
156
217
  };
218
+ /**
219
+ * A key's screen naming the one address the key goes to: the consent to a bypass secret, a site password or a service
220
+ * token is to that address, which the key opens before anything saves it. A confirm says so in its supporting text; a
221
+ * text or account screen, under its prompt.
222
+ */
223
+ export function sentTo(screen, url) {
224
+ const line = `This run sends it first to ${url}, to prove it opens before anything saves it.`;
225
+ return screen.kind === 'confirm' ? { ...screen, hint: `${screen.hint} ${line}` } : { ...screen, prompt: `${screen.prompt} ${line}` };
226
+ }
227
+ /** The apps question on a repository with several apps, worded here: the tool lists the apps the state has, each picked as it is on, so every run asks it the same way. */
228
+ export const APPS_QUESTION = {
229
+ id: 'apps',
230
+ kind: 'multi',
231
+ title: 'This repository has several apps',
232
+ prompt: 'Which apps should get Impeccable previews?',
233
+ hint: 'Each draws pages of its own; untick any that should not get previews.',
234
+ };
235
+ /** The apps question's hint when the command was started below the repository's top: `where` is an app's folder ("Tally Board's folder") or the folder itself. */
236
+ export const startedIn = (where) => `You started in ${where}. Impeccable sets up from the top of the repository.`;
157
237
  /** 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. */
158
- export const TOOL_SHOWN = new Set(['netlify-setup', 'vercel-setup']);
238
+ export const TOOL_SHOWN = new Set(['netlify-setup', 'vercel-setup', APPS_QUESTION.id]);
159
239
  /** A question as the person reads it: the fixed words for a host's id, else the model's own. */
160
240
  export function hostQuestion(q) {
161
241
  const fixed = Object.hasOwn(HOST_QUESTIONS, q.id) ? HOST_QUESTIONS[q.id] : undefined;
package/dist/hosted.js CHANGED
@@ -85,30 +85,66 @@ const HOSTS = {
85
85
  netlify: { name: 'Netlify', gate: 'netlify-protection', login: /(^|\.)netlify\.com$/ },
86
86
  cloudflare: { name: 'Cloudflare Pages', gate: 'cloudflare-access', login: /(^|\.)cloudflareaccess\.com$/ },
87
87
  };
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$/;
88
+ const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
89
+ const NETLIFY = new RegExp(`^(?=[^.]{1,63}\\.netlify\\.app$)deploy-preview-[1-9]\\d{0,6}--(${LABEL})\\.netlify\\.app$`);
89
90
  /** 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
+ const PAGES = new RegExp(`^${LABEL}\\.(${LABEL})\\.pages\\.dev$`);
92
+ /** 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. */
93
+ const LIVE = { vercel: new RegExp(`^${LABEL}\\.vercel\\.app$`), netlify: new RegExp(`^(?!.*--)(${LABEL})\\.netlify\\.app$`), cloudflare: new RegExp(`^(${LABEL})\\.pages\\.dev$`) };
94
+ /** https on one name, with no credentials, port, query or fragment. */
95
+ const plainHttps = (url) => url.protocol === 'https:' && !url.username && !url.password && !url.port && !url.search && !url.hash;
91
96
  /** 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. */
92
97
  const hostOf = (url) => {
93
- if (url.protocol !== 'https:' || url.username || url.password || url.port || url.search || url.hash)
98
+ if (!plainHttps(url))
94
99
  return 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;
100
+ return LIVE.vercel.test(url.hostname) ? 'vercel' : NETLIFY.test(url.hostname) ? 'netlify' : PAGES.test(url.hostname) ? 'cloudflare' : null;
96
101
  };
102
+ /** 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. */
103
+ const liveHostOf = (url) => {
104
+ if (!plainHttps(url))
105
+ return null;
106
+ return LIVE.vercel.test(url.hostname) ? 'vercel' : /(^|\.)netlify\.app$/.test(url.hostname) ? 'netlify' : /(^|\.)pages\.dev$/.test(url.hostname) ? 'cloudflare' : 'other';
107
+ };
108
+ /**
109
+ * Whether an address a file of the checkout names is a live app's on one of the hosts, and not a preview's: the site's
110
+ * or project's own address under vercel.app, netlify.app or pages.dev (a Netlify deploy preview, a Pages deployment or
111
+ * branch alias, and a Vercel branch alias, `<project>-git-<branch>-<team>`, are previews whatever file links them). Any
112
+ * other address a file names is not opened: a file is anyone's to write, and a name there could reach a service on the
113
+ * person's own network, which only the host tools' facts may name.
114
+ */
115
+ export function liveShaped(url) {
116
+ if (!URL.canParse(url))
117
+ return false;
118
+ const parsed = new URL(url);
119
+ const host = liveHostOf(parsed);
120
+ if (host === 'vercel')
121
+ return !/-git-/.test(parsed.hostname);
122
+ if (host === 'netlify')
123
+ return LIVE.netlify.test(parsed.hostname);
124
+ if (host === 'cloudflare')
125
+ return LIVE.cloudflare.test(parsed.hostname);
126
+ return false;
127
+ }
97
128
  /** The host a preview address belongs to, by name; null for an address of none. */
98
129
  export function previewHostName(url) {
99
130
  const host = URL.canParse(url) ? hostOf(new URL(url)) : null;
100
131
  return host ? HOSTS[host].name : null;
101
132
  }
102
- /** The Netlify site a deploy preview address belongs to, by name; null for any other address. */
133
+ /** The Netlify site a deploy preview address, or the site's own netlify.app address, belongs to, by name; null for any other address. */
103
134
  export function netlifySiteOf(url) {
104
- return URL.canParse(url) && hostOf(new URL(url)) === 'netlify' ? NETLIFY.exec(new URL(url).hostname)[1] : null;
135
+ if (!URL.canParse(url) || !plainHttps(new URL(url)))
136
+ return null;
137
+ const { hostname } = new URL(url);
138
+ return NETLIFY.exec(hostname)?.[1] ?? LIVE.netlify.exec(hostname)?.[1] ?? null;
105
139
  }
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` };
140
+ /** 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. A live address stands for none. */
141
+ function patternOf(hostname) {
142
+ const site = NETLIFY.exec(hostname)?.[1];
143
+ if (site)
144
+ return { pattern: `https://deploy-preview-{pr}--${site}.netlify.app` };
145
+ const project = PAGES.exec(hostname)?.[1];
146
+ if (project)
147
+ return { pattern: `https://{branch}.${project}.pages.dev` };
112
148
  return {};
113
149
  }
114
150
  const ENTITIES = {
@@ -123,7 +159,7 @@ function plain(text) {
123
159
  }).replace(/\s+/g, ' ').trim().toLowerCase();
124
160
  }
125
161
  /** Whether a file the checkout tracks holds the words; .env files are never read. */
126
- async function inCheckout(root, words) {
162
+ async function checkoutHolds(root, words) {
127
163
  try {
128
164
  await execFileAsync('git', ['grep', '--quiet', '--fixed-strings', '--ignore-case', '-e', words, '--', '.', ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000, env: gitEnvironment() });
129
165
  return true;
@@ -132,6 +168,17 @@ async function inCheckout(root, words) {
132
168
  return false;
133
169
  }
134
170
  }
171
+ /** Whether a file the checkout tracks (in `folder`, one app's, when given) names the address's origin as such, whole (a full stop after it is the sentence's): not as the tail of a longer host name, such as a deploy preview's of the same site. */
172
+ export async function checkoutNames(root, origin, folder = '') {
173
+ const escaped = origin.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
174
+ try {
175
+ await execFileAsync('git', ['grep', '--quiet', '--ignore-case', '--extended-regexp', '-e', `(^|[^A-Za-z0-9.-])${escaped}([^A-Za-z0-9.-]|\\.([^A-Za-z0-9]|$)|$)`, '--', `:(literal)${folder || '.'}`, ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000, env: gitEnvironment() });
176
+ return true;
177
+ }
178
+ catch {
179
+ return false;
180
+ }
181
+ }
135
182
  /** What a visitor never sees of a page: comments, scripts, styles and templates. */
136
183
  const UNSEEN = /<!--[\s\S]*?-->|<(script|style|noscript|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
137
184
  /** The words a visitor sees: the title and the text, without comments, scripts, styles, tags or attributes. */
@@ -206,18 +253,22 @@ async function formOpens(target, password, phrase, open, signal) {
206
253
  * header, the one way reviews can send it, and with `form` posted to Netlify's form too where the header did not get past
207
254
  * the lock (only for a site whose settings show a password: an app's own password form looks the same); with `access`, a
208
255
  * Cloudflare Access service token, a Pages address is opened with it in the two headers Access reads it from. None goes
209
- * to another host.
256
+ * to another host. With `live`, the address is a live app's rather than a preview's: any https address is opened, a
257
+ * host's lock is told by its domain, or by the host named in `live` for a custom domain the host tool answered as the
258
+ * project's or site's, and no pattern is answered.
210
259
  */
211
260
  export async function provePreview(url, words, root, deps = {}) {
212
261
  const unproven = (reason, status = null) => ({ proven: false, status, reason });
213
- const host = URL.canParse(url) ? hostOf(new URL(url)) : null;
262
+ const parsed = URL.canParse(url) ? new URL(url) : null;
263
+ const host = !parsed ? null : !deps.live ? hostOf(parsed) : !plainHttps(parsed) ? null : typeof deps.live === 'string' ? deps.live : liveHostOf(parsed);
214
264
  if (!host)
215
- 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.');
216
- const { name, gate, login } = HOSTS[host];
265
+ 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, or a Cloudflare Pages preview, https://<id or branch>.<project>.pages.dev, with no query, is opened.');
266
+ // 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.
267
+ const { name, gate, login } = host === 'other' ? { name: 'the app', gate: undefined, login: /$^/ } : HOSTS[host];
217
268
  const phrase = words.trim();
218
269
  if (phrase.length < 3 || phrase.length > 120 || /[\r\n]/.test(phrase))
219
270
  return unproven('The words are 3 to 120 characters on one line.');
220
- if (!(await inCheckout(root, phrase)))
271
+ if (!(await checkoutHolds(root, phrase)))
221
272
  return unproven("No file of this checkout holds those words: give words the app's pages show, from its source.");
222
273
  const signal = AbortSignal.any([AbortSignal.timeout(15_000), ...(deps.signal ? [deps.signal] : [])]);
223
274
  const open = deps.fetch ?? fetch;
@@ -240,7 +291,7 @@ export async function provePreview(url, words, root, deps = {}) {
240
291
  await res.body?.cancel().catch(() => { });
241
292
  const location = res.headers.get('location') ?? '';
242
293
  const next = URL.canParse(location, target.href) ? new URL(location, target) : null;
243
- if (next && login.test(next.hostname))
294
+ if (gate && next && login.test(next.hostname))
244
295
  return { ...unproven(`The address sent the browser to ${name}'s login${still}.`, res.status), gate };
245
296
  if (next?.origin !== target.origin)
246
297
  return unproven('The address sent the browser to another site.', res.status);
@@ -253,7 +304,7 @@ export async function provePreview(url, words, root, deps = {}) {
253
304
  // 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.
254
305
  const page = await pageText(res, 64_000);
255
306
  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)));
256
- if (!named)
307
+ if (!gate || !named)
257
308
  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);
258
309
  // A password the header did not get past: Netlify's own form says whether it is the site's password at all.
259
310
  if (withPassword && deps.form && (await formOpens(target, deps.password, phrase, open, signal))) {
@@ -266,7 +317,7 @@ export async function provePreview(url, words, root, deps = {}) {
266
317
  return unproven(`The address answered ${res.status}.`, res.status);
267
318
  }
268
319
  return visible(await pageText(res)).includes(plain(phrase))
269
- ? { proven: true, status: res.status, ...patternOf(host, new URL(url).hostname) }
320
+ ? { proven: true, status: res.status, ...(deps.live ? {} : patternOf(parsed.hostname)) }
270
321
  : unproven('The page does not show those words: it is another app, or another page.', res.status);
271
322
  }
272
323
  return unproven('The address redirected too many times.');
package/dist/http.js CHANGED
@@ -11,6 +11,8 @@ export async function requestJson(url, init, log) {
11
11
  ...(init.token ? { authorization: `Bearer ${init.token}` } : {}),
12
12
  },
13
13
  body: init.body === undefined ? undefined : JSON.stringify(init.body),
14
+ // A 307 or 308 would send the body (a sign-in code, a bypass secret, a password) on to wherever it points.
15
+ redirect: 'error',
14
16
  signal: AbortSignal.timeout(init.timeoutMs ?? 60_000),
15
17
  });
16
18
  const text = await res.text();
package/dist/log.js CHANGED
@@ -1,4 +1,4 @@
1
- import { appendFileSync } from 'node:fs';
1
+ import { appendFileSync, chmodSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { redactShapes } from './scan.js';
@@ -32,6 +32,7 @@ export function createLog(debug, dir = tmpdir()) {
32
32
  if (!debug)
33
33
  return { secret, write: () => { } };
34
34
  const path = join(dir, `impeccable-reviews-${new Date().toISOString().replace(/[:.]/g, '-')}.log`);
35
+ let tightened = false;
35
36
  return {
36
37
  path,
37
38
  secret,
@@ -39,6 +40,11 @@ export function createLog(debug, dir = tmpdir()) {
39
40
  const detail = data === undefined ? '' : ` ${typeof data === 'string' ? data : JSON.stringify(data)}`;
40
41
  try {
41
42
  appendFileSync(path, `${scrub(`${new Date().toISOString()} ${message}${detail}`, secrets)}\n`, { mode: 0o600 });
43
+ // mode only applies to a new file; one that was there keeps its own until told.
44
+ if (!tightened) {
45
+ tightened = true;
46
+ chmodSync(path, 0o600);
47
+ }
42
48
  }
43
49
  catch {
44
50
  // A debug log that cannot be written is not worth stopping the run for.