@impeccable/reviews 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -20
- package/dist/agent.js +9 -4
- package/dist/cloudflare-access.js +77 -47
- package/dist/cloudflare.js +325 -23
- package/dist/fence.js +939 -143
- package/dist/focus.js +5 -4
- package/dist/host-previews.js +107 -0
- package/dist/host-questions.js +90 -20
- package/dist/hosted.js +72 -21
- package/dist/http.js +2 -0
- package/dist/log.js +7 -1
- package/dist/login.js +45 -13
- package/dist/main.js +32 -17
- package/dist/netlify-password.js +8 -6
- package/dist/netlify.js +43 -14
- package/dist/pi-paths.js +69 -0
- package/dist/playbook/cases/cloudflare-previews.md +12 -4
- package/dist/playbook/cases/first-preview-failure.md +1 -1
- package/dist/playbook/cases/live-app-address.md +15 -0
- package/dist/playbook/cases/monorepo.md +1 -1
- package/dist/playbook/cases/netlify-previews.md +3 -1
- package/dist/playbook/cases/netlify-setup.md +8 -7
- package/dist/playbook/cases/preview-protection.md +2 -1
- package/dist/playbook/cases/pull-requests.md +4 -4
- package/dist/playbook/cases/static-site.md +2 -2
- package/dist/playbook/cases/vercel-previews.md +2 -0
- package/dist/playbook/cases/vercel-setup.md +9 -6
- package/dist/playbook.js +2 -2
- package/dist/preflight.js +36 -7
- package/dist/report.js +1 -0
- package/dist/sandbox.js +364 -50
- package/dist/scan.js +134 -0
- package/dist/sync.js +44 -7
- package/dist/tools.js +397 -88
- package/dist/ui/bridge.js +18 -4
- package/dist/ui/look.js +1 -1
- package/dist/ui/screens.js +5 -3
- package/dist/ui/sensitive.js +10 -0
- package/dist/vercel-bypass.js +12 -7
- package/dist/vercel.js +110 -17
- package/package.json +1 -1
package/dist/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
|
|
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,107 @@
|
|
|
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
|
+
const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
|
|
17
|
+
const name = (value) => (typeof value === 'string' && /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(value) ? value : undefined);
|
|
18
|
+
const rules = (value) => (Array.isArray(value) ? value.filter((v) => typeof v === 'string') : []);
|
|
19
|
+
/**
|
|
20
|
+
* A branch rule as the hosts write them: Cloudflare's `*` stands for any run of characters, so `impeccable/*` and `*`
|
|
21
|
+
* both name the test drive's branch; Vercel's rules are minimatch (`minimatch`), where `*` stops at a slash, `**`
|
|
22
|
+
* does not, and a double star before a slash stands for any number of leading folders, none included.
|
|
23
|
+
*/
|
|
24
|
+
export function matchesBranch(rule, branch, minimatch = false) {
|
|
25
|
+
const escaped = (part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
26
|
+
const stars = (span) => span.split('*').map(escaped).join('[^/]*');
|
|
27
|
+
const pattern = minimatch
|
|
28
|
+
? rule.split('**/').map((piece) => piece.split('**').map(stars).join('.*')).join('(?:.*/)?')
|
|
29
|
+
: rule.split('*').map(escaped).join('.*');
|
|
30
|
+
return new RegExp(`^${pattern}$`).test(branch);
|
|
31
|
+
}
|
|
32
|
+
/** 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. */
|
|
33
|
+
export function cloudflarePreviewsFact(project) {
|
|
34
|
+
const config = record(record(project.source)?.config);
|
|
35
|
+
const fact = { project: name(project.name) ?? '', enabled: true, ...(typeof config?.production_branch === 'string' ? { productionBranch: config.production_branch } : {}) };
|
|
36
|
+
if (!config)
|
|
37
|
+
return { ...fact, enabled: false, cause: 'source', reason: 'The project is not connected to a Git repository, so no push builds.' };
|
|
38
|
+
if (config.deployments_enabled === false)
|
|
39
|
+
return { ...fact, enabled: false, cause: 'deployments', reason: 'Automatic deployments are off for the project, production and previews alike (Settings → Builds & deployments).' };
|
|
40
|
+
const setting = config.preview_deployment_setting;
|
|
41
|
+
if (setting === 'none')
|
|
42
|
+
return { ...fact, enabled: false, cause: 'previews', reason: 'Preview branch control is set to None: no branch but production builds.' };
|
|
43
|
+
if (setting === 'custom') {
|
|
44
|
+
const excluded = rules(config.preview_branch_excludes).find((rule) => matchesBranch(rule, TEST_DRIVE_BRANCH));
|
|
45
|
+
if (excluded)
|
|
46
|
+
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.` };
|
|
47
|
+
if (!rules(config.preview_branch_includes).some((rule) => matchesBranch(rule, TEST_DRIVE_BRANCH)))
|
|
48
|
+
return { ...fact, enabled: false, cause: 'previews', reason: `Preview branch control is set to Custom branches, and no include rule names ${TEST_DRIVE_BRANCH}.` };
|
|
49
|
+
}
|
|
50
|
+
return fact;
|
|
51
|
+
}
|
|
52
|
+
/** What a Netlify site's build settings say of Deploy Previews: `skip_prs` turns them off, and `stop_builds` stops every build. */
|
|
53
|
+
export function netlifyPreviewsFact(site) {
|
|
54
|
+
const siteName = name(site.name);
|
|
55
|
+
if (!siteName)
|
|
56
|
+
return undefined;
|
|
57
|
+
const settings = record(site.build_settings);
|
|
58
|
+
const fact = { project: siteName, enabled: true, ...(typeof settings?.repo_branch === 'string' ? { productionBranch: settings.repo_branch } : {}) };
|
|
59
|
+
if (!settings || typeof settings.repo_path !== 'string')
|
|
60
|
+
return { ...fact, enabled: false, cause: 'source', reason: 'The site is not connected to a Git repository, so no pull request builds.' };
|
|
61
|
+
if (settings.stop_builds === true)
|
|
62
|
+
return { ...fact, enabled: false, cause: 'deployments', reason: "Builds are stopped for the site, production and Deploy Previews alike (Build settings → Build status)." };
|
|
63
|
+
if (settings.skip_prs === true)
|
|
64
|
+
return { ...fact, enabled: false, cause: 'previews', reason: 'Deploy Previews are off for the site (Branches and deploy contexts).' };
|
|
65
|
+
return fact;
|
|
66
|
+
}
|
|
67
|
+
/** 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. */
|
|
68
|
+
export function pickPreviewsFact(facts) {
|
|
69
|
+
return facts.find((fact) => fact.enabled === true) ?? facts[0];
|
|
70
|
+
}
|
|
71
|
+
/** Netlify's page where Deploy Previews are turned on, as the wait for a build names it too. */
|
|
72
|
+
export const netlifyPreviewsPage = (site) => `https://app.netlify.com/projects/${encodeURIComponent(site)}/configuration/deploys#deploy-previews`;
|
|
73
|
+
/** Cloudflare's page where a Pages project's preview branch control is set. */
|
|
74
|
+
export const cloudflarePreviewsPage = (account, project) => `https://dash.cloudflare.com/${account}/pages/view/${encodeURIComponent(project)}/settings/builds`;
|
|
75
|
+
/** The CLI's change of a site's Deploy Previews to on. */
|
|
76
|
+
export const netlifyPreviewsCall = (id) => ['api', 'updateSite', '--data', JSON.stringify({ site_id: id, body: { build_settings: { skip_prs: false } } })];
|
|
77
|
+
/** The site's Deploy Previews as the CLI reads them by the site's name; null when it could not say. */
|
|
78
|
+
export async function readNetlifyPreviews(run, site) {
|
|
79
|
+
try {
|
|
80
|
+
const read = record(JSON.parse(await run(readSiteCall(site))));
|
|
81
|
+
const fact = read && netlifyPreviewsFact(read);
|
|
82
|
+
return fact && typeof read.id === 'string' && /^[A-Za-z0-9-]{1,64}$/.test(read.id) ? { id: read.id, fact } : null;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** 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. */
|
|
89
|
+
export async function enableNetlifyPreviews(run, site) {
|
|
90
|
+
const page = netlifyPreviewsPage(site);
|
|
91
|
+
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." };
|
|
92
|
+
const read = await readNetlifyPreviews(run, site);
|
|
93
|
+
if (!read)
|
|
94
|
+
return { fact: unread, page, changed: false, reason: unread.reason };
|
|
95
|
+
if (read.fact.enabled !== false)
|
|
96
|
+
return { fact: read.fact, page, changed: false };
|
|
97
|
+
if (read.fact.cause !== 'previews')
|
|
98
|
+
return { fact: read.fact, page, changed: false, reason: `The run changes the Deploy Previews setting alone. ${read.fact.reason}` };
|
|
99
|
+
try {
|
|
100
|
+
await run(netlifyPreviewsCall(read.id));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return { fact: read.fact, page, changed: false, reason: 'Netlify refused the change: the role may not allow it.' };
|
|
104
|
+
}
|
|
105
|
+
const after = await readNetlifyPreviews(run, site);
|
|
106
|
+
return { fact: after?.fact ?? read.fact, page, changed: after?.fact.enabled === true };
|
|
107
|
+
}
|
package/dist/host-questions.js
CHANGED
|
@@ -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
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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:
|
|
44
|
-
hint:
|
|
45
|
-
prompt: '
|
|
46
|
-
confirmLabel: '
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
'vercel-access': {
|
|
52
|
-
id: 'vercel-access',
|
|
74
|
+
[`${id}-previews-retry`]: {
|
|
75
|
+
id: `${id}-previews-retry`,
|
|
53
76
|
kind: 'confirm',
|
|
54
|
-
title:
|
|
55
|
-
hint:
|
|
56
|
-
prompt: '
|
|
57
|
-
confirmLabel: '
|
|
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
|
-
|
|
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
|
|
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
|
|
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,6 +215,15 @@ 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
|
+
}
|
|
157
227
|
/** 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
228
|
export const TOOL_SHOWN = new Set(['netlify-setup', 'vercel-setup']);
|
|
159
229
|
/** A question as the person reads it: the fixed words for a host's id, else the model's own. */
|
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
|
|
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 =
|
|
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
|
|
98
|
+
if (!plainHttps(url))
|
|
94
99
|
return null;
|
|
95
|
-
return
|
|
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
|
-
|
|
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(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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 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) {
|
|
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]|$)|$)`, '--', '.', ':(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
|
|
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
|
-
|
|
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
|
|
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, ...
|
|
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.
|
package/dist/login.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createHash, randomBytes } from 'node:crypto';
|
|
3
3
|
import { createServer } from 'node:http';
|
|
4
|
+
import { win32 } from 'node:path';
|
|
4
5
|
import { requestJson } from './http.js';
|
|
5
6
|
import { UserError } from './log.js';
|
|
7
|
+
import { LOOPBACK } from './preflight.js';
|
|
6
8
|
import { askPerson } from './ui/bridge.js';
|
|
7
9
|
/** The callback ports, in the order the contract lists them; the Worker accepts no other. */
|
|
8
10
|
export const CALLBACK_PORTS = [8239, 8238, 8240, 8237, 8236, 8235];
|
|
@@ -40,7 +42,8 @@ function page(res, status, message, more = '') {
|
|
|
40
42
|
/**
|
|
41
43
|
* The page the tab shows once the code has come: it asks the command every
|
|
42
44
|
* half second where to go next, and goes there (the progress page), or says
|
|
43
|
-
* what went wrong; without scripts, a link does the asking.
|
|
45
|
+
* what went wrong; without scripts, a link does the asking. It hears the
|
|
46
|
+
* answer once.
|
|
44
47
|
*/
|
|
45
48
|
function waitingPage(res, state) {
|
|
46
49
|
const script = `<script>(function(){var said=document.getElementById('said'),next='/next?state='+encodeURIComponent(new URLSearchParams(location.search).get('state')||'');function ask(){fetch(next,{cache:'no-store'}).then(function(r){return r.json()}).then(function(a){if(a.page&&/^https?:/.test(a.page))location.replace(a.page);else if(a.error)said.textContent=a.error;else setTimeout(ask,500)},function(){said.textContent='Back to your terminal.'})}ask()})()</script>`;
|
|
@@ -53,8 +56,11 @@ function waitingPage(res, state) {
|
|
|
53
56
|
* the callback. A wrong state gets a 400 and the wait goes on; the right one
|
|
54
57
|
* hands over the code and answers at once with a page that asks `/next` where
|
|
55
58
|
* to go, which says "not yet" until `land` knows (a JSON `wait`), then the
|
|
56
|
-
* page or what went wrong. `/go` is the same for a tab without scripts:
|
|
57
|
-
* redirect, or the waiting page again.
|
|
59
|
+
* page or what went wrong, once. `/go` is the same for a tab without scripts:
|
|
60
|
+
* a redirect, or the waiting page again. Only the first callback with the
|
|
61
|
+
* right state counts: a later one gets a 400 and changes nothing, unless it
|
|
62
|
+
* is the same tab reloading, which waits again. `/next` and `/go` answer only
|
|
63
|
+
* once that callback has brought a code, since the state is all they check.
|
|
58
64
|
*/
|
|
59
65
|
export async function startCallbackServer(state, ports = CALLBACK_PORTS) {
|
|
60
66
|
let resolve;
|
|
@@ -65,7 +71,11 @@ export async function startCallbackServer(state, ports = CALLBACK_PORTS) {
|
|
|
65
71
|
});
|
|
66
72
|
/** A tab brought the code, and waits to hear where to go. */
|
|
67
73
|
let visited = false;
|
|
74
|
+
/** What the one callback that counts brought: its code, or '' when it said the sign-in did not finish. */
|
|
75
|
+
let brought;
|
|
68
76
|
let landing;
|
|
77
|
+
/** The tab has heard where to go, so nothing more is said. */
|
|
78
|
+
let said = false;
|
|
69
79
|
let heard = () => { };
|
|
70
80
|
const told = new Promise((r) => (heard = r)).finally(() => untold.delete(told));
|
|
71
81
|
const answer = (req, res) => {
|
|
@@ -75,9 +85,15 @@ export async function startCallbackServer(state, ports = CALLBACK_PORTS) {
|
|
|
75
85
|
return void res.writeHead(404, { connection: 'close' }).end();
|
|
76
86
|
if (url.searchParams.get('state') !== state)
|
|
77
87
|
return page(res, 400, 'This sign-in is not the one your terminal started. Run impeccable-reviews again.');
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
if (route !== '/callback') {
|
|
89
|
+
if (!visited || said)
|
|
90
|
+
return page(res, 400, 'Back to your terminal.');
|
|
91
|
+
// Heard once the answer that says where to go has left for the tab.
|
|
92
|
+
if (landing) {
|
|
93
|
+
said = true;
|
|
94
|
+
res.once('finish', heard);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
81
97
|
if (route === '/next')
|
|
82
98
|
return void res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', connection: 'close' }).end(JSON.stringify(landing ?? { wait: true }));
|
|
83
99
|
if (route === '/go') {
|
|
@@ -87,12 +103,14 @@ export async function startCallbackServer(state, ports = CALLBACK_PORTS) {
|
|
|
87
103
|
}
|
|
88
104
|
const got = url.searchParams.get('code');
|
|
89
105
|
const error = url.searchParams.get('error');
|
|
106
|
+
if (brought !== undefined)
|
|
107
|
+
return got && !error && got === brought && !said ? waitingPage(res, state) : page(res, 400, 'This sign-in already had its answer. Back to your terminal.');
|
|
108
|
+
brought = !got || error ? '' : got;
|
|
90
109
|
if (!got || error) {
|
|
91
110
|
page(res, 400, 'Sign-in did not finish. Back to your terminal.');
|
|
92
111
|
return reject(new UserError(`Sign-in did not finish${error ? ` (${error})` : ''}. Run impeccable-reviews again.`));
|
|
93
112
|
}
|
|
94
|
-
|
|
95
|
-
untold.add(told);
|
|
113
|
+
untold.add(told);
|
|
96
114
|
visited = true;
|
|
97
115
|
resolve(got);
|
|
98
116
|
waitingPage(res, state);
|
|
@@ -178,15 +196,29 @@ export function parsePasted(text, state) {
|
|
|
178
196
|
}
|
|
179
197
|
return /^[A-Za-z0-9._~-]{8,}$/.test(trimmed) ? trimmed : null;
|
|
180
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* The platform's command that opens `url` in the default browser, with no shell between. Null for anything but an https
|
|
201
|
+
* address, or plain http to this machine (localhost, 127.0.0.1, [::1] or a .localhost name), free of quotes, spaces,
|
|
202
|
+
* control characters and what a shell would read (^ | < >): Windows' opener runs a local file or any other protocol it is
|
|
203
|
+
* handed, and the address may come from the model or the setup API.
|
|
204
|
+
*/
|
|
205
|
+
export function browserCommand(url, platform = process.platform) {
|
|
206
|
+
const parsed = URL.canParse(url) ? new URL(url) : null;
|
|
207
|
+
const web = parsed?.protocol === 'https:' || (parsed?.protocol === 'http:' && (LOOPBACK.has(parsed.hostname) || /^(?:[a-z0-9-]+\.)+localhost$/.test(parsed.hostname)));
|
|
208
|
+
if (!web || /["^|<>\s\x00-\x1f\x7f]/.test(url))
|
|
209
|
+
return null;
|
|
210
|
+
// rundll32 by its full path: the run's folder is the checkout, which Windows would otherwise look in first.
|
|
211
|
+
return platform === 'darwin' ? ['open', [url]]
|
|
212
|
+
: platform === 'win32' ? [win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'rundll32.exe'), ['url.dll,FileProtocolHandler', url]]
|
|
213
|
+
: ['xdg-open', [url]];
|
|
214
|
+
}
|
|
181
215
|
/** Opens the address in the default browser; a failure is fine because the address is printed too. BROWSER=none skips it. */
|
|
182
216
|
export function openBrowser(url) {
|
|
183
|
-
|
|
217
|
+
const opener = browserCommand(url);
|
|
218
|
+
if (process.env.BROWSER === 'none' || !opener)
|
|
184
219
|
return;
|
|
185
|
-
const [command, args, verbatim] = process.platform === 'darwin' ? ['open', [url], false]
|
|
186
|
-
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', `"${url}"`], true]
|
|
187
|
-
: ['xdg-open', [url], false];
|
|
188
220
|
try {
|
|
189
|
-
const child = spawn(
|
|
221
|
+
const child = spawn(opener[0], opener[1], { stdio: 'ignore', detached: true });
|
|
190
222
|
child.on('error', () => { });
|
|
191
223
|
child.unref();
|
|
192
224
|
}
|