@impeccable/reviews 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -20
- package/dist/agent.js +9 -4
- package/dist/cloudflare-access.js +77 -47
- package/dist/cloudflare.js +347 -23
- package/dist/fence.js +939 -143
- package/dist/focus.js +5 -4
- package/dist/host-previews.js +123 -0
- package/dist/host-questions.js +101 -21
- 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 +37 -14
- package/dist/netlify-password.js +8 -6
- package/dist/netlify.js +58 -16
- 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 +8 -8
- package/dist/playbook/cases/netlify-previews.md +3 -1
- package/dist/playbook/cases/netlify-setup.md +7 -6
- package/dist/playbook/cases/preview-protection.md +3 -2
- package/dist/playbook/cases/pull-requests.md +3 -3
- package/dist/playbook/cases/static-site.md +2 -2
- package/dist/playbook/cases/vercel-previews.md +3 -1
- package/dist/playbook/cases/vercel-setup.md +9 -5
- package/dist/playbook.js +3 -3
- package/dist/preflight.js +43 -8
- 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 +682 -140
- 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 +21 -12
- package/dist/vercel.js +147 -17
- package/package.json +1 -1
package/dist/ui/bridge.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
2
|
import { Writable } from 'node:stream';
|
|
3
|
-
import { noteSensitiveAnswer } from './sensitive.js';
|
|
3
|
+
import { noteSensitiveAnswer, plainText } from './sensitive.js';
|
|
4
4
|
let mounted = null;
|
|
5
|
+
/** A question as the person sees it: every line of it plain text. */
|
|
6
|
+
function shown(q) {
|
|
7
|
+
const out = { ...q, prompt: plainText(q.prompt) };
|
|
8
|
+
for (const key of ['title', 'hint', 'prefill', 'caution', 'confirmLabel', 'skipLabel']) {
|
|
9
|
+
const text = q[key];
|
|
10
|
+
if (text !== undefined)
|
|
11
|
+
out[key] = plainText(text);
|
|
12
|
+
}
|
|
13
|
+
if (q.fields)
|
|
14
|
+
out.fields = [plainText(q.fields[0]), plainText(q.fields[1])];
|
|
15
|
+
if (q.options)
|
|
16
|
+
out.options = q.options.map((o) => ({ ...o, label: plainText(o.label) }));
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
5
19
|
/** Asks the person: the question on the screen, or printed with one line read from stdin. */
|
|
6
20
|
export async function askPerson(q) {
|
|
7
|
-
const answer = await (mounted ? mounted.ask(q) : askOnStdin(q));
|
|
21
|
+
const answer = await (mounted ? mounted.ask(shown(q)) : askOnStdin(shown(q)));
|
|
8
22
|
if (q.sensitive)
|
|
9
23
|
for (const value of [answer.value, ...(answer.values ?? [])])
|
|
10
24
|
if (value)
|
|
@@ -15,11 +29,11 @@ export async function askPerson(q) {
|
|
|
15
29
|
}
|
|
16
30
|
/** A status line for the status band. */
|
|
17
31
|
export function reportStatus(line) {
|
|
18
|
-
mounted?.status(line);
|
|
32
|
+
mounted?.status(plainText(line));
|
|
19
33
|
}
|
|
20
34
|
/** Replaces the task list. */
|
|
21
35
|
export function reportTasks(tasks) {
|
|
22
|
-
mounted?.tasks(tasks);
|
|
36
|
+
mounted?.tasks(tasks.map((t) => ({ ...t, title: plainText(t.title) })));
|
|
23
37
|
}
|
|
24
38
|
/** The run waits on something outside it (a host's build): the screen counts the time up beside the status line until `null` ends it. */
|
|
25
39
|
export function reportWait(wait) {
|
package/dist/ui/look.js
CHANGED
|
@@ -304,7 +304,7 @@ function pageOf(f, inner) {
|
|
|
304
304
|
heading: scene.again ? 'Sign in again' : START.heading,
|
|
305
305
|
text: said(scene.again ? AGAIN[scene.again] : 'Sign in with GitHub in your browser, then approve this repository.', inner, st),
|
|
306
306
|
rule: divider(inner, st),
|
|
307
|
-
action: [...checkout, ...link, ...(under ? ['', center(st.full(under), inner)] : []), ...(scene.copied
|
|
307
|
+
action: [...checkout, ...link, ...(under ? ['', center(st.full(under), inner)] : []), ...(scene.copied ? [] : ['', copyWay(inner, st)])],
|
|
308
308
|
};
|
|
309
309
|
}
|
|
310
310
|
case 'run':
|
package/dist/ui/screens.js
CHANGED
|
@@ -7,6 +7,7 @@ import { mountUi } from './bridge.js';
|
|
|
7
7
|
import { createFrameWriter, frameState } from './frames.js';
|
|
8
8
|
import { CLOSES_AFTER_MINUTES, drawFrame, OUTCOME, PRODUCT, palette } from './look.js';
|
|
9
9
|
import { RunState } from './run-state.js';
|
|
10
|
+
import { plainText } from './sensitive.js';
|
|
10
11
|
/** What the command does before a screen ends it at once (Ctrl+C before a run, or a signal): report the stop (main.ts), briefly. */
|
|
11
12
|
let beforeExit = async () => { };
|
|
12
13
|
export function onScreenExit(fn) {
|
|
@@ -439,7 +440,7 @@ export function showIntro(repository, note) {
|
|
|
439
440
|
}
|
|
440
441
|
/**
|
|
441
442
|
* The sign-in screen, in place of whatever is showing, until `close`: the
|
|
442
|
-
* link, put on the clipboard
|
|
443
|
+
* link, put on the clipboard only when the person presses C (`onCopy`), and a way
|
|
443
444
|
* to paste the code from a remote shell (P calls `onPaste`, and a null from it
|
|
444
445
|
* says the code was wrong); Esc calls `onCancel`. Over a run it asks to sign
|
|
445
446
|
* in again.
|
|
@@ -453,7 +454,6 @@ export function showSignIn(link, opts) {
|
|
|
453
454
|
}
|
|
454
455
|
const view = { kind: 'signin', link, opts, pasting: false, again: screen.views.some((v) => v.kind === 'run') && (opts.why ?? 'ran-out') };
|
|
455
456
|
screen.push(view);
|
|
456
|
-
screen.copy(view);
|
|
457
457
|
return { close: () => screen.remove(view) };
|
|
458
458
|
}
|
|
459
459
|
/**
|
|
@@ -505,7 +505,9 @@ export async function showPullRequest(url, hand) {
|
|
|
505
505
|
});
|
|
506
506
|
}
|
|
507
507
|
/** The last screen: what happened, what is left, any warnings and its pull request (handed over as it appears) or the links, until a key other than ↑↓ PgUp PgDn is pressed, or for 20 minutes. Its summary then stays in the scrollback. */
|
|
508
|
-
export async function showOutro(
|
|
508
|
+
export async function showOutro(outro, hand) {
|
|
509
|
+
// What is left and why it stopped can be the model's words, which reach the scrollback after the clipboard holds the pull request.
|
|
510
|
+
const o = { ...outro, result: plainText(outro.result), todo: outro.todo.map(plainText), warnings: outro.warnings.map(plainText) };
|
|
509
511
|
const screen = host();
|
|
510
512
|
if (screen) {
|
|
511
513
|
const copied = o.link && hand ? await handOver(o.link, hand) : false;
|
package/dist/ui/sensitive.js
CHANGED
|
@@ -12,3 +12,13 @@ export function noteSensitiveAnswer(value) {
|
|
|
12
12
|
for (const listener of listeners)
|
|
13
13
|
listener(value);
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* What makes a terminal act rather than show text: an escape sequence (CSI, OSC, a string such as DCS, or two characters) or a control
|
|
17
|
+
* character other than a tab or a newline. OSC 52 writes the clipboard, OSC 0 the window's title, OSC 8 links words to another address.
|
|
18
|
+
* An OSC or a string that is never ended runs to the next escape, as the terminal reads it.
|
|
19
|
+
*/
|
|
20
|
+
const CONTROL = /\x1b(?:\[[0-?]*[ -/]*[@-~]|[\]PX^_][^\x07\x1b]*(?:\x07|\x1b\\)?|[ -/]*[0-~])?|[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
|
|
21
|
+
/** `text` without what CONTROL matches: the model's words, and the repository's through them, as the screen and the scrollback show them. */
|
|
22
|
+
export function plainText(text) {
|
|
23
|
+
return text.replace(CONTROL, '');
|
|
24
|
+
}
|
package/dist/vercel-bypass.js
CHANGED
|
@@ -14,10 +14,12 @@ export const BYPASS_HEADER = 'x-vercel-protection-bypass';
|
|
|
14
14
|
export const SECRET_NOTE = 'Impeccable reviews';
|
|
15
15
|
/** Vercel makes its secrets of 32 letters and digits, and takes no other. */
|
|
16
16
|
export const isBypassSecret = (value) => typeof value === 'string' && /^[A-Za-z0-9]{32}$/.test(value);
|
|
17
|
-
/** The CLI's read of the linked project's protection: `protectionBypass` maps each secret to its note and creation time. */
|
|
17
|
+
/** The CLI's read of the linked project's protection, or with `name` that project's: `protectionBypass` maps each secret to its note and creation time. */
|
|
18
18
|
export const READ = ['project', 'protection', '--format', 'json', '--non-interactive'];
|
|
19
|
-
|
|
19
|
+
const readArgs = (name) => (name ? ['project', 'protection', name, ...READ.slice(2)] : READ);
|
|
20
|
+
/** The CLI's own way to make a secret, which carries no label, for a CLI without `api`; with `name`, on that project. */
|
|
20
21
|
export const MAKE_CLI = ['project', 'protection', 'enable', '--protection-bypass', '--non-interactive'];
|
|
22
|
+
const makeArgs = (name) => (name ? ['project', 'protection', 'enable', name, ...MAKE_CLI.slice(3)] : MAKE_CLI);
|
|
21
23
|
/** `vercel api` (beta) for PATCH /v1/projects/{id}/protection-bypass with `generate.note`, so the secret carries the label; a team's project needs its team on the request. */
|
|
22
24
|
export const makeApi = (link) => [
|
|
23
25
|
'api',
|
|
@@ -27,26 +29,32 @@ export const makeApi = (link) => [
|
|
|
27
29
|
'--raw', '--non-interactive',
|
|
28
30
|
];
|
|
29
31
|
const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
|
|
32
|
+
/** The ids as Vercel makes them, and nothing else goes into a request's path: the file is in the checkout, which the repository and the sandboxed agent write. */
|
|
33
|
+
const PROJECT_ID = /^prj_[A-Za-z0-9]+$/;
|
|
34
|
+
const ORG_ID = /^(?:team_)?[A-Za-z0-9]+$/;
|
|
35
|
+
/** A project and its team or account as Vercel names them, or null for ids that are not Vercel's. */
|
|
36
|
+
export const asLink = (projectId, orgId) => typeof projectId === 'string' && PROJECT_ID.test(projectId) && typeof orgId === 'string' && ORG_ID.test(orgId) ? { projectId, orgId } : null;
|
|
37
|
+
/** The link as the file has it; null with no file, or ids that are not Vercel's. What project the ids name is the CLI's to confirm (readOwnProject in vercel.ts). */
|
|
30
38
|
export async function readLink(cwd) {
|
|
31
39
|
try {
|
|
32
40
|
const link = record(JSON.parse(await readFile(join(cwd, '.vercel', 'project.json'), 'utf8')));
|
|
33
|
-
return
|
|
41
|
+
return asLink(link?.projectId, link?.orgId);
|
|
34
42
|
}
|
|
35
43
|
catch {
|
|
36
44
|
return null;
|
|
37
45
|
}
|
|
38
46
|
}
|
|
39
|
-
/** The linked project's name and automation secrets; null when the CLI could not say: signed out, not linked, or the role does not allow it. */
|
|
40
|
-
async function readProject(run) {
|
|
47
|
+
/** The linked project's name and automation secrets; null when the CLI could not say: signed out, not linked, or the role does not allow it; or when what it read is not the project `link` names. */
|
|
48
|
+
async function readProject(run, link, name) {
|
|
41
49
|
let settings;
|
|
42
50
|
try {
|
|
43
|
-
const printed = await run(
|
|
51
|
+
const printed = await run(readArgs(name));
|
|
44
52
|
settings = record(JSON.parse(printed.slice(printed.indexOf('{'), printed.lastIndexOf('}') + 1)));
|
|
45
53
|
}
|
|
46
54
|
catch {
|
|
47
55
|
return null;
|
|
48
56
|
}
|
|
49
|
-
if (typeof settings?.projectId !== 'string')
|
|
57
|
+
if (typeof settings?.projectId !== 'string' || (link && settings.projectId !== link.projectId))
|
|
50
58
|
return null;
|
|
51
59
|
const secrets = Object.entries(record(settings.protectionBypass) ?? {}).flatMap(([value, meta]) => {
|
|
52
60
|
const m = record(meta);
|
|
@@ -60,7 +68,7 @@ function pick(secrets, before) {
|
|
|
60
68
|
return newest(secrets.filter((s) => s.note === SECRET_NOTE)) ?? (before && newest(secrets.filter((s) => !before.has(s.value))));
|
|
61
69
|
}
|
|
62
70
|
/** Makes the secret with the label through `vercel api`; a CLI without `api` (or one Vercel refuses) falls back to its own command, which the same role check stops. */
|
|
63
|
-
async function make(run, link) {
|
|
71
|
+
async function make(run, link, name) {
|
|
64
72
|
if (link) {
|
|
65
73
|
try {
|
|
66
74
|
await run(makeApi(link));
|
|
@@ -71,7 +79,7 @@ async function make(run, link) {
|
|
|
71
79
|
}
|
|
72
80
|
}
|
|
73
81
|
try {
|
|
74
|
-
await run(
|
|
82
|
+
await run(makeArgs(name));
|
|
75
83
|
return true;
|
|
76
84
|
}
|
|
77
85
|
catch {
|
|
@@ -85,7 +93,8 @@ async function make(run, link) {
|
|
|
85
93
|
* what it gave opens nothing, the person may paste one.
|
|
86
94
|
*/
|
|
87
95
|
export async function passVercelLogin(deps) {
|
|
88
|
-
const
|
|
96
|
+
const link = await deps.link();
|
|
97
|
+
const project = await readProject(deps.run, link, deps.project);
|
|
89
98
|
const from = project?.name ? { project: project.name } : {};
|
|
90
99
|
const finish = async (secret, how) => {
|
|
91
100
|
deps.remember(secret);
|
|
@@ -111,10 +120,10 @@ export async function passVercelLogin(deps) {
|
|
|
111
120
|
if (!(await deps.consent()))
|
|
112
121
|
return { status: 'skipped', reason: 'The person skipped creating a bypass secret.', ...from };
|
|
113
122
|
const before = new Set(project.secrets.map((s) => s.value));
|
|
114
|
-
if (!(await make(deps.run,
|
|
123
|
+
if (!(await make(deps.run, link, deps.project)))
|
|
115
124
|
stopped = 'The Vercel CLI could not make a bypass secret on the project: that takes the Member role or Project Administrator.';
|
|
116
125
|
else {
|
|
117
|
-
const ours = pick((await readProject(deps.run))?.secrets ?? [], before);
|
|
126
|
+
const ours = pick((await readProject(deps.run, link, deps.project))?.secrets ?? [], before);
|
|
118
127
|
if (!ours)
|
|
119
128
|
stopped = 'The secret the Vercel CLI made could not be read back.';
|
|
120
129
|
else {
|
package/dist/vercel.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
4
|
+
import { folderOf, matchesBranch, TEST_DRIVE_BRANCH } from './host-previews.js';
|
|
5
|
+
import { asLink, readLink } from './vercel-bypass.js';
|
|
2
6
|
/** `vercel whoami`: the CLI prints the username, or says it has no credentials and exits 1. */
|
|
3
7
|
export async function vercelStatus(vercel) {
|
|
4
8
|
try {
|
|
@@ -80,6 +84,8 @@ const projectApi = (link, change) => [
|
|
|
80
84
|
'--raw', '--non-interactive',
|
|
81
85
|
];
|
|
82
86
|
export const readProjectApi = (link) => projectApi(link);
|
|
87
|
+
/** `vercel api` reading one deployment by its host, GET /v13/deployments/{idOrUrl} (https://vercel.com/docs/rest-api/deployments/get-a-deployment), which names the project it belongs to; a team's deployment needs its team on the request. */
|
|
88
|
+
export const readDeploymentApi = (link, host) => ['api', `/v13/deployments/${encodeURIComponent(host)}${link.orgId.startsWith('team_') ? `?teamId=${encodeURIComponent(link.orgId)}` : ''}`, '--raw', '--non-interactive'];
|
|
83
89
|
/**
|
|
84
90
|
* Vercel Authentication on All Deployments: Vercel's login in front of every address of the project, its production
|
|
85
91
|
* domains included (https://vercel.com/docs/deployment-protection#all-deployments), as the endpoint takes it
|
|
@@ -92,39 +98,163 @@ const printedProject = async (vercel, args) => {
|
|
|
92
98
|
return record(JSON.parse(printed.slice(printed.indexOf('{'), printed.lastIndexOf('}') + 1)));
|
|
93
99
|
};
|
|
94
100
|
const scope = (project) => record(project?.ssoProtection)?.deploymentType;
|
|
95
|
-
/** The linked project as `vercel api` prints it: its production deployment (the production target, or one among the latest) unless every one failed or was cancelled, its Git link's production branch, its protection and its deployments' hosts. Null when the CLI could not read it. */
|
|
101
|
+
/** The linked project as `vercel api` prints it: its production deployment (the production target, or one among the latest) unless every one failed or was cancelled, its Git link's repository and production branch, its protection and its deployments' hosts. Null when the CLI could not read it. */
|
|
96
102
|
export async function readVercelProject(vercel, link) {
|
|
103
|
+
const project = await readLinkedProject(vercel, link);
|
|
104
|
+
return project === 'missing' ? null : project;
|
|
105
|
+
}
|
|
106
|
+
/** Vercel's answer for a project it has no record of: the API's error, or the CLI's word for it. */
|
|
107
|
+
const NOT_FOUND = /"code"\s*:\s*"not_found"|project not found/i;
|
|
108
|
+
/** The linked project as readVercelProject reads it, or `missing` when Vercel answers that no such project exists (the link file names one that was deleted, or another team's), as told from a read that failed, which says nothing either way. */
|
|
109
|
+
export async function readLinkedProject(vercel, link) {
|
|
97
110
|
let project;
|
|
98
111
|
try {
|
|
99
112
|
project = await printedProject(vercel, readProjectApi(link));
|
|
100
113
|
}
|
|
101
|
-
catch {
|
|
102
|
-
|
|
114
|
+
catch (err) {
|
|
115
|
+
const said = String(err.stdout ?? '') + String(err.stderr ?? '');
|
|
116
|
+
return NOT_FOUND.test(said) ? 'missing' : null;
|
|
103
117
|
}
|
|
118
|
+
if (record(project?.error)?.code === 'not_found')
|
|
119
|
+
return 'missing';
|
|
104
120
|
if (project?.id !== link.projectId)
|
|
105
121
|
return null;
|
|
106
122
|
const target = record(record(project.targets)?.production);
|
|
107
123
|
const latest = (Array.isArray(project.latestDeployments) ? project.latestDeployments : []).map(record).filter((d) => d !== undefined);
|
|
108
124
|
const failed = (d) => d.readyState === 'ERROR' || d.readyState === 'CANCELED';
|
|
109
125
|
const exists = (target !== undefined && !failed(target)) || latest.some((d) => d.target === 'production' && !failed(d));
|
|
110
|
-
const
|
|
126
|
+
const git = record(project.link);
|
|
127
|
+
const branch = git?.productionBranch;
|
|
128
|
+
const repository = git?.type === 'github' && typeof git.org === 'string' && typeof git.repo === 'string' && git.org && git.repo ? `${git.org}/${git.repo}`.toLowerCase() : undefined;
|
|
111
129
|
const protection = scope(project);
|
|
130
|
+
// Each method is an object while on, null or absent while off (https://vercel.com/docs/rest-api/projects/update-an-existing-project); every scope but Trusted IPs' "production" covers the preview addresses.
|
|
131
|
+
const ips = record(project.trustedIps);
|
|
132
|
+
const previewsProtected = record(project.ssoProtection) !== undefined || record(project.passwordProtection) !== undefined || (ips !== undefined && ips.deploymentType !== 'production');
|
|
133
|
+
const hostname = (value) => (typeof value === 'string' && /^(https?:\/\/)?[a-z0-9.-]{1,253}$/i.test(value) ? value.replace(/^https?:\/\//, '').toLowerCase() : undefined);
|
|
112
134
|
const hosts = [target, ...latest].flatMap((d) => (typeof d?.url === 'string' && d.url ? [d.url.replace(/^https?:\/\//, '').toLowerCase()] : []));
|
|
113
|
-
|
|
135
|
+
const ignore = project.commandForIgnoringBuildStep;
|
|
136
|
+
// The live app: the production deployment's first alias, the project's own domain, as Vercel lists them (its automatic aliases follow). Its own hashed address is one build's, which the next production deployment leaves behind: never the before.
|
|
137
|
+
const live = target && !failed(target) ? (Array.isArray(target.alias) ? target.alias : []).map(hostname).find(Boolean) : undefined;
|
|
138
|
+
const root = folderOf(project.rootDirectory);
|
|
139
|
+
return { ...(root !== undefined ? { rootDirectory: root } : {}), ...(typeof project.name === 'string' && project.name ? { name: project.name } : {}), production: exists ? 'exists' : 'none', ...(typeof branch === 'string' && branch ? { productionBranch: branch } : {}), ...(live ? { productionUrl: `https://${live}` } : {}), protection: typeof protection === 'string' ? protection : null, previewsProtected, deploymentHosts: new Set(hosts), ...(repository ? { repository } : {}), ignoredBuildStep: typeof ignore === 'string' && ignore.trim().length > 0, githubDeployments: record(project.gitProviderOptions)?.createDeployments !== 'disabled' };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The linked project as this repository's: read now, and connected to the repository on Vercel, which its Git link
|
|
143
|
+
* says. The link file names whatever project the person can administer (the repository, or the sandboxed agent, writes
|
|
144
|
+
* it), so the run locks, and sends the secrets of, only a project this read vouches for; `reason` says what stopped it.
|
|
145
|
+
*/
|
|
146
|
+
export async function readOwnProject(vercel, link, repository) {
|
|
147
|
+
const project = await readVercelProject(vercel, link);
|
|
148
|
+
if (!project)
|
|
149
|
+
return { reason: 'The Vercel CLI could not read the project (vercel api): it is signed out, or this CLI has no api command.' };
|
|
150
|
+
if (project.repository !== repository.toLowerCase())
|
|
151
|
+
return { reason: `The Vercel project this folder is linked to${project.name ? ` (${project.name})` : ''} is not connected to this repository on Vercel.` };
|
|
152
|
+
return project;
|
|
153
|
+
}
|
|
154
|
+
/** Vercel's project names, which go into the CLI's arguments. */
|
|
155
|
+
const PROJECT_NAME = /^[a-z0-9][a-z0-9._-]{0,99}$/;
|
|
156
|
+
/**
|
|
157
|
+
* The Vercel project that builds `directory` of `repository`, on a repository with several apps: of the projects Vercel
|
|
158
|
+
* lists as connected to the repository (GET /v10/projects?repoUrl=), in the team `team` names (the root link's) or the
|
|
159
|
+
* CLI's current scope, the one whose Root Directory is that folder. Never the newest, nor the one the root link names:
|
|
160
|
+
* none, or several, is a `reason`, so one app never borrows another's project.
|
|
161
|
+
*/
|
|
162
|
+
export async function vercelProjectFor(vercel, repository, directory, team) {
|
|
163
|
+
const where = directory ? `the folder ${directory}` : "the repository's root";
|
|
164
|
+
let listed;
|
|
165
|
+
try {
|
|
166
|
+
const query = `repoUrl=${encodeURIComponent(`https://github.com/${repository}`)}&limit=100${team?.startsWith('team_') ? `&teamId=${encodeURIComponent(team)}` : ''}`;
|
|
167
|
+
const printed = await vercel(['api', `/v10/projects?${query}`, '--raw', '--non-interactive']);
|
|
168
|
+
listed = record(JSON.parse(printed.slice(printed.indexOf('{'), printed.lastIndexOf('}') + 1)))?.projects;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return { reason: "The Vercel CLI could not list this repository's projects (vercel api): it is signed out, or this CLI has no api command." };
|
|
172
|
+
}
|
|
173
|
+
if (!Array.isArray(listed))
|
|
174
|
+
return { reason: "The Vercel CLI could not list this repository's projects (vercel api): it is signed out, or this CLI has no api command." };
|
|
175
|
+
const builds = listed.map(record).filter((p) => {
|
|
176
|
+
const git = record(p?.link);
|
|
177
|
+
return git?.type === 'github' && `${git.org}/${git.repo}`.toLowerCase() === repository.toLowerCase() && folderOf(p.rootDirectory) === folderOf(directory);
|
|
178
|
+
});
|
|
179
|
+
const found = builds.flatMap((p) => {
|
|
180
|
+
const link = asLink(p.id, p.accountId);
|
|
181
|
+
return link && typeof p.name === 'string' && PROJECT_NAME.test(p.name) ? [{ link, name: p.name }] : [];
|
|
182
|
+
});
|
|
183
|
+
if (builds.length > 1)
|
|
184
|
+
return { reason: `Several Vercel projects build ${where} (${found.map((p) => p.name).join(', ') || 'unnamed'}), so which one is this app's cannot be told.` };
|
|
185
|
+
if (!found.length)
|
|
186
|
+
return { reason: `No Vercel project of this repository builds ${where} (its Root Directory), as far as the Vercel CLI here can see.` };
|
|
187
|
+
return found[0];
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Whether the project made the deployment at `host`: one of its latest, as the project read lists them; else, since a
|
|
191
|
+
* project lists its newest deployments alone while an older pull request's preview stays served, the deployment read
|
|
192
|
+
* on its own by its host, as the person, which names its project. False when that read fails: another team's, or none.
|
|
193
|
+
*/
|
|
194
|
+
export async function projectMadeDeployment(vercel, link, project, host) {
|
|
195
|
+
if (project.deploymentHosts.has(host))
|
|
196
|
+
return true;
|
|
197
|
+
try {
|
|
198
|
+
return (await printedProject(vercel, readDeploymentApi(link, host)))?.projectId === link.projectId;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Whether Vercel builds the test drive's branch at all, as the checkout's vercel.json
|
|
206
|
+
* (https://vercel.com/docs/project-configuration/git-configuration#git.deploymentenabled: `git.deploymentEnabled` false
|
|
207
|
+
* turns every automatic deployment off, and a branch rule, in minimatch syntax, turns its branches off unless a rule
|
|
208
|
+
* says true) and the linked project say: an Ignored Build Step decides commit by commit, so the answer is unknown; with
|
|
209
|
+
* Vercel's GitHub deployments off, Impeccable finds no preview. The linked project counts only as this repository's
|
|
210
|
+
* (readOwnProject): another repository's settings are never the fact. Nothing here changes Vercel: the fact is reported.
|
|
211
|
+
* Undefined where the checkout names no Vercel setting and is linked to no project.
|
|
212
|
+
*/
|
|
213
|
+
export async function vercelPreviewsFact(cwd, repository, vercel, app) {
|
|
214
|
+
let setting;
|
|
215
|
+
try {
|
|
216
|
+
// An app's project reads the vercel.json of the folder it builds.
|
|
217
|
+
setting = record(record(JSON.parse(await readFile(join(cwd, app?.directory ?? '', 'vercel.json'), 'utf8')))?.git)?.deploymentEnabled;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// No vercel.json, or none that reads as JSON: Vercel's default, every branch.
|
|
221
|
+
}
|
|
222
|
+
const link = app ? app.link : await readLink(cwd);
|
|
223
|
+
if (setting === undefined && !link)
|
|
224
|
+
return undefined;
|
|
225
|
+
// The CLI runs only for a linked project, and only this repository's own counts.
|
|
226
|
+
const own = link ? await readOwnProject(vercel(), link, repository) : null;
|
|
227
|
+
const project = own && !('reason' in own) ? own : null;
|
|
228
|
+
const fact = { project: project?.name ?? '', enabled: true, ...(project?.productionBranch ? { productionBranch: project.productionBranch } : {}) };
|
|
229
|
+
if (setting === false)
|
|
230
|
+
return { ...fact, enabled: false, reason: `vercel.json turns automatic deployments off for every branch (git.deploymentEnabled: false), so a push of ${TEST_DRIVE_BRANCH} builds nothing.` };
|
|
231
|
+
const branches = record(setting);
|
|
232
|
+
if (branches) {
|
|
233
|
+
const matched = Object.entries(branches).filter(([rule]) => matchesBranch(rule, TEST_DRIVE_BRANCH, true));
|
|
234
|
+
if (matched.length && !matched.some(([, on]) => on === true))
|
|
235
|
+
return { ...fact, enabled: false, reason: `vercel.json turns automatic deployments off for ${TEST_DRIVE_BRANCH} (git.deploymentEnabled: "${matched[0][0]}": false).` };
|
|
236
|
+
}
|
|
237
|
+
if (own && 'reason' in own)
|
|
238
|
+
return { ...fact, enabled: 'unknown', reason: own.reason };
|
|
239
|
+
if (project && !project.githubDeployments)
|
|
240
|
+
return { ...fact, enabled: false, reason: 'The project records no deployments on GitHub (Vercel GitHub deployments are off), and GitHub\'s record is where Impeccable finds Vercel\'s previews.' };
|
|
241
|
+
if (project?.ignoredBuildStep)
|
|
242
|
+
return { ...fact, enabled: 'unknown', reason: 'An Ignored Build Step is set on the project (Settings → Git), which decides commit by commit whether a push builds.' };
|
|
243
|
+
return fact;
|
|
114
244
|
}
|
|
115
245
|
/**
|
|
116
246
|
* The linked project before its first push: read through `vercel api`, and, with no production deployment, locked
|
|
117
247
|
* behind Vercel's login at every address, since Vercel makes a new project's first deployment Production whatever
|
|
118
248
|
* branch it builds and Standard Protection serves production domains to anyone. A project that deploys production
|
|
119
|
-
* already (live or building), or is locked already, is left as it is
|
|
249
|
+
* already (live or building), or is locked already, is left as it is, and so is one not connected to `repository`.
|
|
120
250
|
*/
|
|
121
|
-
export async function prepareFirstDeployment(vercel, link, lock = true) {
|
|
122
|
-
const project = await
|
|
123
|
-
if (
|
|
124
|
-
return { production: 'unknown', locked: false, reason:
|
|
251
|
+
export async function prepareFirstDeployment(vercel, link, repository, lock = true) {
|
|
252
|
+
const project = await readOwnProject(vercel, link, repository);
|
|
253
|
+
if ('reason' in project)
|
|
254
|
+
return { production: 'unknown', locked: false, reason: project.reason };
|
|
125
255
|
const branch = { ...(project.productionBranch ? { productionBranch: project.productionBranch } : {}), ...(project.name ? { project: project.name } : {}) };
|
|
126
256
|
if (project.production === 'exists')
|
|
127
|
-
return { production: 'exists', ...branch };
|
|
257
|
+
return { production: 'exists', ...branch, ...(project.productionUrl ? { productionUrl: project.productionUrl } : {}) };
|
|
128
258
|
if (project.protection === 'all')
|
|
129
259
|
return { production: 'none', locked: true, ...branch };
|
|
130
260
|
if (!lock)
|
|
@@ -146,9 +276,9 @@ export function vercelProjectPage(page, team, project) {
|
|
|
146
276
|
return team && project ? `https://vercel.com/${encodeURIComponent(team)}/${encodeURIComponent(project)}/${page}` : `https://vercel.com/d?to=${encodeURIComponent(`/[team]/[project]/${page}`)}`;
|
|
147
277
|
}
|
|
148
278
|
/** Reads the project again every `wait.everyMs` until Vercel's login stands in front of every address of it, or it deploys production, or `wait.totalMs` has passed (a read that failed counts as neither): the person is locking it meanwhile. */
|
|
149
|
-
export async function waitForVercelLock(vercel, link, deps) {
|
|
279
|
+
export async function waitForVercelLock(vercel, link, repository, deps) {
|
|
150
280
|
for (const end = Date.now() + deps.wait.totalMs;;) {
|
|
151
|
-
const first = await prepareFirstDeployment(vercel, link, false);
|
|
281
|
+
const first = await prepareFirstDeployment(vercel, link, repository, false);
|
|
152
282
|
// A read that failed says nothing yet, so the wait goes on.
|
|
153
283
|
if (first.production === 'exists' || first.locked || Date.now() >= end || deps.signal?.aborted)
|
|
154
284
|
return first;
|
|
@@ -163,9 +293,9 @@ export async function waitForVercelLock(vercel, link, deps) {
|
|
|
163
293
|
/**
|
|
164
294
|
* The hosts of the linked project's deployments while Vercel's login stands in front of every address of it, read
|
|
165
295
|
* now: a Production deployment GitHub lists for a pushed branch is its preview only when it is one of these. Empty
|
|
166
|
-
* when the project is not locked at every address, or could not be read.
|
|
296
|
+
* when the project is not locked at every address, is not this repository's, or could not be read.
|
|
167
297
|
*/
|
|
168
|
-
export async function lockedDeploymentHosts(vercel, link) {
|
|
169
|
-
const project = await
|
|
170
|
-
return project
|
|
298
|
+
export async function lockedDeploymentHosts(vercel, link, repository) {
|
|
299
|
+
const project = await readOwnProject(vercel, link, repository);
|
|
300
|
+
return !('reason' in project) && project.protection === 'all' ? project.deploymentHosts : new Set();
|
|
171
301
|
}
|