@impeccable/reviews 0.1.0
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 +68 -0
- package/dist/agent.js +183 -0
- package/dist/bin.js +11 -0
- package/dist/fence.js +1071 -0
- package/dist/focus.js +98 -0
- package/dist/host-questions.js +71 -0
- package/dist/hosted.js +195 -0
- package/dist/http.js +36 -0
- package/dist/log.js +38 -0
- package/dist/login.js +276 -0
- package/dist/main.js +252 -0
- package/dist/netlify.js +333 -0
- package/dist/playbook/cases/ci-tunnel.md +21 -0
- package/dist/playbook/cases/first-preview-failure.md +28 -0
- package/dist/playbook/cases/netlify-previews.md +17 -0
- package/dist/playbook/cases/netlify-setup.md +24 -0
- package/dist/playbook/cases/preview-protection.md +21 -0
- package/dist/playbook/cases/pull-requests.md +31 -0
- package/dist/playbook/cases/sign-in.md +21 -0
- package/dist/playbook/cases/static-site.md +16 -0
- package/dist/playbook/cases/vercel-previews.md +16 -0
- package/dist/playbook/cases/vercel-setup.md +27 -0
- package/dist/playbook/cases/workflow-as-person.md +23 -0
- package/dist/playbook/github-previews.js +101 -0
- package/dist/playbook/netlify-pattern.js +24 -0
- package/dist/playbook/netlify-previews.js +144 -0
- package/dist/playbook/probe-preview.js +67 -0
- package/dist/playbook/protection-secrets.js +15 -0
- package/dist/playbook/public-preview-request.js +96 -0
- package/dist/playbook/publication-wait.js +46 -0
- package/dist/playbook/vercel-protection.js +121 -0
- package/dist/playbook.js +98 -0
- package/dist/preflight.js +211 -0
- package/dist/stages.js +34 -0
- package/dist/sync.js +142 -0
- package/dist/tools.js +2015 -0
- package/dist/transcript.js +139 -0
- package/dist/ui/bridge.js +145 -0
- package/dist/ui/frames.js +62 -0
- package/dist/ui/look.js +368 -0
- package/dist/ui/run-state.js +91 -0
- package/dist/ui/screens.js +544 -0
- package/dist/ui/sensitive.js +14 -0
- package/dist/vercel-bypass.js +136 -0
- package/dist/vercel.js +70 -0
- package/package.json +39 -0
package/dist/focus.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The setup covers one situation: a web front end previewed by the host that
|
|
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
|
|
5
|
+
* before the model reads it, so the setup page's other cards never reach the run.
|
|
6
|
+
*/
|
|
7
|
+
/** The state items and checkup checks this setup works on. */
|
|
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' };
|
|
11
|
+
/** 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
|
+
const CARDS = new Set(['installed', 'preview', 'signin', 'done']);
|
|
13
|
+
/** 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
|
+
const WRITES = new Set(['preview', 'testAccounts', 'connectionCheck', 'latestPullCheck', 'previewUrlPattern']);
|
|
15
|
+
/** What the model reads of the instructions; `steps`, `rules`, `endpoints` and `writes` are trimmed further. */
|
|
16
|
+
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
|
+
const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
|
|
20
|
+
const only = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => keys.has(key)));
|
|
21
|
+
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) {
|
|
24
|
+
const all = record(instructions);
|
|
25
|
+
if (!all)
|
|
26
|
+
return instructions;
|
|
27
|
+
const trim = (key, value) => {
|
|
28
|
+
if (key === 'endpoints' || key === 'writes')
|
|
29
|
+
return record(value) ? only(record(value), WRITES) : value;
|
|
30
|
+
if (key === 'steps' || key === 'rules')
|
|
31
|
+
return Array.isArray(value) ? value.filter((line) => typeof line !== 'string' || !OTHER_CARDS.test(line)) : value;
|
|
32
|
+
return value;
|
|
33
|
+
};
|
|
34
|
+
return Object.fromEntries(INSTRUCTIONS.filter((key) => key in all).map((key) => [key, trim(key, all[key])]));
|
|
35
|
+
}
|
|
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. */
|
|
39
|
+
export function focusState(state) {
|
|
40
|
+
const all = record(state);
|
|
41
|
+
if (!all)
|
|
42
|
+
return state;
|
|
43
|
+
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.
|
|
45
|
+
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);
|
|
48
|
+
const repo = (value) => {
|
|
49
|
+
const r = record(value);
|
|
50
|
+
if (!r)
|
|
51
|
+
return value;
|
|
52
|
+
const items = record(r.items);
|
|
53
|
+
return {
|
|
54
|
+
...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) } : {}),
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
...without(all, ['summary', 'steps']),
|
|
61
|
+
...(outside.length ? { notPartOfThisSetup: outside } : {}),
|
|
62
|
+
...(Array.isArray(all.repos) ? { repos: all.repos.map(repo) } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 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.
|
|
70
|
+
*/
|
|
71
|
+
export function focusCheckup(checkup) {
|
|
72
|
+
const all = record(checkup);
|
|
73
|
+
if (!all || !Array.isArray(all.repos))
|
|
74
|
+
return checkup;
|
|
75
|
+
// The lists carry no check key, so an entry stays only when it begins with a kept check's words: "<repository>: <what it says>…",
|
|
76
|
+
// or "<repository> (<app>): …". Anything else goes, however the other cards word it.
|
|
77
|
+
const kept = [];
|
|
78
|
+
const repos = all.repos.map((value) => {
|
|
79
|
+
const r = record(value);
|
|
80
|
+
if (!r || !Array.isArray(r.checks))
|
|
81
|
+
return value;
|
|
82
|
+
// 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))));
|
|
84
|
+
for (const says of checks.map((c) => record(c)?.says))
|
|
85
|
+
if (typeof r.name === 'string' && typeof says === 'string' && says)
|
|
86
|
+
kept.push({ repo: r.name, says });
|
|
87
|
+
return { ...r, checks };
|
|
88
|
+
});
|
|
89
|
+
const begins = (entry, { repo, says }) => entry.startsWith(`${repo}: ${says}`) || (entry.startsWith(`${repo} (`) && entry.startsWith(`): ${says}`, entry.indexOf('): ')));
|
|
90
|
+
const lists = ['next', 'forPerson', 'waitingFor'].map((key) => {
|
|
91
|
+
const list = all[key];
|
|
92
|
+
return Array.isArray(list) ? list.filter((entry) => typeof entry === 'string' && kept.some((check) => begins(entry, check))) : list;
|
|
93
|
+
});
|
|
94
|
+
const [next, forPerson, waitingFor] = lists;
|
|
95
|
+
const status = !lists.every(Array.isArray) ? all.status
|
|
96
|
+
: next.length || forPerson.length ? 'incomplete' : waitingFor.length ? 'waiting' : 'complete';
|
|
97
|
+
return { ...without(all, ['summary', 'runAgain']), status, repos, next, forPerson, waitingFor };
|
|
98
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** The ids the model may name, each worded here whatever else it writes for that id. */
|
|
2
|
+
export const HOST_QUESTIONS = {
|
|
3
|
+
'vercel-setup': {
|
|
4
|
+
kind: 'confirm',
|
|
5
|
+
title: 'Vercel, but no previews yet',
|
|
6
|
+
hint: 'Impeccable reviews each pull request on a preview. Vercel can host those previews: the run sets it up. It needs your Vercel sign-in; what is live stays as it is.',
|
|
7
|
+
prompt: 'Sign in to Vercel in your browser?',
|
|
8
|
+
confirmLabel: 'Sign in',
|
|
9
|
+
skipLabel: 'Skip',
|
|
10
|
+
},
|
|
11
|
+
'vercel-team': { kind: 'single', title: 'Vercel team', prompt: 'Which Vercel team should own the new project?' },
|
|
12
|
+
'netlify-setup': {
|
|
13
|
+
kind: 'confirm',
|
|
14
|
+
title: 'Netlify, but no previews yet',
|
|
15
|
+
hint: 'Impeccable reviews each pull request on a preview. Netlify can host those previews: the run sets it up. It needs your Netlify sign-in; what is live stays as it is.',
|
|
16
|
+
prompt: 'Sign in to Netlify in your browser?',
|
|
17
|
+
confirmLabel: 'Sign in',
|
|
18
|
+
skipLabel: 'Skip',
|
|
19
|
+
},
|
|
20
|
+
'netlify-team': { kind: 'single', title: 'Netlify team', prompt: 'Which Netlify team should own the new site?' },
|
|
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. */
|
|
23
|
+
export const HOST_SCREENS = {
|
|
24
|
+
'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
|
+
'netlify-access': {
|
|
26
|
+
id: 'netlify-access',
|
|
27
|
+
kind: 'confirm',
|
|
28
|
+
title: 'Netlify needs access on GitHub',
|
|
29
|
+
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.",
|
|
30
|
+
prompt: 'Open GitHub to give Netlify access?',
|
|
31
|
+
confirmLabel: 'Open GitHub',
|
|
32
|
+
skipLabel: 'Skip',
|
|
33
|
+
},
|
|
34
|
+
'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' },
|
|
35
|
+
'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' },
|
|
36
|
+
'vercel-access': {
|
|
37
|
+
id: 'vercel-access',
|
|
38
|
+
kind: 'confirm',
|
|
39
|
+
title: 'Vercel needs access on GitHub',
|
|
40
|
+
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.",
|
|
41
|
+
prompt: 'Open GitHub to give Vercel access?',
|
|
42
|
+
confirmLabel: 'Open GitHub',
|
|
43
|
+
skipLabel: 'Skip',
|
|
44
|
+
},
|
|
45
|
+
'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' },
|
|
46
|
+
// 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.
|
|
47
|
+
'vercel-bypass': {
|
|
48
|
+
id: 'vercel-bypass',
|
|
49
|
+
kind: 'confirm',
|
|
50
|
+
title: 'Your Vercel previews are protected',
|
|
51
|
+
hint: 'Impeccable reviews each pull request on its preview, and Vercel keeps yours behind a login. Vercel\'s way in for tools is a bypass secret made for one tool: the run creates one in your project, named "Impeccable reviews". It is stored encrypted, used only to open previews, and you can revoke it any time in Settings → Deployment Protection.',
|
|
52
|
+
prompt: 'Create the secret for Impeccable?',
|
|
53
|
+
confirmLabel: 'Create',
|
|
54
|
+
skipLabel: 'Skip',
|
|
55
|
+
},
|
|
56
|
+
'vercel-bypass-key': {
|
|
57
|
+
id: 'vercel-bypass-key',
|
|
58
|
+
kind: 'text',
|
|
59
|
+
sensitive: true,
|
|
60
|
+
title: 'Your Vercel previews are protected',
|
|
61
|
+
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
|
+
hint: 'Paste the secret',
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
/** 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
|
+
export const TOOL_SHOWN = new Set(['netlify-setup', 'vercel-setup']);
|
|
67
|
+
/** A question as the person reads it: the fixed words for a host's id, else the model's own. */
|
|
68
|
+
export function hostQuestion(q) {
|
|
69
|
+
const fixed = Object.hasOwn(HOST_QUESTIONS, q.id) ? HOST_QUESTIONS[q.id] : undefined;
|
|
70
|
+
return fixed ? { id: q.id, prompt: fixed.prompt } : q;
|
|
71
|
+
}
|
package/dist/hosted.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { BYPASS_HEADER } from './vercel-bypass.js';
|
|
4
|
+
/**
|
|
5
|
+
* Previews a host already deploys: what GitHub has on record of the
|
|
6
|
+
* repository's deployments (Vercel; Netlify's previews are read in netlify.ts),
|
|
7
|
+
* and the proof that a preview address, Vercel's or Netlify's, answers with
|
|
8
|
+
* this app. Neither changes anything on the host, and neither hands back more
|
|
9
|
+
* than the fields below: no response body, header or other GitHub field.
|
|
10
|
+
*/
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
/** How many deployments, and how many pull requests, the evidence reads: the newest. */
|
|
13
|
+
const LIMIT = 20;
|
|
14
|
+
const STATES = ['error', 'failure', 'inactive', 'in_progress', 'queued', 'pending', 'success'];
|
|
15
|
+
const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
|
|
16
|
+
const positive = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
17
|
+
/** An https address without credentials, a query or a fragment, as origin and path; null for anything else. */
|
|
18
|
+
function address(value) {
|
|
19
|
+
if (typeof value !== 'string' || value.length > 2048 || !/^https:\/\/[^\s\\?#@]+$/.test(value) || !URL.canParse(value))
|
|
20
|
+
return null;
|
|
21
|
+
const url = new URL(value);
|
|
22
|
+
return `${url.origin}${url.pathname}`.replace(/\/$/, '');
|
|
23
|
+
}
|
|
24
|
+
/** A branch name GitHub can be asked about: the characters git allows, no `..`, and neither a slash nor a dot at the end. */
|
|
25
|
+
const branchName = (value) => /^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/.test(value) && !value.includes('..') && !value.endsWith('/') && !value.endsWith('.');
|
|
26
|
+
/**
|
|
27
|
+
* GitHub's record of this repository's deployments: the newest, each with its latest status and the pull requests whose
|
|
28
|
+
* commit it built; or, with `branch`, that branch's head's only, so a pushed branch's deployment is found before any
|
|
29
|
+
* pull request exists.
|
|
30
|
+
*/
|
|
31
|
+
export async function readHostedPreviews(owner, repo, deps = {}) {
|
|
32
|
+
const unread = { deployments: [], error: "GitHub's deployments could not be read." };
|
|
33
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(owner) || !/^[A-Za-z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..')
|
|
34
|
+
return unread;
|
|
35
|
+
if (deps.branch !== undefined && !branchName(deps.branch))
|
|
36
|
+
return unread;
|
|
37
|
+
const runGh = deps.runGh ?? (async (args) => (await execFileAsync('gh', args, {
|
|
38
|
+
encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024, signal: deps.signal, env: { ...process.env, GH_PROMPT_DISABLED: '1' },
|
|
39
|
+
})).stdout);
|
|
40
|
+
const list = async (route) => {
|
|
41
|
+
const data = JSON.parse(await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]));
|
|
42
|
+
if (!Array.isArray(data))
|
|
43
|
+
throw new Error(`${route} is not a list`);
|
|
44
|
+
return data.slice(0, LIMIT);
|
|
45
|
+
};
|
|
46
|
+
const base = `/repos/${owner}/${repo}`;
|
|
47
|
+
try {
|
|
48
|
+
// A branch's head, as GitHub has it after the push.
|
|
49
|
+
const ref = deps.branch !== undefined ? record(JSON.parse(await runGh(['api', '--hostname', 'github.com', '--method', 'GET', `${base}/branches/${encodeURIComponent(deps.branch)}`]))) : undefined;
|
|
50
|
+
const head = record(ref?.commit)?.sha;
|
|
51
|
+
if (deps.branch !== undefined && (ref?.name !== deps.branch || typeof head !== 'string' || !/^[0-9a-f]{40}$/.test(head)))
|
|
52
|
+
return unread;
|
|
53
|
+
const [deployments, pulls] = await Promise.all([
|
|
54
|
+
list(`${base}/deployments?${head ? `sha=${head}&` : ''}per_page=${LIMIT}`),
|
|
55
|
+
deps.branch !== undefined ? [] : list(`${base}/pulls?state=all&sort=updated&direction=desc&per_page=${LIMIT}`),
|
|
56
|
+
]);
|
|
57
|
+
const heads = pulls.map(record).flatMap((pull) => (pull && positive(pull.number) ? [{ number: pull.number, sha: record(pull.head)?.sha }] : []));
|
|
58
|
+
const numbers = (found) => found.map(record).flatMap((pull) => (pull && positive(pull.number) ? [pull.number] : []));
|
|
59
|
+
let unsaid = false;
|
|
60
|
+
const read = await Promise.all(deployments.map(record).map(async (d) => {
|
|
61
|
+
if (!d || !positive(d.id) || typeof d.sha !== 'string' || !/^[0-9a-f]{40}$/.test(d.sha))
|
|
62
|
+
return null;
|
|
63
|
+
// GitHub lists a deployment's statuses newest first.
|
|
64
|
+
const latest = record((await list(`${base}/deployments/${d.id}/statuses?per_page=1`))[0]);
|
|
65
|
+
const state = STATES.find((s) => s === latest?.state) ?? null;
|
|
66
|
+
const environment = typeof d.environment === 'string' && d.environment.length <= 100 && !/[\x00-\x1f\x7f]/.test(d.environment) ? d.environment : null;
|
|
67
|
+
const url = state ? address(latest?.environment_url) : null;
|
|
68
|
+
let pullRequests = heads.filter((pull) => pull.sha === d.sha).map((pull) => pull.number);
|
|
69
|
+
// A pull request whose head moved on since, or that was updated before the 20 read, still names the commit. When GitHub cannot say, this one stays unlinked and the evidence says so. A branch's head is asked for by its branch, before its pull request exists, so none is looked for.
|
|
70
|
+
if (!pullRequests.length && state === 'success' && url && deps.branch === undefined)
|
|
71
|
+
pullRequests = numbers(await list(`${base}/commits/${d.sha}/pulls`).catch(() => ((unsaid = true), [])));
|
|
72
|
+
return { sha: d.sha, environment, state, url, vercel: record(d.creator)?.login === 'vercel[bot]', pullRequests };
|
|
73
|
+
}));
|
|
74
|
+
return { deployments: read.filter((d) => d !== null), ...(unsaid ? { error: 'GitHub could not say which pull requests some previews belong to: read again.' } : {}) };
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return unread;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const HOSTS = {
|
|
81
|
+
vercel: { name: 'Vercel', gate: 'vercel-protection', login: /(^|\.)vercel\.com$/ },
|
|
82
|
+
netlify: { name: 'Netlify', gate: 'netlify-protection', login: /(^|\.)netlify\.com$/ },
|
|
83
|
+
};
|
|
84
|
+
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. */
|
|
86
|
+
const hostOf = (url) => {
|
|
87
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.port || url.search || url.hash)
|
|
88
|
+
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;
|
|
90
|
+
};
|
|
91
|
+
/** The host a preview address belongs to, by name; null for an address of neither. */
|
|
92
|
+
export function previewHostName(url) {
|
|
93
|
+
const host = URL.canParse(url) ? hostOf(new URL(url)) : null;
|
|
94
|
+
return host ? HOSTS[host].name : null;
|
|
95
|
+
}
|
|
96
|
+
const ENTITIES = {
|
|
97
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”',
|
|
98
|
+
hellip: '…', middot: '·', bull: '•', laquo: '«', raquo: '»', copy: '©', reg: '®', trade: '™', times: '×',
|
|
99
|
+
};
|
|
100
|
+
/** Text as a reader meets it: entities decoded, case and runs of space ignored. */
|
|
101
|
+
function plain(text) {
|
|
102
|
+
return text.replace(/&(#x[0-9a-f]{1,6}|#[0-9]{1,7}|[a-z]+);/gi, (whole, name) => {
|
|
103
|
+
const code = name[0] !== '#' ? NaN : /^#x/i.test(name) ? Number.parseInt(name.slice(2), 16) : Number(name.slice(1));
|
|
104
|
+
return name[0] !== '#' ? (ENTITIES[name.toLowerCase()] ?? whole) : code <= 0x10ffff ? String.fromCodePoint(code) : whole;
|
|
105
|
+
}).replace(/\s+/g, ' ').trim().toLowerCase();
|
|
106
|
+
}
|
|
107
|
+
/** Whether a file the checkout tracks holds the words; .env files are never read. */
|
|
108
|
+
async function inCheckout(root, words) {
|
|
109
|
+
try {
|
|
110
|
+
await execFileAsync('git', ['grep', '--quiet', '--fixed-strings', '--ignore-case', '-e', words, '--', '.', ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000 });
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** 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, ' '));
|
|
119
|
+
/** The first `max` bytes of a page, as text. */
|
|
120
|
+
async function pageText(res, max = 1_000_000) {
|
|
121
|
+
const reader = res.body?.getReader();
|
|
122
|
+
if (!reader)
|
|
123
|
+
return '';
|
|
124
|
+
const chunks = [];
|
|
125
|
+
try {
|
|
126
|
+
for (let size = 0; size < max;) {
|
|
127
|
+
const { done, value } = await reader.read();
|
|
128
|
+
if (done)
|
|
129
|
+
break;
|
|
130
|
+
chunks.push(value);
|
|
131
|
+
size += value.byteLength;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
await reader.cancel().catch(() => { });
|
|
136
|
+
}
|
|
137
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Opens a preview address the way a visitor does, signed in nowhere, and proves it: it answers, and its page shows
|
|
141
|
+
* `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.
|
|
143
|
+
*/
|
|
144
|
+
export async function provePreview(url, words, root, deps = {}) {
|
|
145
|
+
const unproven = (reason, status = null) => ({ proven: false, status, reason });
|
|
146
|
+
const host = URL.canParse(url) ? hostOf(new URL(url)) : null;
|
|
147
|
+
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.');
|
|
149
|
+
const { name, gate, login } = HOSTS[host];
|
|
150
|
+
const phrase = words.trim();
|
|
151
|
+
if (phrase.length < 3 || phrase.length > 120 || /[\r\n]/.test(phrase))
|
|
152
|
+
return unproven('The words are 3 to 120 characters on one line.');
|
|
153
|
+
if (!(await inCheckout(root, phrase)))
|
|
154
|
+
return unproven("No file of this checkout holds those words: give words the app's pages show, from its source.");
|
|
155
|
+
const signal = AbortSignal.any([AbortSignal.timeout(15_000), ...(deps.signal ? [deps.signal] : [])]);
|
|
156
|
+
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' : '';
|
|
160
|
+
let target = new URL(url);
|
|
161
|
+
try {
|
|
162
|
+
for (let hop = 0; hop < 4; hop++) {
|
|
163
|
+
const res = await (deps.fetch ?? fetch)(target, { redirect: 'manual', headers, credentials: 'omit', signal });
|
|
164
|
+
if (res.status >= 300 && res.status < 400) {
|
|
165
|
+
await res.body?.cancel().catch(() => { });
|
|
166
|
+
const location = res.headers.get('location') ?? '';
|
|
167
|
+
const next = URL.canParse(location, target.href) ? new URL(location, target) : null;
|
|
168
|
+
if (next && login.test(next.hostname))
|
|
169
|
+
return { ...unproven(`The address sent the browser to ${name}'s login${still}.`, res.status), gate };
|
|
170
|
+
if (next?.origin !== target.origin)
|
|
171
|
+
return unproven('The address sent the browser to another site.', res.status);
|
|
172
|
+
target = next;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (res.status === 401 || (host === 'netlify' && res.status === 403)) {
|
|
176
|
+
// 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.
|
|
178
|
+
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);
|
|
181
|
+
}
|
|
182
|
+
if (res.status < 200 || res.status >= 300) {
|
|
183
|
+
await res.body?.cancel().catch(() => { });
|
|
184
|
+
return unproven(`The address answered ${res.status}.`, res.status);
|
|
185
|
+
}
|
|
186
|
+
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` } : {}) }
|
|
188
|
+
: unproven('The page does not show those words: it is another app, or another page.', res.status);
|
|
189
|
+
}
|
|
190
|
+
return unproven('The address redirected too many times.');
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return unproven('The address did not answer.');
|
|
194
|
+
}
|
|
195
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** One JSON request. Never throws: a failure comes back as `ok: false` with a short `error`. */
|
|
2
|
+
export async function requestJson(url, init, log) {
|
|
3
|
+
const method = init.method ?? 'GET';
|
|
4
|
+
const started = Date.now();
|
|
5
|
+
try {
|
|
6
|
+
const res = await fetch(url, {
|
|
7
|
+
method,
|
|
8
|
+
headers: {
|
|
9
|
+
accept: 'application/json',
|
|
10
|
+
...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
11
|
+
...(init.token ? { authorization: `Bearer ${init.token}` } : {}),
|
|
12
|
+
},
|
|
13
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
14
|
+
signal: AbortSignal.timeout(init.timeoutMs ?? 60_000),
|
|
15
|
+
});
|
|
16
|
+
const text = await res.text();
|
|
17
|
+
let data = text;
|
|
18
|
+
try {
|
|
19
|
+
data = text ? JSON.parse(text) : null;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Not JSON: keep the text.
|
|
23
|
+
}
|
|
24
|
+
log?.write(`${method} ${url} -> ${res.status} in ${Date.now() - started} ms`);
|
|
25
|
+
if (res.ok)
|
|
26
|
+
return { ok: true, status: res.status, data };
|
|
27
|
+
const said = data && typeof data === 'object' ? data.error : undefined;
|
|
28
|
+
const error = typeof said === 'string' ? said : typeof data === 'string' && data ? data.slice(0, 200) : res.statusText || `HTTP ${res.status}`;
|
|
29
|
+
return { ok: false, status: res.status, data, error };
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
const error = err instanceof Error ? (err.name === 'TimeoutError' ? 'the request timed out' : err.message) : String(err);
|
|
33
|
+
log?.write(`${method} ${url} failed: ${error}`);
|
|
34
|
+
return { ok: false, status: 0, data: null, error };
|
|
35
|
+
}
|
|
36
|
+
}
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { appendFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/** An error whose message is written for the person at the terminal. */
|
|
5
|
+
export class UserError extends Error {
|
|
6
|
+
}
|
|
7
|
+
const TOKEN = /\b(pst|phr)_[A-Za-z0-9._~+/=-]+/g;
|
|
8
|
+
const NAMES = 'access_token|run_token|code_verifier|code|authorization|x-api-key';
|
|
9
|
+
const FIELD = new RegExp(`("?(?:${NAMES})"?\\s*[:=]\\s*"?)(?:Bearer\\s+)?[^"&\\s,}]+`, 'gi');
|
|
10
|
+
/** A field whose value is a credential, such as an authorization header. */
|
|
11
|
+
export const SECRET_FIELD = new RegExp(`^(?:${NAMES})$`, 'i');
|
|
12
|
+
export function redact(text, secrets = []) {
|
|
13
|
+
let out = text;
|
|
14
|
+
for (const secret of secrets)
|
|
15
|
+
if (secret)
|
|
16
|
+
out = out.split(secret).join('[redacted]');
|
|
17
|
+
return out.replace(TOKEN, '$1_[redacted]').replace(FIELD, '$1[redacted]');
|
|
18
|
+
}
|
|
19
|
+
export function createLog(debug, dir = tmpdir()) {
|
|
20
|
+
const secrets = new Set();
|
|
21
|
+
const secret = (value) => void secrets.add(value);
|
|
22
|
+
if (!debug)
|
|
23
|
+
return { secret, write: () => { } };
|
|
24
|
+
const path = join(dir, `impeccable-reviews-${new Date().toISOString().replace(/[:.]/g, '-')}.log`);
|
|
25
|
+
return {
|
|
26
|
+
path,
|
|
27
|
+
secret,
|
|
28
|
+
write(message, data) {
|
|
29
|
+
const detail = data === undefined ? '' : ` ${typeof data === 'string' ? data : JSON.stringify(data)}`;
|
|
30
|
+
try {
|
|
31
|
+
appendFileSync(path, `${redact(`${new Date().toISOString()} ${message}${detail}`, secrets)}\n`, { mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// A debug log that cannot be written is not worth stopping the run for.
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|