@impeccable/reviews 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +0 -0
- package/dist/cloudflare.js +45 -7
- package/dist/main.js +2 -2
- package/dist/tools.js +6 -6
- package/package.json +1 -1
- package/dist/playbook/github-previews.js +0 -101
- package/dist/playbook/netlify-pattern.js +0 -24
- package/dist/playbook/netlify-previews.js +0 -144
- package/dist/playbook/probe-preview.js +0 -67
- package/dist/playbook/protection-secrets.js +0 -15
- package/dist/playbook/public-preview-request.js +0 -96
- package/dist/playbook/publication-wait.js +0 -46
- package/dist/playbook/vercel-protection.js +0 -121
package/dist/bin.js
CHANGED
|
File without changes
|
package/dist/cloudflare.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
1
2
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
+
import { join, resolve, sep } from 'node:path';
|
|
3
4
|
import { cloudflarePreviewsFact, cloudflarePreviewsPage, folderOf } from './host-previews.js';
|
|
4
5
|
import { commandRunner } from './netlify.js';
|
|
5
6
|
import { ownFolder, ownNpmCache } from './sandbox.js';
|
|
@@ -238,13 +239,50 @@ export const CF_PACKAGE = 'cf@1.0.0-beta.13';
|
|
|
238
239
|
/**
|
|
239
240
|
* cf here: its JSON uncoloured, and an account's command given its account, which cf otherwise asks the person to pick
|
|
240
241
|
* when they have several. `browser` lets its sign-in open one. npx runs it from a folder of the command's own, never the
|
|
241
|
-
* checkout, whose node_modules and .npmrc would choose which cf runs as the person, outside the sandbox
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
242
|
+
* checkout, whose node_modules and .npmrc would choose which cf runs as the person, outside the sandbox, and from where cf
|
|
243
|
+
* itself would read the checkout's .env into its token and account and evaluate its cloudflare.config.ts: every cf
|
|
244
|
+
* command names its project or its account, so none needs the checkout. Only the sign-in cf picks by folder does: with
|
|
245
|
+
* `checkout`, each command runs as the profile cf binds to it (`cf auth activate`), which cf names from its own folder
|
|
246
|
+
* too, so the account is the one the person's cf uses there; where reading the bindings failed, the command fails as well,
|
|
247
|
+
* never falling back to another sign-in. It fetches cf through a cache of the command's own, never ~/.npm, where a
|
|
248
|
+
* sandboxed install could have planted a cf under the registry's live ETag; the registry is asked afresh, and no install
|
|
249
|
+
* script of cf's runs, which it does without.
|
|
245
250
|
*/
|
|
246
251
|
export function cfRunner(opts = {}, runner = commandRunner) {
|
|
247
|
-
|
|
252
|
+
const { checkout, ...run } = opts;
|
|
253
|
+
const npx = (args, signal, env = {}) => runner('npx', ownFolder(), { timeoutMs: 120_000, ...run, signal, env: { NO_COLOR: '1', ...env } })(['--yes', '--cache', ownNpmCache(), '--prefer-online', '--ignore-scripts', CF_PACKAGE, ...args]);
|
|
254
|
+
let profile;
|
|
255
|
+
return async (args, { account, signal } = {}) => {
|
|
256
|
+
// Read once a runner; a failed read is read again by the next command.
|
|
257
|
+
if (checkout && !profile)
|
|
258
|
+
profile = npx(['auth', 'list'], signal).then((listed) => boundProfile(listed, checkout)).catch((err) => { profile = undefined; throw err; });
|
|
259
|
+
const bound = await profile;
|
|
260
|
+
return npx([...(bound ? ['--profile', bound] : []), ...args], signal, account ? { CLOUDFLARE_ACCOUNT_ID: account } : {});
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/** The profile `cf auth list` binds to `checkout`, found as cf finds it from there: the longest bound folder that is the checkout's real path or holds it; none where no folder does. A list that is not cf's throws. */
|
|
264
|
+
export function boundProfile(listed, checkout) {
|
|
265
|
+
const profiles = JSON.parse(listed);
|
|
266
|
+
if (!Array.isArray(profiles))
|
|
267
|
+
throw new Error('cf auth list printed no list of profiles');
|
|
268
|
+
let here = resolve(checkout);
|
|
269
|
+
try {
|
|
270
|
+
here = realpathSync(here);
|
|
271
|
+
}
|
|
272
|
+
catch { /* cf's own folder, as cf resolves it */ }
|
|
273
|
+
let found;
|
|
274
|
+
for (const p of profiles) {
|
|
275
|
+
const { name, boundDirectories } = record(p) ?? {};
|
|
276
|
+
if (typeof name !== 'string' || !/^[A-Za-z0-9_-]+$/.test(name) || !Array.isArray(boundDirectories))
|
|
277
|
+
continue;
|
|
278
|
+
for (const dir of boundDirectories) {
|
|
279
|
+
if (typeof dir !== 'string' || !(here === dir || (here.startsWith(dir) && here[dir.length] === sep)))
|
|
280
|
+
continue;
|
|
281
|
+
if (!found || dir.length > found.dir.length)
|
|
282
|
+
found = { name, dir };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return found?.name;
|
|
248
286
|
}
|
|
249
287
|
/** The status of the API call a cf command failed on, which its error box names on a line of its own ("│ 403 Forbidden", "│ 404 Not Found · HTTP /accounts/…"); null when it names none. */
|
|
250
288
|
export function cfFailure(err) {
|
|
@@ -406,7 +444,7 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
|
|
|
406
444
|
// `project`, one app's told by an earlier read, is the one read: never the project the first check run names, which may be another app's.
|
|
407
445
|
const named = app ? undefined : deps.project ?? github.previews.map((p) => pagesProject(p.url)).find(Boolean) ?? (config?.kind === 'pages' ? config.name : undefined);
|
|
408
446
|
// cf installs itself through npx on a machine without it, which takes a while the first time. The project's settings are read with the plain evidence, not while a build is waited for.
|
|
409
|
-
const cf = await fromCf(named, deps.cf ?? cfRunner(), { owner, repo, heads: github.heads.map((h) => h.sha), readme: readme.pages, ...(app ? { directory: deps.directory } : {}) }, plain, deps.signal);
|
|
447
|
+
const cf = await fromCf(named, deps.cf ?? cfRunner({ checkout: cwd }, deps.run), { owner, repo, heads: github.heads.map((h) => h.sha), readme: readme.pages, ...(app ? { directory: deps.directory } : {}) }, plain, deps.signal);
|
|
410
448
|
const where = deps.directory ? `the folder ${deps.directory}` : "the repository's root";
|
|
411
449
|
const unmatched = !app ? undefined
|
|
412
450
|
: cf.cli !== 'signed-in' ? `Which Cloudflare Pages project builds ${where} cannot be told: cf here is not signed in with Pages access, and only a project's settings name the folder it builds (its root directory).`
|
package/dist/main.js
CHANGED
|
@@ -256,8 +256,8 @@ export async function main(argv) {
|
|
|
256
256
|
todo: [...apps.todo, ...report.left],
|
|
257
257
|
warnings: [...removed, ...(saved && !saved.ok ? [`The web page missed the last update (${saved.error}), so it may lag behind this.`] : [])],
|
|
258
258
|
}, hand);
|
|
259
|
-
// The web page has the report; when it missed it, the report stays here
|
|
260
|
-
if (!saved?.ok)
|
|
259
|
+
// The web page has the report; when it missed it, or there is no test drive, whose why it holds, the report stays here as well.
|
|
260
|
+
if (!saved?.ok || !testPr)
|
|
261
261
|
say(`\n${plainReport(report.markdown)}`);
|
|
262
262
|
if (!saved?.ok)
|
|
263
263
|
reporter.step({ event: 'setup_command_stopped', outcome: 'update_lost' });
|
package/dist/tools.js
CHANGED
|
@@ -137,7 +137,7 @@ function stageError(status, tasks) {
|
|
|
137
137
|
}
|
|
138
138
|
/** The line under the last screen's heading ("You're all set up", or "Settings saved" without a test drive), about the pull request that screen ends on: the setup pull request when there is one, else the test drive; and, where a host builds the previews and so no setup pull request was needed, one line saying so. */
|
|
139
139
|
export function resultLine(report) {
|
|
140
|
-
const lead = !report.testPr ? 'No test drive: the report says why.' : report.setupPr ? 'The setup pull request adds the workflow that lets Impeccable run on your previews. Merge it when you are ready.' : SEE_THE_REVIEW;
|
|
140
|
+
const lead = !report.testPr ? 'No test drive: the report says why, printed here when this screen closes.' : report.setupPr ? 'The setup pull request adds the workflow that lets Impeccable run on your previews. Merge it when you are ready.' : SEE_THE_REVIEW;
|
|
141
141
|
return report.setupPr || !report.hostedBy ? lead : `${lead} ${nothingToMerge(report.hostedBy)}`;
|
|
142
142
|
}
|
|
143
143
|
/** Said once, here and in the report, when the host builds the previews and the repository needed no change. */
|
|
@@ -2797,7 +2797,7 @@ export function createTools(ctx) {
|
|
|
2797
2797
|
const turnOn = async () => {
|
|
2798
2798
|
if (host === 'netlify')
|
|
2799
2799
|
return enableNetlifyPreviews((ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }), fact.project);
|
|
2800
|
-
const cf = cfRunner({}, ctx.hostRunner);
|
|
2800
|
+
const cf = cfRunner({ checkout: pi.cwd }, ctx.hostRunner);
|
|
2801
2801
|
return enableCloudflarePreviews(cf, fact.project, await cfStatus(cf, signal), signal);
|
|
2802
2802
|
};
|
|
2803
2803
|
// The run's change, read back; where the host kept the setting, the person changes it on the host's page and the run reads again, twice at most.
|
|
@@ -2842,8 +2842,8 @@ export function createTools(ctx) {
|
|
|
2842
2842
|
if ((await ask({ ...screen, prompt: create ? `Create the Pages project ${project} for ${ctx.owner}/${ctx.repo}?` : `Connect ${ctx.owner}/${ctx.repo} to the Pages project ${project}?` })).skipped)
|
|
2843
2843
|
return answer('skipped', { reason: `The person skipped ${create ? 'creating the Pages project' : 'connecting the repository to the Pages project'}.` });
|
|
2844
2844
|
// cf signed in with pages:write, as it is or after Cloudflare's sign-in in the browser, keeping the scopes it held; a sign-in that did not complete offers a retry, twice.
|
|
2845
|
-
const cf = cfRunner({}, ctx.hostRunner);
|
|
2846
|
-
const login = cfRunner({ browser: true, timeoutMs: 300_000 }, ctx.hostRunner);
|
|
2845
|
+
const cf = cfRunner({ checkout: pi.cwd }, ctx.hostRunner);
|
|
2846
|
+
const login = cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, ctx.hostRunner);
|
|
2847
2847
|
const writes = (status) => status.cli === 'signed-in' && (!status.scopes || status.scopes.includes('pages:write'));
|
|
2848
2848
|
let status = await cfStatus(cf, signal);
|
|
2849
2849
|
if (!writes(status)) {
|
|
@@ -3121,8 +3121,8 @@ export function createTools(ctx) {
|
|
|
3121
3121
|
answer = await passCloudflareAccess({
|
|
3122
3122
|
url: at.url,
|
|
3123
3123
|
consent: async () => !(await ask(sentTo(HOST_SCREENS['cloudflare-access'], at.url))).skipped,
|
|
3124
|
-
cf: cfRunner({}, ctx.hostRunner),
|
|
3125
|
-
login: cfRunner({ browser: true, timeoutMs: 300_000 }, ctx.hostRunner),
|
|
3124
|
+
cf: cfRunner({ checkout: pi.cwd }, ctx.hostRunner),
|
|
3125
|
+
login: cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, ctx.hostRunner),
|
|
3126
3126
|
retry: async () => !(await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped,
|
|
3127
3127
|
status: said,
|
|
3128
3128
|
apiToken: async () => {
|
package/package.json
CHANGED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { promisify } from 'node:util';
|
|
3
|
-
const execFileAsync = promisify(execFile);
|
|
4
|
-
const LIMIT = 20;
|
|
5
|
-
const COVERAGE = 'Only the 20 newest GitHub deployments, their latest statuses, and the 20 most recently updated pull requests are checked. A missing match does not prove previews are absent. Empty GitHub deployment history does not prove that a live app or host previews do not exist. A deployment URL does not prove the application rendered.';
|
|
6
|
-
const FAILURE = 'GitHub preview evidence could not be read completely.';
|
|
7
|
-
const STATES = ['error', 'failure', 'inactive', 'in_progress', 'queued', 'pending', 'success'];
|
|
8
|
-
function record(value) {
|
|
9
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
10
|
-
? value
|
|
11
|
-
: undefined;
|
|
12
|
-
}
|
|
13
|
-
function positiveInteger(value) {
|
|
14
|
-
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
15
|
-
}
|
|
16
|
-
function sha(value) {
|
|
17
|
-
return typeof value === 'string' && /^[a-f0-9]{40}$/.test(value);
|
|
18
|
-
}
|
|
19
|
-
function previewUrl(value) {
|
|
20
|
-
if (typeof value !== 'string' || value.length > 2048 || !/^https:\/\/[^\s\\?#]+$/i.test(value))
|
|
21
|
-
return null;
|
|
22
|
-
try {
|
|
23
|
-
const url = new URL(value);
|
|
24
|
-
if (url.username || url.password || url.search || url.hash || /^https:\/\/[^/]*@/i.test(value))
|
|
25
|
-
return null;
|
|
26
|
-
return url.href;
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
/** Read a bounded projection of GitHub evidence; never return response bodies or command errors. */
|
|
33
|
-
export async function readHostedPreviews(owner, repo, dependencies = {}) {
|
|
34
|
-
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(owner)
|
|
35
|
-
|| !/^[a-zA-Z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..') {
|
|
36
|
-
return { coverage: COVERAGE, deployments: [], error: FAILURE };
|
|
37
|
-
}
|
|
38
|
-
const runGh = dependencies.runGh ?? (async (args) => {
|
|
39
|
-
const { stdout } = await execFileAsync('gh', args, {
|
|
40
|
-
encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024, signal: dependencies.signal,
|
|
41
|
-
});
|
|
42
|
-
return stdout;
|
|
43
|
-
});
|
|
44
|
-
const base = `/repos/${owner}/${repo}`;
|
|
45
|
-
async function get(route) {
|
|
46
|
-
if (dependencies.signal?.aborted)
|
|
47
|
-
throw new Error(FAILURE);
|
|
48
|
-
const stdout = await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]);
|
|
49
|
-
if (dependencies.signal?.aborted)
|
|
50
|
-
throw new Error(FAILURE);
|
|
51
|
-
const data = JSON.parse(stdout);
|
|
52
|
-
if (!Array.isArray(data))
|
|
53
|
-
throw new Error(FAILURE);
|
|
54
|
-
return data.slice(0, LIMIT);
|
|
55
|
-
}
|
|
56
|
-
try {
|
|
57
|
-
const [deployments, pulls] = await Promise.all([
|
|
58
|
-
get(`${base}/deployments?per_page=${LIMIT}`),
|
|
59
|
-
get(`${base}/pulls?state=all&sort=updated&direction=desc&per_page=${LIMIT}`),
|
|
60
|
-
]);
|
|
61
|
-
let incomplete = false;
|
|
62
|
-
const evidence = [];
|
|
63
|
-
for (const entry of deployments) {
|
|
64
|
-
if (dependencies.signal?.aborted) {
|
|
65
|
-
incomplete = true;
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
const deployment = record(entry);
|
|
69
|
-
if (!deployment || !positiveInteger(deployment.id) || !sha(deployment.sha))
|
|
70
|
-
continue;
|
|
71
|
-
let latest;
|
|
72
|
-
try {
|
|
73
|
-
latest = record((await get(`${base}/deployments/${deployment.id}/statuses?per_page=1`))[0]);
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
incomplete = true;
|
|
77
|
-
if (dependencies.signal?.aborted)
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
80
|
-
const status = STATES.find((state) => state === latest?.state) ?? null;
|
|
81
|
-
const environment = typeof deployment.environment === 'string' && deployment.environment.length <= 100
|
|
82
|
-
&& !/[\u0000-\u001f\u007f]/.test(deployment.environment) ? deployment.environment : null;
|
|
83
|
-
const pullRequests = [];
|
|
84
|
-
for (const item of pulls) {
|
|
85
|
-
const pull = record(item);
|
|
86
|
-
if (pull && positiveInteger(pull.number) && (pull.state === 'open' || pull.state === 'closed')
|
|
87
|
-
&& record(pull.head)?.sha === deployment.sha) {
|
|
88
|
-
pullRequests.push({ number: pull.number, state: pull.state });
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
evidence.push({ id: deployment.id, sha: deployment.sha, environment, status,
|
|
92
|
-
previewUrl: status ? previewUrl(latest?.environment_url) : null, pullRequests });
|
|
93
|
-
}
|
|
94
|
-
incomplete ||= dependencies.signal?.aborted ?? false;
|
|
95
|
-
return { coverage: COVERAGE, deployments: evidence,
|
|
96
|
-
...(incomplete ? { error: FAILURE } : { emptyDeploymentHistory: deployments.length === 0 }) };
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
return { coverage: COVERAGE, deployments: [], error: FAILURE };
|
|
100
|
-
}
|
|
101
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/** Keep Netlify's repository-wide pattern tied to successful discovery evidence. */
|
|
2
|
-
export function netlifyPatternError(body, observed) {
|
|
3
|
-
const pattern = body.previewUrlPattern;
|
|
4
|
-
if (typeof pattern !== 'string')
|
|
5
|
-
return null;
|
|
6
|
-
let netlify = /netlify\.app|<site>/i.test(pattern);
|
|
7
|
-
try {
|
|
8
|
-
const hostname = new URL(pattern.replaceAll('{pr}', '1')).hostname;
|
|
9
|
-
netlify ||= hostname === 'netlify.app' || hostname.endsWith('.netlify.app');
|
|
10
|
-
}
|
|
11
|
-
catch { /* The canonical validation below rejects malformed Netlify patterns. */ }
|
|
12
|
-
if (!netlify)
|
|
13
|
-
return null;
|
|
14
|
-
const match = /^https:\/\/deploy-preview-\{pr\}--([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.netlify\.app$/.exec(pattern);
|
|
15
|
-
if (!match || match[1].length > 63)
|
|
16
|
-
return 'Use a real Netlify site pattern from successful preview evidence; placeholders and malformed patterns are not saved.';
|
|
17
|
-
if (Object.hasOwn(body, 'appKey'))
|
|
18
|
-
return 'A Netlify preview URL pattern belongs to the repository; omit appKey.';
|
|
19
|
-
if (body.intendedMethod !== 'preview-deploy')
|
|
20
|
-
return 'Save a Netlify pattern with intendedMethod preview-deploy.';
|
|
21
|
-
if (!observed.has(pattern))
|
|
22
|
-
return 'Read this repository\'s Netlify previews first and choose a pattern backed by a successful current-head status.';
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { promisify } from 'node:util';
|
|
3
|
-
const execFileAsync = promisify(execFile);
|
|
4
|
-
const FAILURE = 'Netlify preview evidence could not be read completely.';
|
|
5
|
-
const COVERAGE = 'Only the 20 most recently updated pull requests, the newest 100 statuses on each exact head SHA, and the 100 most recently updated repository issue comments are checked. Context-derived URLs do not prove the application rendered. Bot comments are historical candidates, not current-head deployment proof. Missing evidence does not prove previews are absent.';
|
|
6
|
-
const SITE = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
|
7
|
-
const CONTEXT = new RegExp(`^netlify/(${SITE})/deploy-preview$`);
|
|
8
|
-
const PREVIEW_HOST = new RegExp(`^deploy-preview-([1-9][0-9]*)--(${SITE})\\.netlify\\.app$`);
|
|
9
|
-
const STATES = ['success', 'pending', 'failure', 'error'];
|
|
10
|
-
function record(value) {
|
|
11
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
12
|
-
? value : undefined;
|
|
13
|
-
}
|
|
14
|
-
function cleanUrl(value) {
|
|
15
|
-
if (typeof value !== 'string' || value.length > 2048 || !/^https:\/\/[^\s\\?#@]+$/i.test(value))
|
|
16
|
-
return;
|
|
17
|
-
try {
|
|
18
|
-
const url = new URL(value);
|
|
19
|
-
if (!url.username && !url.password && !url.port && !url.search && !url.hash)
|
|
20
|
-
return url;
|
|
21
|
-
}
|
|
22
|
-
catch { /* Discard unsafe evidence without returning it. */ }
|
|
23
|
-
}
|
|
24
|
-
function urls(pull, site) {
|
|
25
|
-
if (`deploy-preview-${pull.number}--${site}`.length > 63)
|
|
26
|
-
return;
|
|
27
|
-
return {
|
|
28
|
-
previewUrl: `https://deploy-preview-${pull.number}--${site}.netlify.app/`,
|
|
29
|
-
previewUrlPattern: `https://deploy-preview-{pr}--${site}.netlify.app`,
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
function validTarget(value, pull, site) {
|
|
33
|
-
if (value === null || value === undefined || value === '')
|
|
34
|
-
return true;
|
|
35
|
-
const target = cleanUrl(value);
|
|
36
|
-
if (!target)
|
|
37
|
-
return false;
|
|
38
|
-
if (target.hostname === `deploy-preview-${pull.number}--${site}.netlify.app` && target.pathname === '/')
|
|
39
|
-
return true;
|
|
40
|
-
return target.hostname === 'app.netlify.com'
|
|
41
|
-
&& new RegExp(`^/(?:sites|projects)/${site}/deploys(?:/[a-zA-Z0-9_-]+)?/?$`).test(target.pathname);
|
|
42
|
-
}
|
|
43
|
-
/** Read a bounded, repository-scoped projection; never expose comments, command errors or credentials. */
|
|
44
|
-
export async function readNetlifyPreviews(owner, repo, dependencies = {}) {
|
|
45
|
-
const previews = [];
|
|
46
|
-
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(owner)
|
|
47
|
-
|| !/^[a-zA-Z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..') {
|
|
48
|
-
return { coverage: COVERAGE, previews, error: FAILURE };
|
|
49
|
-
}
|
|
50
|
-
const runGh = dependencies.runGh ?? (async (args) => {
|
|
51
|
-
const { stdout } = await execFileAsync('gh', args, {
|
|
52
|
-
encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024, signal: dependencies.signal,
|
|
53
|
-
});
|
|
54
|
-
return stdout;
|
|
55
|
-
});
|
|
56
|
-
const base = `/repos/${owner}/${repo}`;
|
|
57
|
-
async function get(route, limit) {
|
|
58
|
-
if (dependencies.signal?.aborted)
|
|
59
|
-
throw new Error();
|
|
60
|
-
const raw = await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]);
|
|
61
|
-
if (dependencies.signal?.aborted)
|
|
62
|
-
throw new Error();
|
|
63
|
-
const data = JSON.parse(raw);
|
|
64
|
-
if (!Array.isArray(data))
|
|
65
|
-
throw new Error();
|
|
66
|
-
return data.slice(0, limit);
|
|
67
|
-
}
|
|
68
|
-
const [pullRead, commentRead] = await Promise.allSettled([
|
|
69
|
-
get(`${base}/pulls?state=all&sort=updated&direction=desc&per_page=20`, 20),
|
|
70
|
-
get(`${base}/issues/comments?sort=updated&direction=desc&per_page=100`, 100),
|
|
71
|
-
]);
|
|
72
|
-
if (pullRead.status === 'rejected')
|
|
73
|
-
return { coverage: COVERAGE, previews, error: FAILURE };
|
|
74
|
-
let incomplete = false;
|
|
75
|
-
const warnings = commentRead.status === 'rejected'
|
|
76
|
-
? ['Historical Netlify bot comments could not be read. Current-head status evidence is still checked.'] : [];
|
|
77
|
-
const pulls = new Map();
|
|
78
|
-
for (const item of pullRead.value) {
|
|
79
|
-
const pull = record(item);
|
|
80
|
-
const headSha = record(pull?.head)?.sha;
|
|
81
|
-
if (!pull || typeof pull.number !== 'number' || !Number.isSafeInteger(pull.number) || pull.number <= 0
|
|
82
|
-
|| (pull.state !== 'open' && pull.state !== 'closed') || typeof headSha !== 'string' || !/^[a-f0-9]{40}$/.test(headSha))
|
|
83
|
-
continue;
|
|
84
|
-
const issueUrl = `https://api.github.com${base}/issues/${pull.number}`;
|
|
85
|
-
if (!pulls.has(issueUrl))
|
|
86
|
-
pulls.set(issueUrl, { number: pull.number, state: pull.state, headSha });
|
|
87
|
-
}
|
|
88
|
-
const seen = new Set();
|
|
89
|
-
const statusesBySha = new Map();
|
|
90
|
-
for (const pull of pulls.values()) {
|
|
91
|
-
if (dependencies.signal?.aborted) {
|
|
92
|
-
incomplete = true;
|
|
93
|
-
break;
|
|
94
|
-
}
|
|
95
|
-
try {
|
|
96
|
-
if (!statusesBySha.has(pull.headSha)) {
|
|
97
|
-
statusesBySha.set(pull.headSha, get(`${base}/commits/${pull.headSha}/statuses?per_page=100`, 100));
|
|
98
|
-
}
|
|
99
|
-
for (const item of await statusesBySha.get(pull.headSha)) {
|
|
100
|
-
const status = record(item);
|
|
101
|
-
const site = typeof status?.context === 'string' ? CONTEXT.exec(status.context)?.[1] : undefined;
|
|
102
|
-
if (!site || seen.has(`${pull.number}:${site}`))
|
|
103
|
-
continue;
|
|
104
|
-
// GitHub returns newest first: even an invalid latest entry must hide older success.
|
|
105
|
-
seen.add(`${pull.number}:${site}`);
|
|
106
|
-
const state = STATES.find((state) => state === status?.state);
|
|
107
|
-
const canonical = urls(pull, site);
|
|
108
|
-
if (!state || !canonical || !validTarget(status?.target_url, pull, site))
|
|
109
|
-
continue;
|
|
110
|
-
previews.push({ pullRequest: pull.number, state: pull.state, headSha: pull.headSha, site,
|
|
111
|
-
status: state, ...canonical, source: 'commit-status', matchesHead: true });
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
incomplete = true;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (!dependencies.signal?.aborted && commentRead.status === 'fulfilled') {
|
|
119
|
-
for (const item of commentRead.value) {
|
|
120
|
-
const comment = record(item);
|
|
121
|
-
const author = record(comment?.user);
|
|
122
|
-
const pull = typeof comment?.issue_url === 'string' ? pulls.get(comment.issue_url) : undefined;
|
|
123
|
-
if (!pull || author?.login !== 'netlify[bot]' || author.type !== 'Bot'
|
|
124
|
-
|| typeof comment?.body !== 'string' || comment.body.length > 65_536)
|
|
125
|
-
continue;
|
|
126
|
-
for (const match of comment.body.matchAll(/https:\/\/[^\s<>"'()[\]`]+/gi)) {
|
|
127
|
-
const url = cleanUrl(match[0].replace(/[.,;:]+$/, ''));
|
|
128
|
-
const parts = url ? PREVIEW_HOST.exec(url.hostname) : null;
|
|
129
|
-
if (!url || url.pathname !== '/' || !parts || parts[1] !== String(pull.number))
|
|
130
|
-
continue;
|
|
131
|
-
const site = parts[2];
|
|
132
|
-
const canonical = urls(pull, site);
|
|
133
|
-
if (!canonical || seen.has(`${pull.number}:${site}`))
|
|
134
|
-
continue;
|
|
135
|
-
seen.add(`${pull.number}:${site}`);
|
|
136
|
-
previews.push({ pullRequest: pull.number, state: pull.state, headSha: pull.headSha, site,
|
|
137
|
-
status: null, ...canonical, source: 'bot-comment', matchesHead: false });
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
if (dependencies.signal?.aborted)
|
|
142
|
-
incomplete = true;
|
|
143
|
-
return { coverage: COVERAGE, previews, ...(warnings.length ? { warnings } : {}), ...(incomplete ? { error: FAILURE } : {}) };
|
|
144
|
-
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
function redirectGate(location, source) {
|
|
2
|
-
if (!location)
|
|
3
|
-
return 'redirected';
|
|
4
|
-
try {
|
|
5
|
-
const target = new URL(location, source);
|
|
6
|
-
if (target.protocol !== 'https:')
|
|
7
|
-
return 'redirected';
|
|
8
|
-
if (target.origin === 'https://vercel.com' && target.pathname === '/sso-api'
|
|
9
|
-
&& target.searchParams.has('url'))
|
|
10
|
-
return 'vercel-protection';
|
|
11
|
-
if ((target.hostname === 'vercel.com' || target.hostname.endsWith('.vercel.com'))
|
|
12
|
-
&& /^\/login(?:\/|$)/.test(target.pathname))
|
|
13
|
-
return 'vercel-protection';
|
|
14
|
-
if (target.hostname.endsWith('.cloudflareaccess.com'))
|
|
15
|
-
return 'cloudflare-access';
|
|
16
|
-
if (target.origin === source.origin && /^\/(?:login|sign-in|signin)(?:\/|$)/.test(target.pathname)) {
|
|
17
|
-
return 'app-sign-in';
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
catch {
|
|
21
|
-
// A malformed redirect reveals nothing about preview protection.
|
|
22
|
-
}
|
|
23
|
-
return 'redirected';
|
|
24
|
-
}
|
|
25
|
-
/** An unauthenticated HTTP check only: reachable does not prove this is the intended app. */
|
|
26
|
-
export async function probePreview(url, dependencies = {}) {
|
|
27
|
-
let target;
|
|
28
|
-
try {
|
|
29
|
-
target = new URL(url);
|
|
30
|
-
if (!isPlatformPreview(target)
|
|
31
|
-
|| /[?#\\\s]/.test(url) || !/^https:\/\//i.test(url))
|
|
32
|
-
throw new Error();
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return { status: null, gate: 'unavailable', error: 'invalid-preview-url' };
|
|
36
|
-
}
|
|
37
|
-
const controller = new AbortController();
|
|
38
|
-
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
39
|
-
const signal = dependencies.signal
|
|
40
|
-
? AbortSignal.any([controller.signal, dependencies.signal])
|
|
41
|
-
: controller.signal;
|
|
42
|
-
try {
|
|
43
|
-
signal.throwIfAborted();
|
|
44
|
-
const response = dependencies.fetch ? await dependencies.fetch(target, {
|
|
45
|
-
method: 'GET',
|
|
46
|
-
headers: { accept: 'text/html' },
|
|
47
|
-
credentials: 'omit',
|
|
48
|
-
redirect: 'manual',
|
|
49
|
-
signal,
|
|
50
|
-
}) : await publicPreviewRequest(target, signal);
|
|
51
|
-
const status = response.status;
|
|
52
|
-
const gate = status === 401 && response.headers.get('x-vercel-error') === 'UNAUTHORIZED'
|
|
53
|
-
? 'vercel-protection' : status >= 300 && status < 400
|
|
54
|
-
? redirectGate(response.headers.get('location'), target)
|
|
55
|
-
: status >= 200 && status < 300 ? 'reachable' : 'unavailable';
|
|
56
|
-
// Bodies and redirect details may contain credentials; never read or return them.
|
|
57
|
-
await response.body?.cancel().catch(() => undefined);
|
|
58
|
-
return { status, gate };
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
61
|
-
return { status: null, gate: 'unavailable', error: 'request-failed' };
|
|
62
|
-
}
|
|
63
|
-
finally {
|
|
64
|
-
clearTimeout(timeout);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
import { isPlatformPreview, publicPreviewRequest } from './public-preview-request.js';
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/** Register credential-shaped write fields before a message can be recorded.
|
|
2
|
-
* Do not trust the requested path: even a refused or malformed destination must
|
|
3
|
-
* keep its supplied credentials private. Values remain unchanged in memory.
|
|
4
|
-
*/
|
|
5
|
-
export function protectionWriteSecrets(input) {
|
|
6
|
-
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
7
|
-
return [];
|
|
8
|
-
const body = input.body;
|
|
9
|
-
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
10
|
-
return [];
|
|
11
|
-
return ['value', 'clientId', 'clientSecret']
|
|
12
|
-
.map((key) => body[key])
|
|
13
|
-
.filter((value) => typeof value === 'string' && value.length > 0)
|
|
14
|
-
.sort((a, b) => b.length - a.length);
|
|
15
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { lookup } from 'node:dns/promises';
|
|
2
|
-
import { request } from 'node:https';
|
|
3
|
-
import { BlockList, isIP } from 'node:net';
|
|
4
|
-
const blockedV4 = new BlockList();
|
|
5
|
-
for (const [address, prefix] of [
|
|
6
|
-
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
|
7
|
-
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
|
|
8
|
-
['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24],
|
|
9
|
-
['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
|
|
10
|
-
])
|
|
11
|
-
blockedV4.addSubnet(address, prefix, 'ipv4');
|
|
12
|
-
const publicV6 = new BlockList();
|
|
13
|
-
publicV6.addSubnet('2000::', 3, 'ipv6');
|
|
14
|
-
const blockedV6 = new BlockList();
|
|
15
|
-
// Conservatively exclude special-purpose, transition and documentation prefixes.
|
|
16
|
-
for (const [address, prefix] of [
|
|
17
|
-
['2001::', 23], ['2001:db8::', 32], ['2002::', 16], ['3fff::', 20],
|
|
18
|
-
])
|
|
19
|
-
blockedV6.addSubnet(address, prefix, 'ipv6');
|
|
20
|
-
export function isPlatformPreview(target) {
|
|
21
|
-
return target.protocol === 'https:' && !target.username && !target.password && !target.port
|
|
22
|
-
&& !/[?#]/.test(target.href)
|
|
23
|
-
&& /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:vercel|netlify)\.app$/.test(target.hostname);
|
|
24
|
-
}
|
|
25
|
-
function isPublicAddress(value) {
|
|
26
|
-
if (!value || typeof value.address !== 'string' || value.address.includes('%'))
|
|
27
|
-
return false;
|
|
28
|
-
const family = isIP(value.address);
|
|
29
|
-
if (family !== value.family)
|
|
30
|
-
return false;
|
|
31
|
-
if (family === 4)
|
|
32
|
-
return !blockedV4.check(value.address, 'ipv4');
|
|
33
|
-
return family === 6 && publicV6.check(value.address, 'ipv6') && !blockedV6.check(value.address, 'ipv6');
|
|
34
|
-
}
|
|
35
|
-
/** Resolve once, validate every answer, then connect only to the pinned public addresses. */
|
|
36
|
-
export async function publicPreviewRequest(target, signal, dependencies = {}) {
|
|
37
|
-
try {
|
|
38
|
-
if (!isPlatformPreview(target))
|
|
39
|
-
throw new Error();
|
|
40
|
-
signal.throwIfAborted();
|
|
41
|
-
let abortDns = () => { };
|
|
42
|
-
const aborted = new Promise((_resolve, reject) => {
|
|
43
|
-
abortDns = () => reject(new Error());
|
|
44
|
-
signal.addEventListener('abort', abortDns, { once: true });
|
|
45
|
-
});
|
|
46
|
-
const addresses = await Promise.race([
|
|
47
|
-
(dependencies.lookup ?? lookup)(target.hostname, { all: true, verbatim: true }), aborted,
|
|
48
|
-
]).finally(() => signal.removeEventListener('abort', abortDns));
|
|
49
|
-
signal.throwIfAborted();
|
|
50
|
-
if (!Array.isArray(addresses) || !addresses.length || !addresses.every(isPublicAddress))
|
|
51
|
-
throw new Error();
|
|
52
|
-
const pinned = addresses.map(({ address, family }) => ({ address, family }));
|
|
53
|
-
return await new Promise((resolve, reject) => {
|
|
54
|
-
const options = {
|
|
55
|
-
method: 'GET', headers: { accept: 'text/html' }, signal,
|
|
56
|
-
agent: false, autoSelectFamily: true, rejectUnauthorized: true,
|
|
57
|
-
lookup(hostname, options, callback) {
|
|
58
|
-
if (hostname !== target.hostname)
|
|
59
|
-
return callback(new Error('Preview request failed'), '');
|
|
60
|
-
if (options.all)
|
|
61
|
-
callback(null, pinned.map((address) => ({ ...address })));
|
|
62
|
-
else
|
|
63
|
-
callback(null, pinned[0].address, pinned[0].family);
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
const req = (dependencies.request ?? request)(target, options, (response) => {
|
|
67
|
-
response.once('error', () => { });
|
|
68
|
-
try {
|
|
69
|
-
const status = response.statusCode;
|
|
70
|
-
const headers = new Headers();
|
|
71
|
-
for (const name of ['location', 'x-vercel-error']) {
|
|
72
|
-
const value = response.headers[name];
|
|
73
|
-
if (typeof value === 'string')
|
|
74
|
-
headers.set(name, value);
|
|
75
|
-
}
|
|
76
|
-
if (!status)
|
|
77
|
-
reject(new Error());
|
|
78
|
-
else
|
|
79
|
-
resolve({ status, headers, body: null });
|
|
80
|
-
}
|
|
81
|
-
catch {
|
|
82
|
-
reject(new Error());
|
|
83
|
-
}
|
|
84
|
-
finally {
|
|
85
|
-
response.destroy();
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
req.once('error', () => reject(new Error()));
|
|
89
|
-
req.once('upgrade', (_response, socket) => { socket.destroy(); reject(new Error()); });
|
|
90
|
-
req.end();
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
throw new Error('Preview request failed');
|
|
95
|
-
}
|
|
96
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
-
const INTERVAL_MS = 30_000;
|
|
3
|
-
const DEADLINE_MS = 8 * 60_000;
|
|
4
|
-
/** One publication deadline per setup PR, including time spent checking the preview. */
|
|
5
|
-
export function createPublicationWait(dependencies = {}) {
|
|
6
|
-
const now = dependencies.now ?? (() => performance.now());
|
|
7
|
-
const pause = dependencies.sleep ?? ((ms, signal) => sleep(ms, undefined, { signal }));
|
|
8
|
-
const starts = new Map();
|
|
9
|
-
return {
|
|
10
|
-
begin(pr) {
|
|
11
|
-
if (!starts.has(pr))
|
|
12
|
-
starts.set(pr, now());
|
|
13
|
-
},
|
|
14
|
-
remainingMs(pr) {
|
|
15
|
-
const start = starts.get(pr);
|
|
16
|
-
return start === undefined ? null : Math.max(0, DEADLINE_MS - (now() - start));
|
|
17
|
-
},
|
|
18
|
-
async wait(pr, signal) {
|
|
19
|
-
const start = starts.get(pr);
|
|
20
|
-
if (start === undefined)
|
|
21
|
-
return null;
|
|
22
|
-
const result = (status) => {
|
|
23
|
-
const elapsed = Math.max(0, now() - start);
|
|
24
|
-
return {
|
|
25
|
-
status,
|
|
26
|
-
waited_seconds: Math.floor(elapsed / 1000),
|
|
27
|
-
remaining_seconds: Math.ceil(Math.max(0, DEADLINE_MS - elapsed) / 1000),
|
|
28
|
-
};
|
|
29
|
-
};
|
|
30
|
-
if (signal?.aborted)
|
|
31
|
-
return result('cancelled');
|
|
32
|
-
const remaining = DEADLINE_MS - (now() - start);
|
|
33
|
-
if (remaining <= 0)
|
|
34
|
-
return result('timed-out');
|
|
35
|
-
try {
|
|
36
|
-
await pause(Math.min(INTERVAL_MS, remaining), signal);
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
return result('cancelled');
|
|
40
|
-
}
|
|
41
|
-
if (signal?.aborted)
|
|
42
|
-
return result('cancelled');
|
|
43
|
-
return result(now() - start >= DEADLINE_MS ? 'timed-out' : 'retry');
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
|
-
}
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { constants } from 'node:fs';
|
|
3
|
-
import { lstat, open, realpath } from 'node:fs/promises';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
const METADATA_LIMIT = 16 * 1024;
|
|
6
|
-
const OUTPUT_LIMIT = 2 * 1024 * 1024;
|
|
7
|
-
function record(value) {
|
|
8
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
9
|
-
? value : undefined;
|
|
10
|
-
}
|
|
11
|
-
/** Only the root's small project-link metadata is read; CLI authentication stays with the CLI. */
|
|
12
|
-
async function linkedProject(cwd) {
|
|
13
|
-
try {
|
|
14
|
-
const root = await realpath(cwd);
|
|
15
|
-
const directory = join(root, '.vercel');
|
|
16
|
-
const directoryStat = await lstat(directory);
|
|
17
|
-
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || await realpath(directory) !== directory)
|
|
18
|
-
return;
|
|
19
|
-
const path = join(directory, 'project.json');
|
|
20
|
-
if (!(await lstat(path)).isFile())
|
|
21
|
-
return;
|
|
22
|
-
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
23
|
-
try {
|
|
24
|
-
const stat = await file.stat();
|
|
25
|
-
const currentDirectory = await lstat(directory);
|
|
26
|
-
const currentFile = await lstat(path);
|
|
27
|
-
if (!stat.isFile() || stat.size > METADATA_LIMIT
|
|
28
|
-
|| currentDirectory.dev !== directoryStat.dev || currentDirectory.ino !== directoryStat.ino
|
|
29
|
-
|| currentFile.isSymbolicLink() || currentFile.dev !== stat.dev || currentFile.ino !== stat.ino
|
|
30
|
-
|| await realpath(path) !== path)
|
|
31
|
-
return;
|
|
32
|
-
const buffer = Buffer.alloc(METADATA_LIMIT + 1);
|
|
33
|
-
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
34
|
-
if (bytesRead > METADATA_LIMIT)
|
|
35
|
-
return;
|
|
36
|
-
const metadata = record(JSON.parse(buffer.toString('utf8', 0, bytesRead)));
|
|
37
|
-
if (typeof metadata?.projectId !== 'string' || !/^prj_[A-Za-z0-9]{1,128}$/.test(metadata.projectId)
|
|
38
|
-
|| typeof metadata.orgId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(metadata.orgId))
|
|
39
|
-
return;
|
|
40
|
-
return { root, projectId: metadata.projectId, orgId: metadata.orgId };
|
|
41
|
-
}
|
|
42
|
-
finally {
|
|
43
|
-
await file.close();
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
/** Reuse one existing default automation bypass in memory, without exposing project responses or keys. */
|
|
51
|
-
export async function useExistingVercelBypass(input, dependencies) {
|
|
52
|
-
const { signal } = dependencies;
|
|
53
|
-
if (signal?.aborted)
|
|
54
|
-
return { status: 'cancelled' };
|
|
55
|
-
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(input.owner)
|
|
56
|
-
|| !/^[a-zA-Z0-9_.-]{1,100}$/.test(input.repo) || input.repo === '.' || input.repo === '..') {
|
|
57
|
-
return { status: 'project-mismatch' };
|
|
58
|
-
}
|
|
59
|
-
const linked = await linkedProject(input.cwd);
|
|
60
|
-
if (signal?.aborted)
|
|
61
|
-
return { status: 'cancelled' };
|
|
62
|
-
if (!linked)
|
|
63
|
-
return { status: 'not-linked' };
|
|
64
|
-
const runVercel = dependencies.runVercel ?? ((args) => new Promise((resolve, reject) => {
|
|
65
|
-
const child = execFile('vercel', args, {
|
|
66
|
-
cwd: linked.root, encoding: 'utf8', timeout: 30_000, maxBuffer: OUTPUT_LIMIT, signal,
|
|
67
|
-
}, (error, stdout) => error ? reject(new Error('Vercel project lookup failed.')) : resolve(stdout));
|
|
68
|
-
child.stdin?.end();
|
|
69
|
-
}));
|
|
70
|
-
// teamId selects the linked team. Legacy personal projects omit it and still require accountId to match.
|
|
71
|
-
const route = `/v9/projects/${linked.projectId}${linked.orgId.startsWith('team_') ? `?teamId=${linked.orgId}` : ''}`;
|
|
72
|
-
let selected;
|
|
73
|
-
try {
|
|
74
|
-
const stdout = await runVercel(['api', route, '--method', 'GET', '--raw', '--non-interactive'], signal);
|
|
75
|
-
if (signal?.aborted)
|
|
76
|
-
return { status: 'cancelled' };
|
|
77
|
-
if (Buffer.byteLength(stdout, 'utf8') > OUTPUT_LIMIT)
|
|
78
|
-
return { status: 'unavailable' };
|
|
79
|
-
const project = record(JSON.parse(stdout));
|
|
80
|
-
const link = record(project?.link);
|
|
81
|
-
if (project?.id !== linked.projectId || project.accountId !== linked.orgId
|
|
82
|
-
|| !link || !['github', 'github-limited'].includes(String(link.type))
|
|
83
|
-
|| typeof link.org !== 'string' || link.org.toLowerCase() !== input.owner.toLowerCase()
|
|
84
|
-
|| typeof link.repo !== 'string' || link.repo.toLowerCase() !== input.repo.toLowerCase()) {
|
|
85
|
-
return { status: 'project-mismatch' };
|
|
86
|
-
}
|
|
87
|
-
const bypasses = record(project.protectionBypass);
|
|
88
|
-
if (!bypasses || Object.keys(bypasses).length > 128)
|
|
89
|
-
return { status: 'unavailable' };
|
|
90
|
-
const candidates = [];
|
|
91
|
-
for (const [value, metadata] of Object.entries(bypasses)) {
|
|
92
|
-
const bypass = record(metadata);
|
|
93
|
-
if (bypass?.scope !== 'automation-bypass')
|
|
94
|
-
continue;
|
|
95
|
-
if (!/^[A-Za-z0-9]{32}$/.test(value)
|
|
96
|
-
|| (bypass.isEnvVar !== undefined && typeof bypass.isEnvVar !== 'boolean'))
|
|
97
|
-
return { status: 'unavailable' };
|
|
98
|
-
// An omitted isEnvVar is the legacy default. Explicit false keys must never be chosen implicitly.
|
|
99
|
-
if (bypass.isEnvVar !== false)
|
|
100
|
-
candidates.push(value);
|
|
101
|
-
}
|
|
102
|
-
if (candidates.length > 1)
|
|
103
|
-
return { status: 'ambiguous' };
|
|
104
|
-
if (candidates.length === 0)
|
|
105
|
-
return { status: 'unavailable' };
|
|
106
|
-
selected = candidates[0];
|
|
107
|
-
dependencies.rememberSecret(selected);
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
return { status: signal?.aborted ? 'cancelled' : 'unavailable' };
|
|
111
|
-
}
|
|
112
|
-
if (signal?.aborted)
|
|
113
|
-
return { status: 'cancelled' };
|
|
114
|
-
try {
|
|
115
|
-
const status = await dependencies.save(selected);
|
|
116
|
-
return { status: ['saved', 'save-failed', 'partial-save', 'cancelled'].includes(status) ? status : 'save-failed' };
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
return { status: signal?.aborted ? 'cancelled' : 'save-failed' };
|
|
120
|
-
}
|
|
121
|
-
}
|