@impeccable/reviews 0.1.2 → 0.1.3
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/fence.js +1 -1
- package/dist/playbook/cases/cloudflare-previews.md +2 -2
- package/dist/playbook/cases/first-preview-failure.md +1 -1
- package/dist/playbook/cases/monorepo.md +4 -3
- package/dist/playbook/cases/netlify-previews.md +2 -2
- package/dist/playbook/cases/netlify-setup.md +3 -3
- package/dist/playbook/cases/pull-requests.md +2 -2
- package/dist/playbook/cases/server-previews.md +17 -0
- package/dist/playbook/cases/sign-in.md +0 -1
- package/dist/playbook/cases/static-site.md +1 -1
- package/dist/playbook/cases/vercel-previews.md +1 -1
- package/dist/playbook/cases/vercel-setup.md +3 -3
- package/dist/playbook.js +2 -2
- package/dist/tools.js +5 -2
- package/package.json +1 -1
- package/dist/playbook/cases/ci-tunnel.md +0 -21
- 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/fence.js
CHANGED
|
@@ -330,7 +330,7 @@ function scriptsIn(dir) {
|
|
|
330
330
|
* package.json and the files the scripts and listed tools load from the checkout by convention (`*.config.js`, `.*rc`): once the agent
|
|
331
331
|
* changes one, what runs is no longer the repository's own code.
|
|
332
332
|
*/
|
|
333
|
-
/**
|
|
333
|
+
/** A rerun on Impeccable's tunnel (first-preview-failure) corrects Vite's preview.allowedHosts and proves it with the local preview check, which runs in its own copy and environment: after that edit only the shell stops. */
|
|
334
334
|
const VITE_CONFIG = /(?:^|\/)vite\.config\.[cm]?[jt]s$/i;
|
|
335
335
|
const LOADED = /(?:^|\/)(?:package\.json|[\w.-]+\.config\.[cm]?[jt]s|\.[\w-]+rc(?:\.\w+)*|tsconfig[^/]*\.json|knexfile\.[^/]+|bunfig\.toml|prisma\/seed\.[^/]+|node_modules\/.+)$/i;
|
|
336
336
|
/** Why scripts and tools stop running: the agent changed a package.json (true when it does not say which), or a file named here that they load. */
|
|
@@ -10,8 +10,8 @@ Cloudflare Pages deploys previews of the pull requests: impeccable_cloudflare_pr
|
|
|
10
10
|
### Reference
|
|
11
11
|
|
|
12
12
|
- **Behind Cloudflare Access** (`gate: "cloudflare-access"`): Cloudflare's login stands in front of the previews. Get past it as preview-protection says, which proves the preview, and go on with step 2; when nothing got past it, that case says how the run ends.
|
|
13
|
-
- **Another app, or no answer from any of the three**: Cloudflare's previews do not show this app. Set the preview up as
|
|
14
|
-
- **Nothing listed**: Cloudflare's previews are not on record, so set the preview up as
|
|
13
|
+
- **Another app, or no answer from any of the three**: Cloudflare's previews do not show this app. Set the preview up as server-previews or static-site says instead, and say under "What is not verified" what the Cloudflare address answered.
|
|
14
|
+
- **Nothing listed**: Cloudflare's previews are not on record, so set the preview up as server-previews or static-site says. An `error` means a read failed, so a preview may exist that is not listed: say so under "What is not verified".
|
|
15
15
|
- **The pull requests**: no setup pull request; the test drive branches from the default branch, and Cloudflare builds every pushed branch, so pull-requests' step 4 goes the Vercel way with impeccable_cloudflare_previews in place of impeccable_hosted_previews: with `branch: "impeccable/test-drive"` it waits up to five minutes for the check run on the pushed head, under a status line with its timer, and the test drive's page is proven on the alias it lists before the pull request opens. In place of pull-requests' step 3, build the test drive branch once with the build Cloudflare runs (the Pages project's build command, which the checkout's `package.json` `build` script usually is), so a page that does not build is never pushed.
|
|
16
16
|
- The evidence is read only: link, deploy and change nothing on Cloudflare. The one Cloudflare sign-in is impeccable_cloudflare_access's, behind Access, on the person's yes. Cloudflare does not report its previews as GitHub deployments, so Impeccable finds them by their address pattern, the branch's alias: save the pattern the proof answered, and no other.
|
|
17
17
|
- The report names the address that answered with this app under "What we found", and the pattern saved under "What was configured, and by whom". The test drive's own preview, which Cloudflare builds after the run, goes under "What is not verified".
|
|
@@ -25,4 +25,4 @@ A rerun: the setup pull request an earlier run opened has a preview job that fai
|
|
|
25
25
|
| upload | The real build output and its entry page; correct `outputDir`. |
|
|
26
26
|
| seed | The migration or seed command and the throwaway database. |
|
|
27
27
|
| start | `startCommand`, `port`, `env` and readiness. |
|
|
28
|
-
| tunnel |
|
|
28
|
+
| tunnel | A repository already on Impeccable's tunnel: the app refusing the forwarded Host, allowed in the framework's host check for previews only (Rails `config.hosts`, Django `ALLOWED_HOSTS`, Vite `preview.allowedHosts`), not Impeccable's own access gate: a 403 on the bare link alone is not a broken preview. |
|
|
@@ -8,9 +8,10 @@ The state's repository has an `apps` item: the scan proposed several apps, folde
|
|
|
8
8
|
- **An item that is `done` or `skipped`** was answered before, on the setup page or by an earlier run (a run that stopped at the question left the scan's default, as a skip does): write nothing and ask nothing; the state's apps that are `enabled` are the chosen ones. Step 4.
|
|
9
9
|
2. **Ask which apps get previews**, once, with impeccable_ask: `id: "apps"`, `kind: "multi"`, `title` *"This repository has several apps"*, `prompt` *"Which apps should get Impeccable previews?"*, `hint` *"Each draws pages of its own; untick any that should not get previews."*, and `options` one per app the answer listed: `value` its `key`, `label` its `name` and folder (*"Storefront · apps/storefront"*), `selected: true` on every one. The repository's own app (`main`) is among them only where the Worker kept it as a product of its own, with pages outside every app's folder: its label is its name and `/`. Done when it answered `values`, or a skip.
|
|
10
10
|
3. **Write the picks**, one action a call as the item's instructions give them: `enable` each chosen app that is off, then `disable` each app that is on and was not chosen. The last app that is on cannot be switched off (409), which is why the enables come first. **After Skip**, write nothing more: the app the scan switched on stays the one that is on. Done when the last answer's `apps` show `enabled` true for every chosen app and false for the rest, or, after Skip, the confirm's answer stands.
|
|
11
|
-
4. **Read the state again** with impeccable_state. Done when the preview item's `apps` names each chosen app's `directory`, `build` and `method
|
|
12
|
-
5. **Set each chosen app up**, one after the other, as the playbook's steps 3 and 4 say for that app alone, `appKey` on every write and check (`preview`, `test-accounts`, `latest-pull-check`, `connection-check`): its method by the case that applies to it (
|
|
13
|
-
6. **The pull requests** as pull-requests says, once for the repository: the setup commit takes the state's one workflow `file` (a job for each chosen
|
|
11
|
+
4. **Read the state again** with impeccable_state. Done when the preview item's `apps` names each chosen app's `directory`, `build` and `method` (none yet for an app not set up), and `perApp` its status.
|
|
12
|
+
5. **Set each chosen app up**, one after the other, as the playbook's steps 3 and 4 say for that app alone, `appKey` on every write and check (`preview`, `test-accounts`, `latest-pull-check`, `connection-check`): its method by the case that applies to it (server-previews when its pages need a server, static-site when they do not, a host's case when the host deploys that app as a project of its own, proven on that app's own address), and its own sign-in when the scan item's `accessByApp` says it has one, each account question's title naming the app (*"Order desk admin account"*). Done when every chosen app's method is saved, or its preview skipped as server-previews says, and its sign-in saved or skipped, and no other app got a write, a build or a question.
|
|
13
|
+
6. **The pull requests** as pull-requests says, once for the repository: the setup commit takes the state's one workflow `file` (a job for each chosen static site whose preview Impeccable builds) once every chosen app's method and build settings are saved, or its preview skipped as server-previews says; the test drive adds its page to every chosen app with a preview (an app whose preview was skipped gets none, and goes under "What was skipped, and why"), in that app's own framework and linked from that app's own public page, so one review renders each of them on its own preview; that case's step 3 proves each chosen app with the input that proved it; its step 6 checks once, with the `appKey` of the first chosen app with a preview, and the other apps' previews go under "What is not verified". Done when pull-requests' steps are done.
|
|
14
|
+
- **A chosen app whose preview was skipped** (server-previews): it stays on, with no method, no job in the workflow and no test drive page. It holds nothing back: the other apps' setup commit, test drive and checks go on as above, and its checkup entry left in `next` counts as done for the playbook's step 5. The report says, on its line and under "What was skipped, and why", that it gets no preview until its host deploys them.
|
|
14
15
|
- **A host previews some chosen apps and Impeccable builds the others**: a host's case speaks for its own app. The setup commit carries the workflow for the others and the test drive branches from it, and pull-requests' step 4 goes each app's way: the host's app proven on the host's build, the others by the workflow. The repository goes without a workflow and a setup pull request only where a host previews every chosen app.
|
|
15
16
|
7. **Report per app**: under "What was configured, and by whom", one line per app the question offered: on or off and why (picked, unticked, the scan's default after a skip), its method, its sign-in, what was verified; an app left out is under "What was skipped, and why" as well, with the setup page as where it is turned on. Done when every app is on one line.
|
|
16
17
|
|
|
@@ -10,8 +10,8 @@ Netlify deploys previews of the pull requests: impeccable_netlify_previews lists
|
|
|
10
10
|
### Reference
|
|
11
11
|
|
|
12
12
|
- **Behind a password or Netlify's login** (`gate: "netlify-protection"`): the site password, or Netlify's team login, stands in front of the previews. That is the preview-protection case's, whose steps come before step 2: the password is got past on the person's yes, the team login is not.
|
|
13
|
-
- **Another app, or no answer from any of the three**: Netlify's previews do not show this app. Set the preview up as
|
|
14
|
-
- **Nothing listed**: Netlify's previews are not on record. Where the checkout is set up for Netlify that is the netlify-setup case's; else set the preview up as
|
|
13
|
+
- **Another app, or no answer from any of the three**: Netlify's previews do not show this app. Set the preview up as server-previews or static-site says instead, and say under "What is not verified" what the Netlify address answered.
|
|
14
|
+
- **Nothing listed**: Netlify's previews are not on record. Where the checkout is set up for Netlify that is the netlify-setup case's; else set the preview up as server-previews or static-site says. An `error` means a read failed, so a preview may exist that is not listed: say so under "What is not verified".
|
|
15
15
|
- **The pull requests**: no setup pull request; the test drive branches from the default branch. Netlify builds a deploy preview only for a pull request, so the test drive opens as a draft, its deploy preview is waited for and the test drive's page proven on it, and then the draft is marked ready: pull-requests' step 4. In place of pull-requests' step 3, build the test drive branch once with the build Netlify runs (`netlify.toml`'s `[build]` `command`, else the package's `build` script), so a page that does not build is never pushed.
|
|
16
16
|
- The evidence is read only: link, deploy and change nothing on Netlify. Netlify does not report its previews as GitHub deployments, so Impeccable finds them by their address pattern: save the pattern the proof answered, and no other.
|
|
17
17
|
- The report names the address that answered with this app under "What we found", and the pattern saved under "What was configured, and by whom". The test drive's own preview, which Netlify builds after the run, goes under "What is not verified".
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
### When this applies
|
|
2
2
|
|
|
3
|
-
The checkout is set up for Netlify: a `netlify.toml`, a `.netlify/` folder, or a package script that runs the `netlify` command (a Netlify package alone, `@netlify/*` or `netlify-cli`, says nothing of where the repository deploys). And no pull request has a Netlify deploy preview: impeccable_netlify_previews lists none, and no `error` (an error says nothing either way). These steps replace the playbook's step 3. As in netlify-previews, the repository needs no workflow and no setup pull request.
|
|
3
|
+
The checkout is set up for Netlify: a `netlify.toml`, a `.netlify/` folder, or a package script that runs the `netlify` command (a Netlify package alone, `@netlify/*` or `netlify-cli`, says nothing of where the repository deploys); or the person picked Netlify in server-previews. And no pull request has a Netlify deploy preview: impeccable_netlify_previews lists none, and no `error` (an error says nothing either way). These steps replace the playbook's step 3. As in netlify-previews, the repository needs no workflow and no setup pull request.
|
|
4
4
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
7
7
|
1. **Sign in**, before any other preview work, with impeccable_ask: `kind: "confirm"`, `id: "netlify-setup"`. The tool does the rest: it shows the sign-in screen only when the Netlify CLI is signed out (a person signed in already is asked nothing), runs the login in the browser and offers a retry. Done when it answered `confirmed`.
|
|
8
8
|
- **`cli: "missing"`**: install the CLI with the person's package manager (`npm install -g netlify-cli`, `pnpm add -g netlify-cli`, `yarn global add netlify-cli` or `bun add -g netlify-cli`), then ask again: the screen shows no second time.
|
|
9
9
|
- **`teams` in the answer** (the person has several Netlify teams): ask once which owns the site, `kind: "single"`, `id: "netlify-team"`, `options` the teams as given (the tool words the rest), and pass the pick to `sites:create` as `--account-slug`.
|
|
10
|
-
- **Skip**: set the preview up as
|
|
10
|
+
- **Skip**: set the preview up as server-previews or static-site says, and say under "What was skipped, and why" that Netlify was found and the sign-in was skipped. The steps below do not apply.
|
|
11
11
|
2. **Link or create the site**: `netlify link --git-remote-url <the GitHub origin>` links a site Netlify already builds from this repository; when it finds none, `netlify sites:create --name <name>` creates one in the person's team and links this folder. Name it after the repository: lowercase letters, digits and hyphens, at most 40 characters; a name Netlify says is taken gets `-2`. Done when `netlify status` names the linked project.
|
|
12
12
|
3. **Connect the repository** with impeccable_netlify_connect: it connects the linked site to this repository through Netlify's GitHub app, so pull requests build deploy previews, and asks the person for the app's access when Netlify cannot read the repository. Give `command` and `publish` only where no `netlify.toml` sets the build. Done when it answered `connected: true`; a site `netlify link` found is connected already, and it says so.
|
|
13
13
|
4. **Build and push the test drive** (the Test drive task) as pull-requests' steps 2 to 4 say, with the changes netlify-previews' reference gives: from the default branch, no setup pull request, built once with the build Netlify runs. Stage its files by name: `netlify sites:create` writes `.netlify/` and may add it to `.gitignore`, and that change stays out of the pull request. Done when `git push -u origin impeccable/test-drive` succeeded.
|
|
@@ -15,7 +15,7 @@ The checkout is set up for Netlify: a `netlify.toml`, a `.netlify/` folder, or a
|
|
|
15
15
|
|
|
16
16
|
### Reference
|
|
17
17
|
|
|
18
|
-
- **A step fails before the test drive is pushed** (the sign-in skipped or not completed, the site not created, the repository not connected): the Netlify setup ends there. Set the preview up as
|
|
18
|
+
- **A step fails before the test drive is pushed** (the sign-in skipped or not completed, the site not created, the repository not connected): the Netlify setup ends there. Set the preview up as server-previews or static-site says, say under "What was skipped, and why" what stopped it, and put the person's step in `left` when there is one, such as "Give Netlify's GitHub app access to the repository", with the `access` page in the report.
|
|
19
19
|
- **No preview for the draft**: save no method, set up no other preview, and leave the draft as it is, unrecorded, as pull-requests' "No preview, no test drive" says; the site and the connection stay, and the report names them.
|
|
20
20
|
- **The build failed** (`state` is `error` or `failure`): say so under "What is not verified", with the command that builds this linked checkout as Netlify does and prints why, for the person to run: `netlify deploy --build --context deploy-preview`. It never runs here, since it builds with their Netlify sign-in.
|
|
21
21
|
- **No deploy preview within five minutes**: the build had not finished, or Netlify builds no deploy preview for pull requests here: Deploy Previews may be off in the site's settings, so put "Turn Deploy Previews on in the site's Netlify settings" in `left`.
|
|
@@ -6,11 +6,11 @@ Every run, once the settings are saved. The person merges the setup pull request
|
|
|
6
6
|
|
|
7
7
|
1. **The setup commit**, when the repository needs a change to keep previews working: the workflow and any fix the build needed, nothing else. With the workflow current on the default branch and no fix, there is none. Check access as workflow-as-person says, branch `impeccable/setup` from `origin/<defaultBranch>`, and bring in the workflow and commit as that case says. Push it in step 7, after the test drive. Done when that case's steps are done.
|
|
8
8
|
2. **The test drive page**, always. The test drive shows the person a whole review: pages to capture, a finding for each measure, and a new page, which is what starts the review's visitor sessions. Branch `impeccable/test-drive` from the setup commit, so it carries the same workflow and its preview runs from its own head (from the default branch when there is no setup commit). Add one small, self-contained page in the repository's own framework, such as `/impeccable-test-drive`: a file where the routing finds pages, plus its styles, with a heading, a paragraph and a few elements in the app's own words (so the review judges it as one of the app's pages), and the flaws below. Link it from a page **a visitor opens without signing in**, the landing page when it is one, and keep the new page open without signing in too: no guard, on the server or in the browser, sends it to the sign-in. Done when the new page carries every flaw below with its comment, and the page you picked links to it.
|
|
9
|
-
3. **Prove both pages** with impeccable_verify_preview, for a preview Impeccable builds itself (
|
|
9
|
+
3. **Prove both pages** with impeccable_verify_preview, for a preview Impeccable builds itself (static-site): the input that proved the preview, with both pages in `entries`. A host's preview (vercel-previews, vercel-setup, netlify-previews, netlify-setup) is proven on the host's own build in step 4 instead; its case says how the branch is built here first. Done when it answers `verified: true`, or the preview is a host's.
|
|
10
10
|
4. **Push the test drive and prove its preview**: commit, `git push -u origin impeccable/test-drive`, then the way its preview comes:
|
|
11
11
|
- **Vercel** (vercel-previews, vercel-setup) builds every push: impeccable_hosted_previews with `branch: "impeccable/test-drive"` waits up to five minutes for its deployment of the pushed head, under a status line with its timer. Prove the test drive's page on that address with impeccable_prove_preview (past Vercel's login as preview-protection says), and save the method as the case says. Then step 5.
|
|
12
12
|
- **Netlify** (netlify-previews, netlify-setup) builds a deploy preview only for a pull request: open the test drive now as a **draft**, step 5's `gh pr create` with `--draft`, which Impeccable leaves unreviewed. impeccable_netlify_previews with `pull` the number it printed waits up to five minutes for the deploy preview, under a status line with its timer. Prove the test drive's page on it, save the method as the case says, then `gh pr ready impeccable/test-drive --repo <owner>/<repo>` marks it ready, which starts the review. Then step 5's recording.
|
|
13
|
-
- **Impeccable's own preview** (
|
|
13
|
+
- **Impeccable's own preview** (static-site): the workflow in the branch builds it on the pull request, and Impeccable's review holds up to eight minutes for that job before it renders, so step 3's proof and the workflow in the branch (from the setup commit) are all this step takes. Then step 5.
|
|
14
14
|
|
|
15
15
|
Done when the test drive's page is proven on its preview, and a host's method saved; when nothing proved it, the reference below says how the run goes on.
|
|
16
16
|
5. **Open the test drive and record it**: `gh pr create --repo <owner>/<repo> --head impeccable/test-drive --base <defaultBranch>` with the title "Impeccable test drive (safe to close)" and a body that starts "This pull request only shows Impeccable reviewing a change. Close it when you are done; nothing here needs to be merged." and then names the two pages and lists the flaws planted for the review to find, one line each (a Netlify draft opened this way in step 4, and was marked ready there). Record it right away with impeccable_test_pr, so the web page follows its review; it refuses a draft. Done when impeccable_test_pr answered "Recorded".
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
### When this applies
|
|
2
|
+
|
|
3
|
+
The pages need a server from this repository (SSR, API routes, or a backend the front end calls; a front end whose bundle builds on its own still needs its backend here), and no host's previews show them: no host case applied, or one ended without a preview. Impeccable no longer runs a server in CI for a new setup, so such pages are previewed only by their host's pull request previews. These steps replace the playbook's step 3.
|
|
4
|
+
|
|
5
|
+
### Steps
|
|
6
|
+
|
|
7
|
+
1. **Ask where the previews come from**, once, with impeccable_ask: `id: "preview-host"`, `kind: "single"`, `title` *"Pull request previews"*, `prompt` *"Which host should deploy a preview of each pull request?"*, `hint` *"Impeccable reviews this app on its host's previews, and no longer runs its server itself."*, and `options` Vercel (`vercel`) and Netlify (`netlify`). Done when it answered a value, or a skip.
|
|
8
|
+
- **Several apps**: ask once for each app this case applies to, the title naming the app (*"Order desk previews"*).
|
|
9
|
+
- **Asked already for this app in this run**, or a host's setup for it ended without a preview (the sign-in skipped, a step failed): ask nothing, and go on as after a skip.
|
|
10
|
+
2. **Set the host up**: on `vercel`, as the vercel-setup case says, and on `netlify`, as the netlify-setup case says. The pick stands in for that case's "When this applies": its steps run whatever the checkout holds for the host, and even where the host already deploys previews that showed another app or did not answer, since the test drive's own preview, proven on that host, decides. Done when that case's steps are done.
|
|
11
|
+
3. **After a skip**: no preview, no test drive. Save no method and write no workflow for these pages, and open no test drive, as pull-requests' "No preview, no test drive" says. Mark the Preview method and Test drive tasks failed, say under "What was skipped, and why" that the pages need a server and no host deploys previews of them, and put "Turn on your host's pull request previews, then run the setup again" in `left`, naming Vercel, Netlify and Cloudflare in the report. The checkup's entry for this preview then stays in `next`, which counts as done for the playbook's step 5. With several apps this holds for the skipped app alone: the other chosen apps' previews and the test drive go on as monorepo says, and the tasks fail only where no app has a preview. Done when the report says so.
|
|
12
|
+
|
|
13
|
+
### Reference
|
|
14
|
+
|
|
15
|
+
- **Already on Impeccable's tunnel**: a repository whose state shows `ci-tunnel` as the preview's method, with its workflow committed, keeps working as it is. Change nothing about that preview: a host the person picks here replaces it once its preview is saved, and after a skip the tunnel stays, which the report says in place of the step for `left`.
|
|
16
|
+
- **Never save `ci-tunnel`**: impeccable_write refuses it.
|
|
17
|
+
- The sign-in case applies all the same: the accounts serve the host's previews once they come.
|
|
@@ -18,4 +18,3 @@ The app has its own sign-in: a form that takes a username or an email and a pass
|
|
|
18
18
|
- impeccable_ask and impeccable_write are the terminal prompt the instructions' `signIn` describes: the person types each password hidden, and it goes straight to `test-accounts`.
|
|
19
19
|
- **A password is only the reference impeccable_ask returned**; impeccable_write refuses any other, so a password in the seed, a README or an env file stays where it is. When the seed sets one, name its file in the account question's hint and let the person type it.
|
|
20
20
|
- `loginPage` is the login form's path when the code names one, such as `/login`.
|
|
21
|
-
- A `ci-tunnel` preview runs the seed, so the seed's accounts exist there.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
### When this applies
|
|
2
2
|
|
|
3
|
-
The pages need no server from this repository: plain HTML, or a build that writes a complete static site. Pages that need SSR, API routes or a backend here follow
|
|
3
|
+
The pages need no server from this repository: plain HTML, or a build that writes a complete static site. Pages that need SSR, API routes or a backend here follow server-previews; an external API they call does not change that. A build that writes only JavaScript, CSS or types is a library, not a site.
|
|
4
4
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
@@ -10,7 +10,7 @@ Vercel deploys previews of the pull requests: impeccable_hosted_previews lists a
|
|
|
10
10
|
### Reference
|
|
11
11
|
|
|
12
12
|
- **Behind Vercel's login** (`gate: "vercel-protection"`): Deployment Protection stands in front of the previews. Get past it as preview-protection says, which proves the preview, and go on with step 2; when nothing got past it, that case says how the run ends.
|
|
13
|
-
- **Another app, or no answer from any of the three**: Vercel's previews do not show this app. Set the preview up as
|
|
13
|
+
- **Another app, or no answer from any of the three**: Vercel's previews do not show this app. Set the preview up as server-previews or static-site says instead, and say under "What is not verified" what the Vercel address answered.
|
|
14
14
|
- **The pull requests**: no setup pull request; the test drive branches from the default branch, and Vercel builds its preview from the push, which pull-requests' step 4 waits for and proves the test drive's page on before the pull request opens. In place of pull-requests' step 3, build the test drive branch once with the install and build Vercel runs (`vercel.json`'s `installCommand` and `buildCommand`, else the package's), as plain commands (`pnpm install`, then `pnpm run build`) and not with impeccable_verify_preview, which is for a preview Impeccable starts itself, so a page that does not build is never pushed.
|
|
15
15
|
- The evidence is GitHub's: link, deploy and change nothing on Vercel, and save no address pattern, since Vercel reports each preview's address to GitHub itself.
|
|
16
16
|
- The report names the address that answered with this app under "What we found", and puts the test drive's own preview under "What is not verified".
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
### When this applies
|
|
2
2
|
|
|
3
|
-
The checkout is set up for Vercel (`vercel.json`, `.vercel/project.json`, a `vercel` dependency or script) and Vercel has never deployed it: impeccable_hosted_previews lists no deployment that Vercel made (`vercel: true`), and no `error` (an error says nothing either way). These steps replace the playbook's step 3. As in vercel-previews, the repository needs no workflow and no setup pull request.
|
|
3
|
+
The checkout is set up for Vercel (`vercel.json`, `.vercel/project.json`, a `vercel` dependency or script), or the person picked Vercel in server-previews, and Vercel has never deployed it: impeccable_hosted_previews lists no deployment that Vercel made (`vercel: true`), and no `error` (an error says nothing either way). These steps replace the playbook's step 3. As in vercel-previews, the repository needs no workflow and no setup pull request.
|
|
4
4
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ The checkout is set up for Vercel (`vercel.json`, `.vercel/project.json`, a `ver
|
|
|
8
8
|
- **`cli: "missing"`**: install the CLI with the person's package manager (`npm install -g vercel`, `pnpm add -g vercel`, `yarn global add vercel` or `bun add -g vercel`), then ask again: the screen shows no second time.
|
|
9
9
|
- **`teams` in the answer instead of `team`** (several teams, none current): ask once which owns the project, `kind: "single"`, `id: "vercel-team"`, `options` the teams as given (the tool words the rest). The pick is the team.
|
|
10
10
|
- **Neither `team` nor `teams`**: the account has no team, and `vercel link --yes` without `--team` links in the person's own scope.
|
|
11
|
-
- **Skip**: set the preview up as
|
|
11
|
+
- **Skip**: set the preview up as server-previews or static-site says, and say under "What was skipped, and why" that Vercel was found and the sign-in was skipped. The steps below do not apply.
|
|
12
12
|
2. **Create the project** (run each Vercel command plain: its answer is short, and a pipe to `tail` is refused): `vercel link --yes --team <the team>` creates the project in that team, named after the folder (or links the one that has that name), and connects the GitHub `origin`; the CLI names no team on its own without a terminal, so the option is given whenever there is a team. Done when it prints the linked project.
|
|
13
13
|
3. **Connect the repository, and lock the project before its first deployment**, with impeccable_vercel_connect: it runs the connection on the linked project, asks the person for Vercel's GitHub app's access when the app cannot reach the repository, and then, on a project with no production deployment, puts Vercel's login in front of every address of the project, its own included. Vercel makes a new project's first deployment Production whatever branch it builds and serves it at the project's own address, and the test drive's push is that first deployment. Done when it answered `connected: true` with `production: "exists"` or `locked: true`; a project connected by the link already is, and it says so.
|
|
14
14
|
4. **Build and push the test drive** (the Test drive task) as pull-requests' steps 2 to 4 say, with the changes vercel-previews' reference gives: from the default branch, no setup pull request, built once with plain commands. Stage its files by name: `vercel link` writes `.vercel/` and a `.env.local` that holds a Vercel token, and may add `.vercel` to `.gitignore`, and none of that goes into the pull request. Its push starts the project's first deployment. Done when `git push -u origin impeccable/test-drive` succeeded.
|
|
@@ -16,7 +16,7 @@ The checkout is set up for Vercel (`vercel.json`, `.vercel/project.json`, a `ver
|
|
|
16
16
|
|
|
17
17
|
### Reference
|
|
18
18
|
|
|
19
|
-
- **A step fails before the test drive is pushed** (the sign-in skipped or not completed, the team skipped, the project not created, the repository not connected): the Vercel setup ends there. Set the preview up as
|
|
19
|
+
- **A step fails before the test drive is pushed** (the sign-in skipped or not completed, the team skipped, the project not created, the repository not connected): the Vercel setup ends there. Set the preview up as server-previews or static-site says, say under "What was skipped, and why" what stopped it, and put the person's step in `left` when there is one, such as "Give Vercel's GitHub app access to the repository", with the `access` page in the report.
|
|
20
20
|
- **A push before step 3** is refused, since a connected project builds every push: run impeccable_vercel_connect first.
|
|
21
21
|
- **`locked: false`** from step 3, with `production` not "exists": push nothing, and set up no other preview either, since the connected project would build that push as its first deployment, public at its own address. Mark the Test drive task failed, say under "What was skipped, and why" what stopped the lock (the answer's `reason`) and how the person locks the project (Vercel Authentication for All Deployments, under the project's Settings → Deployment Protection), and put "Lock the Vercel project, then run the setup again" in `left`.
|
|
22
22
|
- **No preview for the pushed test drive**: save no method, set up no other preview and open no pull request, as pull-requests' "No preview, no test drive" says; the project and the connection stay, and the report names them.
|
package/dist/playbook.js
CHANGED
|
@@ -12,13 +12,13 @@ export function loadCases(directory = new URL('./playbook/cases/', import.meta.u
|
|
|
12
12
|
}
|
|
13
13
|
/** The system prompt: the setup, its steps, then the reference and the cases. The instructions JSON carries every write shape and the fence decides what runs; this restates neither. */
|
|
14
14
|
export function playbook(repo) {
|
|
15
|
-
return `You are Impeccable's setup agent, in a checkout of ${repo.owner}/${repo.name} on the person's own machine. This setup covers one situation: Impeccable previews the repository's web front end, taking the previews its host deploys for pull requests (vercel-previews, netlify-previews, cloudflare-previews, or vercel-setup and netlify-setup, which turn them on) or building it itself through the workflow the setup state generates (\`
|
|
15
|
+
return `You are Impeccable's setup agent, in a checkout of ${repo.owner}/${repo.name} on the person's own machine. This setup covers one situation: Impeccable previews the repository's web front end, taking the previews its host deploys for pull requests (vercel-previews, netlify-previews, cloudflare-previews, or vercel-setup and netlify-setup, which turn them on) or, for a static site, building it itself through the workflow the setup state generates (\`static-upload\`), and signs in with the app's own username-or-email-and-password accounts when it has a sign-in. You read files and run commands here, save Impeccable's settings with the impeccable_* tools, and open pull requests as the person. The person watches this terminal, and a web page mirrors your tasks and the report.
|
|
16
16
|
|
|
17
17
|
# Steps
|
|
18
18
|
|
|
19
19
|
1. **Read the state.** The setup API's instructions and state came with the first message, trimmed to this setup; impeccable_state reads the state again. Done when you know the preview item's status, \`file\` and \`workflowStanding\`, whether there is a \`signin\` item, whether there is an \`apps\` item (the monorepo case comes first then), and the titles in \`notPartOfThisSetup\`.
|
|
20
20
|
2. **Read the repository**: manifests, the lockfile, scripts, framework config, CI workflows, env examples, the routes, the auth code and the seed. Done when you can name the package manager, the install, build and start commands, whether the pages need a server, how people sign in, and which pages a visitor opens without signing in.
|
|
21
|
-
3. **Set up the preview** as the vercel-previews case says when impeccable_hosted_previews lists Vercel's previews of the pull requests, as the netlify-previews case says when impeccable_netlify_previews lists Netlify's (and as preview-protection says when the host's login or password stands in front of either), as the cloudflare-previews case says when impeccable_cloudflare_previews lists Cloudflare Pages', as the vercel-setup case says when the checkout is set up for Vercel and Vercel has never deployed it, as the netlify-setup case says when the checkout is set up for Netlify and it lists none, else as the
|
|
21
|
+
3. **Set up the preview** as the vercel-previews case says when impeccable_hosted_previews lists Vercel's previews of the pull requests, as the netlify-previews case says when impeccable_netlify_previews lists Netlify's (and as preview-protection says when the host's login or password stands in front of either), as the cloudflare-previews case says when impeccable_cloudflare_previews lists Cloudflare Pages', as the vercel-setup case says when the checkout is set up for Vercel and Vercel has never deployed it, as the netlify-setup case says when the checkout is set up for Netlify and it lists none, else as the server-previews case says when the pages need a server, and as the static-site case says when they do not. Done when that case's steps are done.
|
|
22
22
|
4. **Set up the sign-in** as the sign-in case says, when the app has its own. Done when that case's steps are done.
|
|
23
23
|
5. **Run impeccable_checkup**, do what \`next\` says, and run it again. Done when \`status\` is complete, or all that is left is in \`forPerson\` or \`waitingFor\`.
|
|
24
24
|
6. **Open the pull requests** as the pull-requests case says. What waits on the person holds neither back. Done when that case's steps are done.
|
package/dist/tools.js
CHANGED
|
@@ -1755,7 +1755,7 @@ export function createTools(ctx) {
|
|
|
1755
1755
|
return [
|
|
1756
1756
|
tool('impeccable_workflow_access', 'Check workflow access', 'Check that the setup pull request can carry the workflow: reads the active GitHub CLI account and its scopes, the origin remote and the default branch, and refreshes origin/<defaultBranch>. Call it before branching. needsWorkflowScope true means the account lacks workflow access over HTTPS; base.status "refreshed" means origin/<defaultBranch> is current. Only the push itself proves Git may push.', NO_INPUT, (_p, pi, signal) => workflowAccessTool(pi, signal)),
|
|
1757
1757
|
tool('impeccable_sync_workflow', 'Update the preview workflow', 'Write the state\'s workflow to .github/workflows/impeccable-preview.yml byte for byte and remove the retired workflow the state names, after checking access as impeccable_workflow_access does. Call it once the settings are saved, and again after committing: stagedMatches and committedMatches true (and retiredRemoval staged and committed) prove the commit holds the exact bytes; null means nothing was compared. It refuses, changing nothing, when the file has the person\'s local edits. It never branches, stages, commits or pushes.', NO_INPUT, (_p, pi, signal) => syncWorkflowTool(pi, signal)),
|
|
1758
|
-
tool('impeccable_verify_preview', 'Check the local preview', 'Prove the preview on this machine before saving it: runs the steps in a throwaway copy of the checkout, with a throwaway database, and removes everything afterwards. For a server give start, port, paths and host: it starts the app and requests each path as localhost and as the tunnel host. For a static site give output: it checks the build wrote the entry pages. verified covers HTTP answers and files only, never rendering, sign-in or the CI runtime; a failure names its stage.', PREVIEW_VERIFICATION, async (p, pi, signal) => JSON.stringify(await verifyLocalPreview(p, pi.cwd, signal, ctx.sandbox))),
|
|
1758
|
+
tool('impeccable_verify_preview', 'Check the local preview', 'Prove the preview on this machine before saving it: runs the steps in a throwaway copy of the checkout, with a throwaway database, and removes everything afterwards. For a server already on Impeccable\'s tunnel give start, port, paths and host: it starts the app and requests each path as localhost and as the tunnel host. For a static site give output: it checks the build wrote the entry pages. verified covers HTTP answers and files only, never rendering, sign-in or the CI runtime; a failure names its stage.', PREVIEW_VERIFICATION, async (p, pi, signal) => JSON.stringify(await verifyLocalPreview(p, pi.cwd, signal, ctx.sandbox))),
|
|
1759
1759
|
tool('impeccable_preview_run', 'Read the preview attempt', 'On a rerun, once impeccable_test_pr recorded the setup pull request: read its preview workflow\'s runs. Answers each attempt with its failed stage and a fixed reason (never log text), the three-attempt budget (used, remaining, historyKnown), and adds the attempts to the task list. Reading is free.', PREVIEW_RUN, async (p, pi, signal) => {
|
|
1760
1760
|
if (!retry.pr)
|
|
1761
1761
|
return 'Refused: record the setup pull request with impeccable_test_pr first.';
|
|
@@ -1832,6 +1832,9 @@ export function createTools(ctx) {
|
|
|
1832
1832
|
return `Refused: ${target.error}`;
|
|
1833
1833
|
if (/\/(preview-auth\/[^/]+|preview-protection)$/.test(target.url))
|
|
1834
1834
|
return "Refused: a host's key is saved by impeccable_vercel_bypass (a Vercel bypass secret), impeccable_netlify_password (a Netlify site password) or impeccable_cloudflare_access (a Cloudflare Access service token), which opens the preview with it first, and never through this tool. Nothing was saved.";
|
|
1835
|
+
if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'ci-tunnel') {
|
|
1836
|
+
return "Refused: Impeccable no longer runs a server in CI for a new setup, so ci-tunnel is never saved. Pages that need a server get their host's pull request previews, as the server-previews case says. Nothing was saved.";
|
|
1837
|
+
}
|
|
1835
1838
|
if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'preview-deploy' && !hostedPreviewProven) {
|
|
1836
1839
|
return "Refused: prove a preview address with impeccable_prove_preview first: the host's previews become the method only once one answered with this app. Nothing was saved.";
|
|
1837
1840
|
}
|
|
@@ -1855,7 +1858,7 @@ export function createTools(ctx) {
|
|
|
1855
1858
|
const response = await call(pi, target.url, { method: target.method, body });
|
|
1856
1859
|
if (target.url.endsWith('/preview') && response.ok) {
|
|
1857
1860
|
const keys = object(object(response.data)?.keys);
|
|
1858
|
-
if (keys?.intendedMethod === 'stored' &&
|
|
1861
|
+
if (keys?.intendedMethod === 'stored' && object(p.body)?.intendedMethod === 'static-upload')
|
|
1859
1862
|
retry.requireHistory = true;
|
|
1860
1863
|
if (object(p.body)?.intendedMethod === 'preview-deploy')
|
|
1861
1864
|
savedHost = provenHost;
|
package/package.json
CHANGED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
### When this applies
|
|
2
|
-
|
|
3
|
-
The pages need a server from this repository: SSR, API routes, or a backend the front end calls. A front end whose bundle builds on its own still needs its backend here.
|
|
4
|
-
|
|
5
|
-
### Steps
|
|
6
|
-
|
|
7
|
-
1. **Work out the commands** in the workflow's order: install, prepare, build, seed and start, each with the folder it runs in. Done when each has a command, or none, and a folder.
|
|
8
|
-
2. **Prove them** with impeccable_verify_preview in server mode. Done when it answers `verified: true`; a failure names its `stage`, which the report explains, and pull-requests' "No preview, no test drive" says how the run goes on without a proof.
|
|
9
|
-
3. **Save them** with impeccable_write to `preview`: `intendedMethod: "ci-tunnel"` and the settings you proved, from the keys `buildSettings` lists. Done when the answer's `keys` show each key stored or unchanged.
|
|
10
|
-
|
|
11
|
-
### Reference
|
|
12
|
-
|
|
13
|
-
- **Seed and migrate only a throwaway database**, never a shared or production one:
|
|
14
|
-
- **SQLite**: a relative file URL in `env` (`DATABASE_URL=file:./preview.db`), and the repository's schema setup as the prepare or seed step.
|
|
15
|
-
- **Postgres or MySQL**: `service`, which gives the steps their own `DATABASE_URL`.
|
|
16
|
-
- **Cloudflare D1**: `--local` migrations and seeds.
|
|
17
|
-
- **The tunnel host refused** (localhost answers, the tunnel host gets a 403): allow the preview host in the framework's host check (Rails `config.hosts`, Django `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS`, Vite `preview.allowedHosts`) for previews only, in the setup pull request; keep the check on.
|
|
18
|
-
- **Readiness**: the job waits for `/` to answer. When only a base path answers, report it as a blocker: no setting points it elsewhere.
|
|
19
|
-
- **Runtime**: compare `node --version` (or python, ruby, pnpm) with the repository's pins; the job sets its runtime from `runtimeVersion`, so a mismatch goes under "What is not verified".
|
|
20
|
-
- **Env**: literal, non-secret values. A secret the app needs is `${{ secrets.NAME }}` in the saved `env`, and "Add the NAME repository secret" in `left`. A demo or test-login flag belongs in the preview's env only.
|
|
21
|
-
- A verified result covers HTTP answers only: rendering and the CI runtime go under "What is not verified".
|
|
@@ -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
|
-
}
|