@impeccable/reviews 0.1.8 → 0.1.9
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 +1 -1
- package/dist/bin.js +0 -0
- package/dist/host-questions.js +10 -0
- package/dist/main.js +8 -5
- package/dist/playbook/cases/monorepo.md +4 -4
- package/dist/playbook/cases/preview-protection.md +1 -1
- package/dist/playbook/cases/vercel-previews.md +2 -0
- package/dist/playbook/cases/vercel-setup.md +1 -1
- package/dist/tools.js +149 -19
- package/package.json +1 -1
- package/dist/playbook/github-previews.js +0 -101
- package/dist/playbook/netlify-pattern.js +0 -24
- package/dist/playbook/netlify-previews.js +0 -144
- package/dist/playbook/probe-preview.js +0 -67
- package/dist/playbook/protection-secrets.js +0 -15
- package/dist/playbook/public-preview-request.js +0 -96
- package/dist/playbook/publication-wait.js +0 -46
- package/dist/playbook/vercel-protection.js +0 -121
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Keys: Enter goes on, Esc cancels the intro or the sign-in and skips a question (
|
|
|
24
24
|
| Flag | Default | What it does |
|
|
25
25
|
| --- | --- | --- |
|
|
26
26
|
| `--install-dir <path>` | the folder the command is run in | The folder to set up, a checkout of the repository or a folder inside one, so the command runs from anywhere: `npx @impeccable/reviews --install-dir ~/code/your-repo`. A relative path is taken from the folder the command is run in; `IMPECCABLE_REVIEWS_INSTALL_DIR` does the same when the option is absent. |
|
|
27
|
-
| `--app <key>` | asked | On a repository with several apps, sets up that one app, by its key (such as `storefront`),
|
|
27
|
+
| `--app <key>` | asked | On a repository with several apps, sets up that one app, by its key (such as `storefront`): one confirm screen names it instead of the list of apps, and only that app is touched. Without it, every run asks, with the apps that are on already picked. |
|
|
28
28
|
| `--host <origin>` | `https://impeccable.reviews` | Impeccable's address. Plain `http` only for localhost. |
|
|
29
29
|
| `--model <id>` | the model the service hands out (Sonnet 5.5 today) | The model the setup agent runs on, through Impeccable's gateway. |
|
|
30
30
|
| `--effort <level>` | `high` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` or `max`. |
|
package/dist/bin.js
CHANGED
|
File without changes
|
package/dist/host-questions.js
CHANGED
|
@@ -234,6 +234,16 @@ export const APPS_QUESTION = {
|
|
|
234
234
|
};
|
|
235
235
|
/** The apps question's hint when the command was started below the repository's top: `where` is an app's folder ("Tally Board's folder") or the folder itself. */
|
|
236
236
|
export const startedIn = (where) => `You started in ${where}. Impeccable sets up from the top of the repository.`;
|
|
237
|
+
/** The apps question when the command was started for one app (`--app`): no list, one confirm screen naming that app, and the list behind Skip for someone who wants another app (#286). */
|
|
238
|
+
export const namedAppQuestion = (app) => ({
|
|
239
|
+
id: APPS_QUESTION.id,
|
|
240
|
+
kind: 'confirm',
|
|
241
|
+
title: APPS_QUESTION.title,
|
|
242
|
+
hint: `You're setting up ${app.name} (${app.folder || '/'}); the other apps stay as they are.`,
|
|
243
|
+
prompt: `Set up ${app.name}?`,
|
|
244
|
+
confirmLabel: `Set up ${app.name}`,
|
|
245
|
+
skipLabel: 'Pick other apps',
|
|
246
|
+
});
|
|
237
247
|
/** The ids whose screens the tool puts up itself, when it needs the person, and takes down: the web page follows those, not the model's call. */
|
|
238
248
|
export const TOOL_SHOWN = new Set(['netlify-setup', 'vercel-setup', APPS_QUESTION.id]);
|
|
239
249
|
/** A question as the person reads it: the fixed words for a host's id, else the model's own. */
|
package/dist/main.js
CHANGED
|
@@ -16,7 +16,7 @@ import { scanRecorder } from './scan.js';
|
|
|
16
16
|
import { createReporter, reportingOff } from './report.js';
|
|
17
17
|
import { createSessionSync, pageStatus, plainReport, syncExtension } from './sync.js';
|
|
18
18
|
import { checkPushAccess, readPushAccess } from './push-access.js';
|
|
19
|
-
import { createTools, ownPullRequest, resultLine } from './tools.js';
|
|
19
|
+
import { appsSummary, createTools, ownPullRequest, resultLine } from './tools.js';
|
|
20
20
|
import { transcriptPaths } from './transcript.js';
|
|
21
21
|
import { askPerson } from './ui/bridge.js';
|
|
22
22
|
import { atExit, closeScreens, helpMark, onScreenExit, recordFrames, showIntro, showOutro, showPullRequest, showRun, showSignIn } from './ui/screens.js';
|
|
@@ -235,14 +235,17 @@ export async function main(argv) {
|
|
|
235
235
|
const saved = await settle(liveSync.flush(), 5000);
|
|
236
236
|
const { testPr } = report;
|
|
237
237
|
const setupPr = shownSetupPr(report.setupPr);
|
|
238
|
+
// On a repository with several apps, "You're all set up" means every app this run picked is: one left unproven is named, with its one step (#286).
|
|
239
|
+
const apps = appsSummary(report.apps ?? []);
|
|
240
|
+
const allSetUp = Boolean(testPr) && !apps.notReady.length;
|
|
238
241
|
// One pull request a screen, each opened in the browser and put on the clipboard as its screen appears: the test drive, then the setup pull request.
|
|
239
242
|
const hand = { open: openBrowser, copy: copyText };
|
|
240
243
|
if (testPr && setupPr)
|
|
241
244
|
await showPullRequest(testPr.url, hand);
|
|
242
245
|
await showOutro({
|
|
243
|
-
outcome:
|
|
244
|
-
title:
|
|
245
|
-
result: resultLine({ ...report, setupPr }),
|
|
246
|
+
outcome: allSetUp ? 'done' : 'partial',
|
|
247
|
+
title: allSetUp ? "You're all set up" : apps.notReady.length ? `${apps.notReady.join(' and ')} ${apps.notReady.length === 1 ? 'is' : 'are'} not set up` : 'Settings saved',
|
|
248
|
+
result: [resultLine({ ...report, setupPr }), apps.line].filter(Boolean).join(' '),
|
|
246
249
|
link: (setupPr ?? testPr)?.url,
|
|
247
250
|
links: [
|
|
248
251
|
...(testPr ? [{ label: 'Test drive, watch the review there', url: testPr.url }] : []),
|
|
@@ -250,7 +253,7 @@ export async function main(argv) {
|
|
|
250
253
|
...(setupPr ? [{ label: testPr ? 'Setup pull request, merge this' : 'Setup pull request', url: setupPr.url }] : []),
|
|
251
254
|
{ label: 'Web page', url: page },
|
|
252
255
|
],
|
|
253
|
-
todo: report.left,
|
|
256
|
+
todo: [...apps.todo, ...report.left],
|
|
254
257
|
warnings: [...removed, ...(saved && !saved.ok ? [`The web page missed the last update (${saved.error}), so it may lag behind this.`] : [])],
|
|
255
258
|
}, hand);
|
|
256
259
|
// The web page has the report; when it missed it, the report stays here instead.
|
|
@@ -5,14 +5,14 @@ The state's repository has an `apps` item: the scan proposed several apps, folde
|
|
|
5
5
|
### Steps
|
|
6
6
|
|
|
7
7
|
1. **Confirm the proposal** as it stands, while the item's status is `needs-you`: impeccable_write to `apps` with the `confirm` action the item's instructions give. Its answer lists every app of the repository, on and off, with its `key`, `name`, `pathPrefixes` and `enabled`; the one the scan switched on is the repository's default. An item that is `done` or `skipped` was answered before (on the setup page, or by an earlier run): no confirm, and step 2 all the same. Done when it answered `apps`, or the item was answered before.
|
|
8
|
-
2. **Ask which apps this run sets up**, on every run, with impeccable_ask: `id: "apps"`, `kind: "multi"`, and the `prompt` *"Which apps should get Impeccable previews?"*. The tool words the question and lists the state's apps (the repository's own, `main`, only where the Worker kept it as a product of its own), each picked as it is on, or every one after this run's confirm. Mark the Apps task in progress first. Every write and host tool for an app is refused until it is answered. Done when it answered `values`, `named` (the command was started for that one app with `--app`,
|
|
8
|
+
2. **Ask which apps this run sets up**, on every run, with impeccable_ask: `id: "apps"`, `kind: "multi"`, and the `prompt` *"Which apps should get Impeccable previews?"*. The tool words the question and lists the state's apps (the repository's own, `main`, only where the Worker kept it as a product of its own), each picked as it is on, or every one after this run's confirm. Mark the Apps task in progress first. Every write and host tool for an app is refused until it is answered. Done when it answered `values`, `named` (the command was started for that one app with `--app`, from its card on the dashboard: the tool showed one confirm screen naming that app instead of the list, and the list all the same when the person asked for other apps there), or a skip, whose `on` lists the apps that are on: the ones this run sets up. After `named`, that app is the one this run sets up: every host read, key, proof, method and check is its own, and no other app is mentioned past that screen.
|
|
9
9
|
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 `named`**, `enable` that app when it is off, and switch no other off. **After Skip**, write nothing more: the apps that are on stay on. Done when the last answer's `apps` show `enabled` true for every chosen app and false for the rest (after `named`, true for that app), or, after Skip, nothing was written.
|
|
10
10
|
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.
|
|
11
|
-
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: impeccable_hosted_previews, impeccable_netlify_previews and impeccable_cloudflare_previews take its `appKey` and list only the previews of the project or site that builds its folder, so another app's address, project or key never stands in for it; their `unmatched` says why none can be told, and that app goes as server-previews or static-site says, with the reason under "What is not verified"), 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.
|
|
12
|
-
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
|
|
11
|
+
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: impeccable_hosted_previews, impeccable_netlify_previews and impeccable_cloudflare_previews take its `appKey` and list only the previews of the project or site that builds its folder, so another app's address, project or key never stands in for it; their `unmatched` says why none can be told, and that app goes as server-previews or static-site says, with the reason under "What is not verified"), 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"*). **The lock in front of each app's previews is that app's own**: when its proof answers a `gate`, preview-protection's steps run for that app, with its `appKey`, before any other app's step (the tools refuse the next app's reads and writes until its key tool was called), and the key goes to that app's own project or site; a key saved for one app says nothing about another's, and the state's `keySaved` speaks for one app at most. 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.
|
|
12
|
+
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 4 proves the test drive's page on each chosen app's own preview, with that app's key where its lock stands in front; its step 6 checks each chosen app with a preview, with its `appKey`, one call each. Done when pull-requests' steps are done.
|
|
13
13
|
- **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
14
|
- **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
|
-
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, named with `--app`, on already 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.
|
|
15
|
+
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, named with `--app`, on already 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. impeccable_report adds a section of its own, "Each app, as this run left it", from what the run saved and proved, and the last screen says "You're all set up" only when every chosen app is: an app whose preview met a lock with no key saved, or got no method, is named there with its one step, so the report's lines say the same. Done when every app is on one line.
|
|
16
16
|
|
|
17
17
|
### Reference
|
|
18
18
|
|
|
@@ -20,7 +20,7 @@ impeccable_prove_preview answered a `gate`: the host's own lock stands in front
|
|
|
20
20
|
|
|
21
21
|
- **The report** names, under "What was configured, and by whom", the Vercel project that holds the secret named "Impeccable reviews" (`project`), whether this run created it, found it there or the person typed it, and where it is revoked: the project's Settings → Deployment Protection → Protection Bypass for Automation; or the Netlify site (`site`) whose password is stored, whether this run read it from the site or the person typed it, and where it is changed: the site's Project configuration → General → Visitor access; or, with `opened`, the Netlify site made public, by this run on the person's yes or by the person on Netlify's page (`madePublic`), so anyone with its address opens it, and where it is made private again: the same Visitor access; or the service token named "Impeccable reviews", whether this run created it or the person typed it, the Access application whose policy allows it (`application`), when a created one runs out (`expires`), and where it is revoked: Zero Trust → Access controls → Service credentials → Service Tokens; with `apiToken`, the Cloudflare API token the person made for the run is theirs to delete (dash.cloudflare.com/profile/api-tokens). Revoking, regenerating or changing it stops reviews until a new one is saved on the setup page.
|
|
22
22
|
- **The tool does the writes**: the key as Impeccable's credential for the lock, and the setting that sends it in the host's header (`x-vercel-protection-bypass`, `Authorization`, or `CF-Access-Client-Id` and `CF-Access-Client-Secret`), both through the setup API, so impeccable_write refuses those two. The state's `preview-protection` item words the same calls, and shows `keySaved` once they are done.
|
|
23
|
-
- **A key saved before** (the item shows `keySaved` and a `check` that `passed`): the lock was handled by an earlier run or the person. Run step 2's check and go on; call the tool only when that check fails.
|
|
23
|
+
- **A key saved before** (the item shows `keySaved` and a `check` that `passed`): the lock was handled by an earlier run or the person. Run step 2's check and go on; call the tool only when that check fails. On a repository with several apps, the item's `keySaved` speaks for one app at most: an app whose own proof answered a `gate` gets step 1 with its `appKey`, whatever another app's key did, and its step 2 check is its own.
|
|
24
24
|
- **The key is never yours**: it goes from the command's own process to the host and to Impeccable, and into no message, status line, file, command or report. `vercel project protection`, `vercel api`, `vercel curl` and `netlify api` print it, and the fence refuses them.
|
|
25
25
|
- **The app's own sign-in** is another card: a preview opened past the lock proves nothing about it.
|
|
26
26
|
- **Status lines** belong to the Preview method task. Word Vercel's login and Cloudflare Access as a login, and Netlify's lock as a password.
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Vercel deploys previews of the pull requests: impeccable_hosted_previews lists a successful Preview deployment that Vercel made (`vercel: true`) of a pull request, with its address. The checkout often shows it too (`vercel.json`, `.vercel/project.json`, a `vercel` dependency), but GitHub's record decides. Vercel builds and serves every preview, so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request.
|
|
4
4
|
|
|
5
|
+
It applies as well **before the first pull request**: Vercel deploys the repository already (impeccable_hosted_previews lists deployments Vercel made, production ones or of pushes, and none of a pull request; on a repository with several apps, those of the project that builds the app, named in `project`), so its previews start with the test drive's push. Then there is nothing to prove in step 1 yet: impeccable_ask with `vercel-setup` is refused, since there is no sign-in, project or connection to make; go on to pull-requests' step 4, which pushes the test drive, waits for Vercel's deployment of the pushed branch (impeccable_hosted_previews with `branch`, and the app's `appKey`), proves the test drive's page there, past Vercel's login as preview-protection says, and then step 2 saves the method.
|
|
6
|
+
|
|
5
7
|
### Steps
|
|
6
8
|
|
|
7
9
|
1. **Prove the newest preview** with impeccable_prove_preview: the address of the newest successful Preview deployment of a pull request (on a repository with several apps, the newest of those impeccable_hosted_previews listed for the app's `appKey`, the deployments of the Vercel project that builds its folder) (or a page on it a visitor opens without signing in, when the app's own sign-in answers there), and words the page shows that the checkout holds, such as the app's name in its title. Done when it answers `proven: true`; when an address no longer answers, try the next newest, three at most.
|
|
@@ -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), 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.
|
|
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`) of any kind, production ones included, and no `error` (an error says nothing either way); on a repository with several apps, it names no project of the app's (`unmatched`). A repository Vercel deploys already, with no pull request yet, is vercel-previews' ("before the first pull request"), and step 1's screen is refused for it. 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
|
|
package/dist/tools.js
CHANGED
|
@@ -10,7 +10,7 @@ import { defineTool } from '@earendil-works/pi-coding-agent';
|
|
|
10
10
|
import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, chosenHostTeam, classifyPreviewCommand, endHostSetup, hostSetupAgreed, isSecretFile, linkedByRun, observePreviewAttempts, parseCommand, previewRetryState, settleVercelFirstDeployment, vercelProjectsAdded } from './fence.js';
|
|
11
11
|
import { requestJson } from './http.js';
|
|
12
12
|
import { appsLeftOut, focusCheckup, focusInstructions, focusState, severalApps } from './focus.js';
|
|
13
|
-
import { APPS_QUESTION, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, sentTo, startedIn } from './host-questions.js';
|
|
13
|
+
import { APPS_QUESTION, HOST_QUESTIONS, HOST_SCREENS, HOST_SIGNED_IN, namedAppQuestion, sentTo, startedIn } from './host-questions.js';
|
|
14
14
|
import { enableNetlifyPreviews, folderOf } from './host-previews.js';
|
|
15
15
|
import { checkoutNames, liveShaped, netlifySiteOf, previewHostName, provePreview, readHostedPreviews } from './hosted.js';
|
|
16
16
|
import { cfRunner, cfStatus, CLOUDFLARE_APP_PAGE, connectCloudflareProject, enableCloudflarePreviews, pagesProject, pagesProjectName, readCloudflarePreviews } from './cloudflare.js';
|
|
@@ -107,6 +107,21 @@ export function resolveWrite(instructions, setupApi, repo, path) {
|
|
|
107
107
|
}
|
|
108
108
|
return { error: `No PUT or POST in the instructions' "endpoints" matches ${pathname}. Pick one from there.` };
|
|
109
109
|
}
|
|
110
|
+
/** The last screen's words for a run on several apps: the picked apps not set up, by name, the line naming which are and which are not, and a line for each that is not. */
|
|
111
|
+
export function appsSummary(apps) {
|
|
112
|
+
const picked = apps.filter((a) => a.state !== 'unpicked');
|
|
113
|
+
const ready = picked.filter((a) => a.state === 'ready');
|
|
114
|
+
const rest = picked.filter((a) => a.state !== 'ready');
|
|
115
|
+
const names = (list) => list.map((a) => a.name).join(', ');
|
|
116
|
+
const line = [...(ready.length ? [`Set up: ${names(ready)}.`] : []), ...(rest.length ? [`Not set up: ${names(rest)}.`] : [])].join(' ');
|
|
117
|
+
return { notReady: rest.map((a) => a.name), line, todo: rest.flatMap((a) => (a.left ? [a.left] : [])) };
|
|
118
|
+
}
|
|
119
|
+
/** A host's lock in front of a preview, as impeccable_prove_preview names it: how the person reads it, the tool that gets a key past it, and the step left where none was saved (preview-protection's step 3). */
|
|
120
|
+
const LOCKS = {
|
|
121
|
+
'vercel-protection': { lock: "Vercel's login", tool: 'impeccable_vercel_bypass', left: 'Save the Vercel bypass secret on the setup page' },
|
|
122
|
+
'netlify-protection': { lock: "Netlify's password or team login", tool: 'impeccable_netlify_password', left: 'Save the Netlify site password on the setup page, or make the site public on its Visitor access page' },
|
|
123
|
+
'cloudflare-access': { lock: 'Cloudflare Access', tool: 'impeccable_cloudflare_access', left: 'Save the Cloudflare service token on the setup page' },
|
|
124
|
+
};
|
|
110
125
|
const NO_INPUT = { type: 'object', properties: {}, additionalProperties: false };
|
|
111
126
|
/** Why the status line and the task list say different things, one task in progress at a time and the status its detail; null when they agree. */
|
|
112
127
|
function stageError(status, tasks) {
|
|
@@ -803,18 +818,34 @@ export async function driveCoversSetup(cwd, setupBranch, driveBranch) {
|
|
|
803
818
|
* What the pushed `head` changes against `base`, as their remote-tracking refs in this checkout show GitHub: each file's
|
|
804
819
|
* `git diff --name-status` letter and its path as it is, never quoted; a rename as a removal and an addition. Null when either ref is missing or git fails.
|
|
805
820
|
*/
|
|
806
|
-
export async function pushedChanges(cwd, base, head) {
|
|
821
|
+
export async function pushedChanges(cwd, base, head, lessSetup = false) {
|
|
807
822
|
try {
|
|
823
|
+
const git = (args, timeout = 10_000) => execFileAsync('git', args, { cwd, timeout, env: gitEnvironment() });
|
|
808
824
|
// The two branches as the remote has them, fetched so the diff can be read here: refs/remotes/origin/* sit in the checkout's git folder, where a command in the sandbox can move them.
|
|
809
825
|
const refs = [`refs/heads/${base}`, `refs/heads/${head}`];
|
|
810
|
-
const { stdout: heads } = await
|
|
826
|
+
const { stdout: heads } = await git(['ls-remote', '--heads', 'origin', ...refs], 60_000);
|
|
811
827
|
const sha = new Map(heads.split('\n').filter(Boolean).map((line) => line.split('\t').reverse()));
|
|
812
828
|
if (!refs.every((ref) => sha.has(ref)))
|
|
813
829
|
return null;
|
|
814
|
-
await
|
|
815
|
-
|
|
816
|
-
const
|
|
817
|
-
|
|
830
|
+
await git(['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', 'origin', ...refs], 60_000);
|
|
831
|
+
// From where the two met (three dots), so a base that moved on since the branch left it adds nothing of its own.
|
|
832
|
+
const changed = async (from, to) => {
|
|
833
|
+
const { stdout } = await git(['diff', '-z', '--name-status', '--no-renames', `${from}...${to}`]);
|
|
834
|
+
const fields = stdout.split('\0');
|
|
835
|
+
return Array.from({ length: Math.floor(fields.length / 2) }, (_, i) => ({ status: fields[2 * i], path: fields[2 * i + 1] }));
|
|
836
|
+
};
|
|
837
|
+
const pushed = sha.get(refs[1]);
|
|
838
|
+
if (!lessSetup)
|
|
839
|
+
return changed(sha.get(refs[0]), pushed);
|
|
840
|
+
// The test drive branches from the setup commit, so a diff from the base carries the setup branch's files too (the workflow, a build fix). Read from the local setup branch under the pushed head instead (impeccable/setup, or its -N; of several, the nearest), the changes are the test drive's own: a file the setup branch changed and the test drive changed again is among them, one it left as the setup branch had it is not.
|
|
841
|
+
const { stdout: setups } = await git(['for-each-ref', '--format=%(refname)', 'refs/heads/impeccable/setup', 'refs/heads/impeccable/setup-*']);
|
|
842
|
+
const isAncestor = (of, to) => git(['merge-base', '--is-ancestor', of, to]).then(() => true, () => false);
|
|
843
|
+
let setup;
|
|
844
|
+
for (const ref of setups.split('\n').filter(Boolean)) {
|
|
845
|
+
if ((await isAncestor(ref, pushed)) && (setup === undefined || (await isAncestor(setup, ref))))
|
|
846
|
+
setup = ref;
|
|
847
|
+
}
|
|
848
|
+
return changed(setup ?? sha.get(refs[0]), pushed);
|
|
818
849
|
}
|
|
819
850
|
catch {
|
|
820
851
|
return null;
|
|
@@ -1648,6 +1679,12 @@ export function createTools(ctx) {
|
|
|
1648
1679
|
/** On a repository with several apps: the app each production address a host tool answered is the live app of, read for that app's own project or site, and the apps whose host preview a proof opened. */
|
|
1649
1680
|
const liveApps = new Map();
|
|
1650
1681
|
const provenApps = new Set();
|
|
1682
|
+
/** On a repository with several apps (#286): the method each app's preview write saved; the host's lock a proof of each app's preview met; and the apps whose lock was met with no key tool called for them since, which holds every other app's steps until it is. */
|
|
1683
|
+
const appMethods = new Map();
|
|
1684
|
+
const lockMet = new Map();
|
|
1685
|
+
const lockPending = new Map();
|
|
1686
|
+
/** The projects whose deployments Vercel made of this repository, as impeccable_hosted_previews read them, by the app read for ('' for the repository): a repository Vercel deploys already needs no Vercel setup, since its previews start with the first pull request (#286). */
|
|
1687
|
+
const vercelDeployed = new Map();
|
|
1651
1688
|
/** Each Vercel project's production address, read from Vercel, by origin, and the project it was read from: the one live address the project's own bypass secret is sent to, since a secret is one project's, and the link file can name another project of this repository between the two reads. */
|
|
1652
1689
|
const vercelProductions = new Map();
|
|
1653
1690
|
/** The bypass secret a proof sends to `origin`: the one proved there, or, for a live address, the one of the project whose production it is. */
|
|
@@ -1938,6 +1975,10 @@ export function createTools(ctx) {
|
|
|
1938
1975
|
let confirmedApps = false;
|
|
1939
1976
|
/** Each app's folder, by key, as the state names it: what its host project or site must build. */
|
|
1940
1977
|
const appFolders = new Map();
|
|
1978
|
+
/** The apps as the state last listed them. */
|
|
1979
|
+
let knownApps = null;
|
|
1980
|
+
/** An app's name as the state gives it, or its key. */
|
|
1981
|
+
const appName = (key) => knownApps?.find((a) => a.key === key)?.name ?? key;
|
|
1941
1982
|
/**
|
|
1942
1983
|
* A read of the state or the checkup, trimmed to this setup before the model sees it. The setup token covers the apps
|
|
1943
1984
|
* there were at sign-in, so an app the scan proposed later reads `not-selected`: the run signs in again, for a token
|
|
@@ -1971,6 +2012,7 @@ export function createTools(ctx) {
|
|
|
1971
2012
|
});
|
|
1972
2013
|
for (const app of apps)
|
|
1973
2014
|
appFolders.set(app.key, app.folder);
|
|
2015
|
+
knownApps = apps;
|
|
1974
2016
|
return apps;
|
|
1975
2017
|
};
|
|
1976
2018
|
/** An app's folder, read from the state the first time it is asked for. */
|
|
@@ -1988,6 +2030,48 @@ export function createTools(ctx) {
|
|
|
1988
2030
|
return `Refused: give "appKey", the key of an app this run sets up (${[...pickedApps].join(', ')}). Nothing was ${what}.`;
|
|
1989
2031
|
return null;
|
|
1990
2032
|
};
|
|
2033
|
+
/**
|
|
2034
|
+
* On a repository with several apps, why a step for another app than `appKey` may not run yet: an app's proof met a
|
|
2035
|
+
* host's lock, and the tool that gets a key past it was not called for that app since, so its key would never be put
|
|
2036
|
+
* in place (#286). Null where it may: no lock met, or every one answered by its tool.
|
|
2037
|
+
*/
|
|
2038
|
+
const lockRefusal = (appKey, what) => {
|
|
2039
|
+
for (const [key, gate] of lockPending) {
|
|
2040
|
+
if (key === appKey)
|
|
2041
|
+
continue;
|
|
2042
|
+
const { lock, tool } = LOCKS[gate];
|
|
2043
|
+
return `Refused: ${appName(key)}'s preview is behind ${lock}, and ${tool} was not called for it: call it first, as preview-protection says, so each app gets its own key. Nothing was ${what}.`;
|
|
2044
|
+
}
|
|
2045
|
+
return null;
|
|
2046
|
+
};
|
|
2047
|
+
/** How this run left each app the state lists (#286), for the report and the last screen. */
|
|
2048
|
+
const appOutcomes = async (pi) => {
|
|
2049
|
+
const apps = knownApps ?? (await stateApps(pi)) ?? [];
|
|
2050
|
+
return apps.map(({ key, name, folder }) => {
|
|
2051
|
+
const method = appMethods.get(key);
|
|
2052
|
+
const gate = lockMet.get(key);
|
|
2053
|
+
const proven = provenApps.has(key);
|
|
2054
|
+
if (!pickedApps?.has(key))
|
|
2055
|
+
return { key, name, folder, state: 'unpicked' };
|
|
2056
|
+
if (method !== undefined && (method !== 'preview-deploy' || proven))
|
|
2057
|
+
return { key, name, folder, state: 'ready' };
|
|
2058
|
+
if (gate && !proven)
|
|
2059
|
+
return { key, name, folder, state: 'locked', left: `${name}: its previews are behind ${LOCKS[gate].lock}, and no key was saved. ${LOCKS[gate].left}.` };
|
|
2060
|
+
return { key, name, folder, state: 'unset', left: `${name}: no preview method was saved; the report says why.` };
|
|
2061
|
+
});
|
|
2062
|
+
};
|
|
2063
|
+
/** The report's section on the apps, one line each, from what this run saved and proved rather than from the model's words. */
|
|
2064
|
+
const appsSection = (apps) => `\n\n## Each app, as this run left it\n\n${apps.map((a) => {
|
|
2065
|
+
const where = `${a.name} (${a.folder || '/'})`;
|
|
2066
|
+
const lock = lockMet.has(a.key) ? LOCKS[lockMet.get(a.key)].lock : undefined;
|
|
2067
|
+
if (a.state === 'ready')
|
|
2068
|
+
return `- ${where}: set up. Method: ${appMethods.get(a.key)}${provenApps.has(a.key) ? `, its preview proven${lock ? ` past ${lock}` : ''}` : ''}.`;
|
|
2069
|
+
if (a.state === 'locked')
|
|
2070
|
+
return `- ${where}: not set up. Its previews are behind ${lock}, and no key was saved for it.`;
|
|
2071
|
+
if (a.state === 'unset')
|
|
2072
|
+
return `- ${where}: not set up. No preview method was saved for it.`;
|
|
2073
|
+
return `- ${where}: not set up this run.`;
|
|
2074
|
+
}).join('\n')}`;
|
|
1991
2075
|
/** Each app's Vercel project, found once a run (a failure is read again, the CLI may have signed in since). */
|
|
1992
2076
|
const vercelApps = new Map();
|
|
1993
2077
|
/** The app's Vercel project as this repository's, read now for its deployments: the one that builds the app's folder, or why none can be told. */
|
|
@@ -2016,15 +2100,17 @@ export function createTools(ctx) {
|
|
|
2016
2100
|
const apps = await stateApps(pi);
|
|
2017
2101
|
if (!apps || apps.length < 2)
|
|
2018
2102
|
return 'Refused: the setup state could not be read, or lists fewer than two apps: read it again with impeccable_state, then ask again.';
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2103
|
+
// Started for one app (--app, from its card): one confirm screen naming it instead of the list, whose Skip opens the list for someone who wants another app (#286).
|
|
2104
|
+
const named = ctx.app !== undefined ? apps.find((a) => a.key === ctx.app) : undefined;
|
|
2105
|
+
if (named && !(await ask(namedAppQuestion(named))).skipped) {
|
|
2106
|
+
pickedApps = new Set([named.key]);
|
|
2107
|
+
return JSON.stringify({ values: [named.key], named: true });
|
|
2022
2108
|
}
|
|
2023
2109
|
// Started below the top: the app whose folder holds that folder (the deepest, never the root's) is ticked too, and the hint says so.
|
|
2024
2110
|
const start = ctx.start ?? '';
|
|
2025
2111
|
const here = start ? apps.filter((a) => a.folder && (start === a.folder || start.startsWith(`${a.folder}/`))).sort((a, b) => b.folder.length - a.folder.length)[0] : undefined;
|
|
2026
2112
|
const hint = here ? startedIn(`${here.name}'s folder`) : start ? startedIn(start) : APPS_QUESTION.hint;
|
|
2027
|
-
const answer = await ask({ ...APPS_QUESTION, hint, options: apps.map((a) => ({ label: `${a.name} · ${a.folder || '/'}`, value: a.key, selected: confirmedApps || a.enabled || a === here })) });
|
|
2113
|
+
const answer = await ask({ ...APPS_QUESTION, hint, options: apps.map((a) => ({ label: `${a.name} · ${a.folder || '/'}`, value: a.key, selected: confirmedApps || a.enabled || a === here || a === named })) });
|
|
2028
2114
|
if (answer.skipped) {
|
|
2029
2115
|
pickedApps = new Set(apps.filter((a) => a.enabled).map((a) => a.key));
|
|
2030
2116
|
return JSON.stringify({ skipped: true, on: [...pickedApps] });
|
|
@@ -2114,7 +2200,7 @@ export function createTools(ctx) {
|
|
|
2114
2200
|
// On a repository with several apps, nothing but the apps write goes before the apps question, and an app's setting or check names a picked app.
|
|
2115
2201
|
const forApp = /\/(preview|test-accounts|connection-check|latest-pull-check)$/.test(target.url);
|
|
2116
2202
|
if (!target.url.endsWith('/apps')) {
|
|
2117
|
-
const refused = appRefusal(object(p.body)?.appKey, 'saved', forApp);
|
|
2203
|
+
const refused = appRefusal(object(p.body)?.appKey, 'saved', forApp) ?? (target.url.endsWith('/preview') ? lockRefusal(object(p.body)?.appKey, 'saved') : null);
|
|
2118
2204
|
if (refused)
|
|
2119
2205
|
return refused;
|
|
2120
2206
|
}
|
|
@@ -2167,6 +2253,8 @@ export function createTools(ctx) {
|
|
|
2167
2253
|
savedHost = provenHost;
|
|
2168
2254
|
if (saved?.appKey !== undefined)
|
|
2169
2255
|
perApp = true;
|
|
2256
|
+
if (saved?.appKey !== undefined && typeof saved.intendedMethod === 'string')
|
|
2257
|
+
appMethods.set(String(saved.appKey), saved.intendedMethod);
|
|
2170
2258
|
if (saved && 'intendedMethod' in saved)
|
|
2171
2259
|
hostMethod = saved.intendedMethod === 'preview-deploy';
|
|
2172
2260
|
for (const key of Object.keys(object(p.body) ?? {}))
|
|
@@ -2191,12 +2279,18 @@ export function createTools(ctx) {
|
|
|
2191
2279
|
// A test account never comes back to the model, even if the API echoed it.
|
|
2192
2280
|
return testAccounts ? redact(answerText(response), typed.values()) : answerText(response);
|
|
2193
2281
|
}),
|
|
2194
|
-
tool('impeccable_ask', 'Ask the person', 'Ask the person one question in a dialog, and wait for the answer; the web page shows the run waiting meanwhile. Answers {"value": "…"} for text and single, {"values": […]} for multi, {"confirmed": true} for confirm, {"username": "…", "password": "secret:…"} for account, or {"skipped": true}. A host\'s setup question (id "vercel-setup", "vercel-team", "netlify-setup" or "netlify-team") is worded by this tool: give it that id and its kind. So is the apps question on a repository with several apps (id "apps", kind "multi"), which lists the state\'s apps itself and answers {"values": […]}, {"values": [key], "named": true} when the command was started for that app, or {"skipped": true, "on": […]}. netlify-setup and vercel-setup are the host\'s sign-in: it shows its screen only when that CLI is signed out, signs the person in through the browser with a retry, and answers {"confirmed": true, "signedIn": true}, with "team" (the one the project goes into) or "teams" to choose from when the person has several, {"confirmed": true, "cli": "missing"} when the CLI is to be installed first and this asked again, or {"skipped": true}.', ASK, async (p, pi, signal) => {
|
|
2282
|
+
tool('impeccable_ask', 'Ask the person', 'Ask the person one question in a dialog, and wait for the answer; the web page shows the run waiting meanwhile. Answers {"value": "…"} for text and single, {"values": […]} for multi, {"confirmed": true} for confirm, {"username": "…", "password": "secret:…"} for account, or {"skipped": true}. A host\'s setup question (id "vercel-setup", "vercel-team", "netlify-setup" or "netlify-team") is worded by this tool: give it that id and its kind. So is the apps question on a repository with several apps (id "apps", kind "multi"), which lists the state\'s apps itself and answers {"values": […]}, {"values": [key], "named": true} when the command was started for that app (it shows one confirm screen naming the app instead of the list, and the list when the person asks for other apps there), or {"skipped": true, "on": […]}. netlify-setup and vercel-setup are the host\'s sign-in: it shows its screen only when that CLI is signed out, signs the person in through the browser with a retry, and answers {"confirmed": true, "signedIn": true}, with "team" (the one the project goes into) or "teams" to choose from when the person has several, {"confirmed": true, "cli": "missing"} when the CLI is to be installed first and this asked again, or {"skipped": true}.', ASK, async (p, pi, signal) => {
|
|
2195
2283
|
// A skip never stops the run, so every question offers one.
|
|
2196
2284
|
const asked = p;
|
|
2197
2285
|
if (asked.id === APPS_QUESTION.id && appsRepo)
|
|
2198
2286
|
return appsQuestion(pi);
|
|
2199
2287
|
const host = /^(netlify|vercel)-(setup|team)$/.exec(asked.id);
|
|
2288
|
+
// The Vercel setup is for a repository Vercel has never deployed: one it deploys already (every app this run sets up, on a repository with several), as impeccable_hosted_previews read it, gets its previews from the first pull request on (#286). An app with no project of its own still gets the screen, and a setup under way (asked again once the CLI was installed) is not stopped here.
|
|
2289
|
+
const deployed = appsRepo ? pickedApps !== undefined && pickedApps.size > 0 && [...pickedApps].every((key) => vercelDeployed.has(key)) : vercelDeployed.has('');
|
|
2290
|
+
if (asked.id === 'vercel-setup' && deployed && !hostSignIns.has('vercel')) {
|
|
2291
|
+
const projects = [...vercelDeployed.values()].filter(Boolean);
|
|
2292
|
+
return `Refused: Vercel deploys this repository already${projects.length ? ` (${projects.join(', ')})` : ''}: it made deployments of it, production included, so its previews start with the first pull request, and there is no Vercel sign-in, project or connection to make. Go on as vercel-previews says: build and push the test drive as pull-requests' step 4 says, wait for Vercel's deployment of the pushed branch with impeccable_hosted_previews and branch, prove the test drive's page there (past Vercel's login as preview-protection says), and save the method.`;
|
|
2293
|
+
}
|
|
2200
2294
|
if (host?.[2] === 'setup')
|
|
2201
2295
|
return JSON.stringify(await hostSignIn(host[1], pi, signal));
|
|
2202
2296
|
if (host && (teamChoices.get(host[1])?.length ?? 0) < 2)
|
|
@@ -2265,11 +2359,18 @@ export function createTools(ctx) {
|
|
|
2265
2359
|
return `Refused: ${found.problem}.`;
|
|
2266
2360
|
drive.url = found.url;
|
|
2267
2361
|
// The page that links to the test drive is an existing file the branch changes; files under dot folders (.github, .netlify) and dotfiles never link to it (#241).
|
|
2268
|
-
const changes = !recordedBranches && found.base ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, found.base, found.branch) : null;
|
|
2362
|
+
const changes = !recordedBranches && found.base ? await (ctx.pushedChanges ?? pushedChanges)(pi.cwd, found.base, found.branch, true) : null;
|
|
2269
2363
|
const files = (changes ?? []).filter(({ path }) => !path.split('/').some((part) => part.startsWith('.')));
|
|
2270
2364
|
if (files.length && files.every(({ status }) => status === 'A')) {
|
|
2271
2365
|
return `Refused: ${found.branch} only adds ${files.map(({ path }) => path).join(', ')} and changes no existing page, so nothing links to the test drive. Commit the page that links to it and push, prove the new push's preview again as pull-requests' step 4 says, then record it again.`;
|
|
2272
2366
|
}
|
|
2367
|
+
// On a repository with several apps, the test drive's page goes into a picked app's own folder, and no other app is touched (#286): a file it adds anywhere else is refused, whatever its name. The setup branch's own files (a build fix at the root) are not in `files`, as pushedChanges leaves them out.
|
|
2368
|
+
if (appsRepo && pickedApps) {
|
|
2369
|
+
const folders = (await Promise.all([...pickedApps].map((key) => appFolder(pi, key)))).filter((folder) => folder !== undefined);
|
|
2370
|
+
const outside = files.filter(({ status, path }) => status === 'A' && !folders.some((folder) => !folder || path.startsWith(`${folder}/`))).map(({ path }) => path);
|
|
2371
|
+
if (outside.length)
|
|
2372
|
+
return `Refused: ${found.branch} adds ${outside.join(', ')}, outside the folders of the apps this run sets up (${folders.map((folder) => folder || '/').join(', ')}). The test drive's page goes into each picked app's own folder, as the monorepo case says, and no other app is touched: move it, push, prove the new push's preview again as pull-requests' step 4 says, then record it again.`;
|
|
2373
|
+
}
|
|
2273
2374
|
let setupBranch = recordedBranches?.setup, setupBase = recordedBranches?.setupBase;
|
|
2274
2375
|
if (setup) {
|
|
2275
2376
|
// The setup pull request carries the preview attempts, so its binding cannot change.
|
|
@@ -2299,6 +2400,9 @@ export function createTools(ctx) {
|
|
|
2299
2400
|
const control = left.find((line) => plainText(line) !== line);
|
|
2300
2401
|
if (control)
|
|
2301
2402
|
return `Refused: "${control}" holds a control character or an escape sequence. Each line of "left" is plain text. Nothing was saved: call impeccable_report again.`;
|
|
2403
|
+
const pending = lockRefusal(undefined, 'saved');
|
|
2404
|
+
if (pending)
|
|
2405
|
+
return `${pending} Call impeccable_report again once it answered.`;
|
|
2302
2406
|
// The web page keeps the task list as the report leaves it, so no task may still read as waiting or running (#83).
|
|
2303
2407
|
const open = ctx.sync.payload.tasks.filter((t) => !t.id.startsWith('preview-attempt-') && (t.status === 'pending' || t.status === 'in_progress'));
|
|
2304
2408
|
if (open.length)
|
|
@@ -2312,6 +2416,10 @@ export function createTools(ctx) {
|
|
|
2312
2416
|
const shown = (path) => path.replace(/[\\`*_[\]<>|~]/g, '\\$&').replace(/[\x00-\x1f\x7f]/g, (c) => JSON.stringify(c).slice(1, -1));
|
|
2313
2417
|
markdown += `\n\n## What the setup pull request changes\n\n${changes ? changes.map(({ status, path }) => `- ${shown(path)} (${verbs[status] ?? status})`).join('\n') || 'Nothing: it carries no file.' : 'This checkout could not list its files: read them on the pull request before merging.'}`;
|
|
2314
2418
|
}
|
|
2419
|
+
// On a repository with several apps, how this run left each app, from what it saved and proved (#286).
|
|
2420
|
+
const apps = appsRepo && pickedApps ? await appOutcomes(pi) : undefined;
|
|
2421
|
+
if (apps)
|
|
2422
|
+
markdown += appsSection(apps);
|
|
2315
2423
|
if (previews.active && retry.pr) {
|
|
2316
2424
|
await observe(signal);
|
|
2317
2425
|
const lines = attempts().map((a, i) => `${i + 1}. [Attempt ${i + 1}](${a.url}) (run attempt ${a.attempt}): ${a.status}. ${a.diagnostics.map((d) => `${d.appKey}: step ${d.stage ?? 'unverified'}. ${d.reason}`).join(' ')}${a.changes.length ? ` Changed settings: ${a.changes.join(', ')}.` : ''}`);
|
|
@@ -2335,14 +2443,14 @@ export function createTools(ctx) {
|
|
|
2335
2443
|
return `Error: the report was refused (${res.status}: ${res.error}). Fix what it says and call impeccable_report again.`;
|
|
2336
2444
|
}
|
|
2337
2445
|
// Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
|
|
2338
|
-
ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}) });
|
|
2446
|
+
ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}), ...(apps ? { apps } : {}) });
|
|
2339
2447
|
pi.shutdown();
|
|
2340
2448
|
const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
|
|
2341
2449
|
return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
|
|
2342
2450
|
}),
|
|
2343
2451
|
tool('impeccable_hosted_previews', 'Hosted previews', 'Read the deployments GitHub has on record for this repository: the 20 newest, each with its latest status, its address, its environment, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. Without branch, where GitHub records a production deployment Vercel made and this folder is linked to the project (.vercel/project.json) the Vercel CLI reads as this repository\'s, production is the live app\'s address, the project\'s own domain, for the before. With branch (a branch of this run, pushed, before its pull request exists), only its head\'s, waiting up to five minutes for Vercel\'s deployment of it to finish building, under a status line with its timer, and again on the person\'s yes when the time ran out. A Production deployment of that branch (a project\'s first is, whatever branch it builds) is listed as its preview only while the project this folder is linked to is locked at every address (impeccable_vercel_connect locks it) and made it, read from Vercel at that moment, since a production deployment is otherwise served at the project\'s own address without a login; then no proof opens it. It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, pi, signal) => {
|
|
2344
2452
|
const branch = p.branch;
|
|
2345
|
-
const refused = appRefusal(p.appKey, 'read');
|
|
2453
|
+
const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
|
|
2346
2454
|
if (refused)
|
|
2347
2455
|
return refused;
|
|
2348
2456
|
// The fact, the live app and an app's project read the same projects: each `vercel api` call is made once.
|
|
@@ -2412,6 +2520,13 @@ export function createTools(ctx) {
|
|
|
2412
2520
|
if (app)
|
|
2413
2521
|
listedApps.set(url.origin, { appKey: appKey, link: app.link, name: app.name });
|
|
2414
2522
|
}
|
|
2523
|
+
// Vercel deploys the repository (this app's project did) when it made a deployment of it, production included: then its previews start with the first pull request, and no Vercel setup is asked (#286).
|
|
2524
|
+
if (branch === undefined) {
|
|
2525
|
+
if (evidence.deployments.some((d) => d.vercel && d.state === 'success'))
|
|
2526
|
+
vercelDeployed.set(appKey ?? '', app?.name ?? '');
|
|
2527
|
+
else
|
|
2528
|
+
vercelDeployed.delete(appKey ?? '');
|
|
2529
|
+
}
|
|
2415
2530
|
// What vercel.json and the linked project (the app's, with its folder's vercel.json) say of the test drive's branch rides with the plain evidence, never with a wait: reported, never changed.
|
|
2416
2531
|
if (branch === undefined) {
|
|
2417
2532
|
const fact = await (ctx.vercelPreviewsReader ?? vercelPreviewsFact)(pi.cwd, `${ctx.owner}/${ctx.repo}`, () => vercel, app ? { directory: app.folder, link: app.link } : undefined);
|
|
@@ -2435,7 +2550,7 @@ export function createTools(ctx) {
|
|
|
2435
2550
|
}),
|
|
2436
2551
|
tool('impeccable_netlify_previews', 'Netlify previews', "Read whether this repository's pull requests get Netlify deploy previews: the 10 newest, each with its site, its address, its state and where that was read: GitHub's commit status or Netlify's bot comment on the pull request, or the Netlify CLI on this machine when it is signed in (read only: it never signs in). production is the live site's own address, for the before. cli says whether that CLI is missing, signed out, signed in or did not answer. An error says something could not be read, which is unknown rather than absent. With pull (a pull request of this run, a draft included: Netlify builds a deploy preview only for a pull request), only that pull request's, waiting up to five minutes for one to finish building, under a status line with its timer, and again on the person's yes when the time ran out. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
|
|
2437
2552
|
const pull = p.pull;
|
|
2438
|
-
const refused = appRefusal(p.appKey, 'read');
|
|
2553
|
+
const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
|
|
2439
2554
|
if (refused)
|
|
2440
2555
|
return refused;
|
|
2441
2556
|
// On a repository with several apps, only the site that builds the app's folder (#281).
|
|
@@ -2473,7 +2588,7 @@ export function createTools(ctx) {
|
|
|
2473
2588
|
const branch = p.branch;
|
|
2474
2589
|
if (pull !== undefined && branch !== undefined)
|
|
2475
2590
|
return 'Invalid input: give pull or branch, not both.';
|
|
2476
|
-
const refused = appRefusal(p.appKey, 'read');
|
|
2591
|
+
const refused = appRefusal(p.appKey, 'read') ?? lockRefusal(p.appKey, 'read');
|
|
2477
2592
|
if (refused)
|
|
2478
2593
|
return refused;
|
|
2479
2594
|
// On a repository with several apps, only the Pages project that builds the app's folder (#281): told by the plain read, which a wait for a build keeps to.
|
|
@@ -2556,6 +2671,14 @@ export function createTools(ctx) {
|
|
|
2556
2671
|
provenHost = previewHostName(url) ?? provenHost;
|
|
2557
2672
|
if (proof.proven && app)
|
|
2558
2673
|
provenApps.add(app.appKey);
|
|
2674
|
+
if (proof.proven && app)
|
|
2675
|
+
lockPending.delete(app.appKey);
|
|
2676
|
+
// The lock in front of an app's preview is that app's own: its key tool is the next call for it, and every other app's steps wait for that (#286). An earlier proof of the app (another preview, or one the key opened before) says nothing about this lock: the app is proven again only once something opens it.
|
|
2677
|
+
if (app && proof.gate && Object.hasOwn(LOCKS, proof.gate)) {
|
|
2678
|
+
lockMet.set(app.appKey, proof.gate);
|
|
2679
|
+
lockPending.set(app.appKey, proof.gate);
|
|
2680
|
+
provenApps.delete(app.appKey);
|
|
2681
|
+
}
|
|
2559
2682
|
if (proof.proven && proof.pattern)
|
|
2560
2683
|
provenPattern = proof.pattern;
|
|
2561
2684
|
if (proof.gate === 'vercel-protection' || proof.gate === 'netlify-protection')
|
|
@@ -2694,6 +2817,8 @@ export function createTools(ctx) {
|
|
|
2694
2817
|
if (gated?.gate !== 'vercel-protection' || !vercelMade.has(new URL(gated.url).origin))
|
|
2695
2818
|
return 'Refused: this is for a preview that Vercel made, listed by impeccable_hosted_previews, which impeccable_prove_preview answered with gate "vercel-protection". Prove one first.';
|
|
2696
2819
|
const at = gated;
|
|
2820
|
+
if (at.app)
|
|
2821
|
+
lockPending.delete(at.app.appKey);
|
|
2697
2822
|
const { origin, host } = new URL(at.url);
|
|
2698
2823
|
// Where the setup API keeps the secret: the app's own on a repository with several apps, else the repository's.
|
|
2699
2824
|
const store = at.app?.appKey ?? '';
|
|
@@ -2781,6 +2906,8 @@ export function createTools(ctx) {
|
|
|
2781
2906
|
if (!gated || !site)
|
|
2782
2907
|
return 'Refused: this is for a Netlify deploy preview, listed by impeccable_netlify_previews, which impeccable_prove_preview answered with gate "netlify-protection". Prove one first.';
|
|
2783
2908
|
const at = gated;
|
|
2909
|
+
if (at.app)
|
|
2910
|
+
lockPending.delete(at.app.appKey);
|
|
2784
2911
|
/** The address pattern the proof with the password answers, saved as the method once the password is. */
|
|
2785
2912
|
let pattern;
|
|
2786
2913
|
const prove = async (password, form = false) => {
|
|
@@ -2880,9 +3007,12 @@ export function createTools(ctx) {
|
|
|
2880
3007
|
tool('impeccable_cloudflare_access', 'Cloudflare Access', 'Get past the Cloudflare Access login that impeccable_prove_preview met (gate "cloudflare-access") in front of a Cloudflare Pages preview, the way Cloudflare documents for tools: a service token, sent in two headers, that a Service Auth policy of the Access application allows. It does the rest as the person: one screen asking to create the token, Cloudflare\'s sign-in in the browser where cf, Cloudflare\'s CLI, is not signed in here with Access (with a retry), then the token named "Impeccable reviews" created through cf and allowed in the policy of the application in front of the preview; one screen taking a Cloudflare API token only where that sign-in cannot create it; on a skip, or where that cannot be done, one screen taking a pasted client id and secret, only once an Access application in the person\'s accounts was read to cover the preview (none read: refused, with the reason, since the token goes to no other address). It opens the preview with the token and saves it for reviews. Answers {"status": …}: saved (the preview is proven, and pattern is the address pattern to save with the method; token says whether it was created or typed, application which Access application allows it, expires when a created one runs out, apiToken true when the person\'s Cloudflare API token created it), skipped (nothing was saved; reason says what stopped it), refused (no Access application of the person\'s covers the preview, or the typed token did not open it; reason says which) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No token ever comes back.', NO_INPUT, async (_p, pi, signal) => {
|
|
2881
3008
|
if (!accessGated)
|
|
2882
3009
|
return 'Refused: this is for a Cloudflare Pages preview impeccable_cloudflare_previews listed, which impeccable_prove_preview answered with gate "cloudflare-access". Prove one first.';
|
|
3010
|
+
const at = accessGated;
|
|
3011
|
+
// The call for this app is made, whatever it answers: the skip asked once stands for the next app's lock too.
|
|
3012
|
+
if (at.app)
|
|
3013
|
+
lockPending.delete(at.app.appKey);
|
|
2883
3014
|
if (accessOutcome?.status === 'skipped')
|
|
2884
3015
|
return JSON.stringify(accessOutcome);
|
|
2885
|
-
const at = accessGated;
|
|
2886
3016
|
let proof;
|
|
2887
3017
|
const prove = async (token) => (proof = await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, access: token }));
|
|
2888
3018
|
const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
|
package/package.json
CHANGED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { promisify } from 'node:util';
|
|
3
|
-
const execFileAsync = promisify(execFile);
|
|
4
|
-
const LIMIT = 20;
|
|
5
|
-
const COVERAGE = 'Only the 20 newest GitHub deployments, their latest statuses, and the 20 most recently updated pull requests are checked. A missing match does not prove previews are absent. Empty GitHub deployment history does not prove that a live app or host previews do not exist. A deployment URL does not prove the application rendered.';
|
|
6
|
-
const FAILURE = 'GitHub preview evidence could not be read completely.';
|
|
7
|
-
const STATES = ['error', 'failure', 'inactive', 'in_progress', 'queued', 'pending', 'success'];
|
|
8
|
-
function record(value) {
|
|
9
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
10
|
-
? value
|
|
11
|
-
: undefined;
|
|
12
|
-
}
|
|
13
|
-
function positiveInteger(value) {
|
|
14
|
-
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
15
|
-
}
|
|
16
|
-
function sha(value) {
|
|
17
|
-
return typeof value === 'string' && /^[a-f0-9]{40}$/.test(value);
|
|
18
|
-
}
|
|
19
|
-
function previewUrl(value) {
|
|
20
|
-
if (typeof value !== 'string' || value.length > 2048 || !/^https:\/\/[^\s\\?#]+$/i.test(value))
|
|
21
|
-
return null;
|
|
22
|
-
try {
|
|
23
|
-
const url = new URL(value);
|
|
24
|
-
if (url.username || url.password || url.search || url.hash || /^https:\/\/[^/]*@/i.test(value))
|
|
25
|
-
return null;
|
|
26
|
-
return url.href;
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
/** Read a bounded projection of GitHub evidence; never return response bodies or command errors. */
|
|
33
|
-
export async function readHostedPreviews(owner, repo, dependencies = {}) {
|
|
34
|
-
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(owner)
|
|
35
|
-
|| !/^[a-zA-Z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..') {
|
|
36
|
-
return { coverage: COVERAGE, deployments: [], error: FAILURE };
|
|
37
|
-
}
|
|
38
|
-
const runGh = dependencies.runGh ?? (async (args) => {
|
|
39
|
-
const { stdout } = await execFileAsync('gh', args, {
|
|
40
|
-
encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024, signal: dependencies.signal,
|
|
41
|
-
});
|
|
42
|
-
return stdout;
|
|
43
|
-
});
|
|
44
|
-
const base = `/repos/${owner}/${repo}`;
|
|
45
|
-
async function get(route) {
|
|
46
|
-
if (dependencies.signal?.aborted)
|
|
47
|
-
throw new Error(FAILURE);
|
|
48
|
-
const stdout = await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]);
|
|
49
|
-
if (dependencies.signal?.aborted)
|
|
50
|
-
throw new Error(FAILURE);
|
|
51
|
-
const data = JSON.parse(stdout);
|
|
52
|
-
if (!Array.isArray(data))
|
|
53
|
-
throw new Error(FAILURE);
|
|
54
|
-
return data.slice(0, LIMIT);
|
|
55
|
-
}
|
|
56
|
-
try {
|
|
57
|
-
const [deployments, pulls] = await Promise.all([
|
|
58
|
-
get(`${base}/deployments?per_page=${LIMIT}`),
|
|
59
|
-
get(`${base}/pulls?state=all&sort=updated&direction=desc&per_page=${LIMIT}`),
|
|
60
|
-
]);
|
|
61
|
-
let incomplete = false;
|
|
62
|
-
const evidence = [];
|
|
63
|
-
for (const entry of deployments) {
|
|
64
|
-
if (dependencies.signal?.aborted) {
|
|
65
|
-
incomplete = true;
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
const deployment = record(entry);
|
|
69
|
-
if (!deployment || !positiveInteger(deployment.id) || !sha(deployment.sha))
|
|
70
|
-
continue;
|
|
71
|
-
let latest;
|
|
72
|
-
try {
|
|
73
|
-
latest = record((await get(`${base}/deployments/${deployment.id}/statuses?per_page=1`))[0]);
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
incomplete = true;
|
|
77
|
-
if (dependencies.signal?.aborted)
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
80
|
-
const status = STATES.find((state) => state === latest?.state) ?? null;
|
|
81
|
-
const environment = typeof deployment.environment === 'string' && deployment.environment.length <= 100
|
|
82
|
-
&& !/[\u0000-\u001f\u007f]/.test(deployment.environment) ? deployment.environment : null;
|
|
83
|
-
const pullRequests = [];
|
|
84
|
-
for (const item of pulls) {
|
|
85
|
-
const pull = record(item);
|
|
86
|
-
if (pull && positiveInteger(pull.number) && (pull.state === 'open' || pull.state === 'closed')
|
|
87
|
-
&& record(pull.head)?.sha === deployment.sha) {
|
|
88
|
-
pullRequests.push({ number: pull.number, state: pull.state });
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
evidence.push({ id: deployment.id, sha: deployment.sha, environment, status,
|
|
92
|
-
previewUrl: status ? previewUrl(latest?.environment_url) : null, pullRequests });
|
|
93
|
-
}
|
|
94
|
-
incomplete ||= dependencies.signal?.aborted ?? false;
|
|
95
|
-
return { coverage: COVERAGE, deployments: evidence,
|
|
96
|
-
...(incomplete ? { error: FAILURE } : { emptyDeploymentHistory: deployments.length === 0 }) };
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
return { coverage: COVERAGE, deployments: [], error: FAILURE };
|
|
100
|
-
}
|
|
101
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/** Keep Netlify's repository-wide pattern tied to successful discovery evidence. */
|
|
2
|
-
export function netlifyPatternError(body, observed) {
|
|
3
|
-
const pattern = body.previewUrlPattern;
|
|
4
|
-
if (typeof pattern !== 'string')
|
|
5
|
-
return null;
|
|
6
|
-
let netlify = /netlify\.app|<site>/i.test(pattern);
|
|
7
|
-
try {
|
|
8
|
-
const hostname = new URL(pattern.replaceAll('{pr}', '1')).hostname;
|
|
9
|
-
netlify ||= hostname === 'netlify.app' || hostname.endsWith('.netlify.app');
|
|
10
|
-
}
|
|
11
|
-
catch { /* The canonical validation below rejects malformed Netlify patterns. */ }
|
|
12
|
-
if (!netlify)
|
|
13
|
-
return null;
|
|
14
|
-
const match = /^https:\/\/deploy-preview-\{pr\}--([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.netlify\.app$/.exec(pattern);
|
|
15
|
-
if (!match || match[1].length > 63)
|
|
16
|
-
return 'Use a real Netlify site pattern from successful preview evidence; placeholders and malformed patterns are not saved.';
|
|
17
|
-
if (Object.hasOwn(body, 'appKey'))
|
|
18
|
-
return 'A Netlify preview URL pattern belongs to the repository; omit appKey.';
|
|
19
|
-
if (body.intendedMethod !== 'preview-deploy')
|
|
20
|
-
return 'Save a Netlify pattern with intendedMethod preview-deploy.';
|
|
21
|
-
if (!observed.has(pattern))
|
|
22
|
-
return 'Read this repository\'s Netlify previews first and choose a pattern backed by a successful current-head status.';
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { promisify } from 'node:util';
|
|
3
|
-
const execFileAsync = promisify(execFile);
|
|
4
|
-
const FAILURE = 'Netlify preview evidence could not be read completely.';
|
|
5
|
-
const COVERAGE = 'Only the 20 most recently updated pull requests, the newest 100 statuses on each exact head SHA, and the 100 most recently updated repository issue comments are checked. Context-derived URLs do not prove the application rendered. Bot comments are historical candidates, not current-head deployment proof. Missing evidence does not prove previews are absent.';
|
|
6
|
-
const SITE = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
|
7
|
-
const CONTEXT = new RegExp(`^netlify/(${SITE})/deploy-preview$`);
|
|
8
|
-
const PREVIEW_HOST = new RegExp(`^deploy-preview-([1-9][0-9]*)--(${SITE})\\.netlify\\.app$`);
|
|
9
|
-
const STATES = ['success', 'pending', 'failure', 'error'];
|
|
10
|
-
function record(value) {
|
|
11
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
12
|
-
? value : undefined;
|
|
13
|
-
}
|
|
14
|
-
function cleanUrl(value) {
|
|
15
|
-
if (typeof value !== 'string' || value.length > 2048 || !/^https:\/\/[^\s\\?#@]+$/i.test(value))
|
|
16
|
-
return;
|
|
17
|
-
try {
|
|
18
|
-
const url = new URL(value);
|
|
19
|
-
if (!url.username && !url.password && !url.port && !url.search && !url.hash)
|
|
20
|
-
return url;
|
|
21
|
-
}
|
|
22
|
-
catch { /* Discard unsafe evidence without returning it. */ }
|
|
23
|
-
}
|
|
24
|
-
function urls(pull, site) {
|
|
25
|
-
if (`deploy-preview-${pull.number}--${site}`.length > 63)
|
|
26
|
-
return;
|
|
27
|
-
return {
|
|
28
|
-
previewUrl: `https://deploy-preview-${pull.number}--${site}.netlify.app/`,
|
|
29
|
-
previewUrlPattern: `https://deploy-preview-{pr}--${site}.netlify.app`,
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
function validTarget(value, pull, site) {
|
|
33
|
-
if (value === null || value === undefined || value === '')
|
|
34
|
-
return true;
|
|
35
|
-
const target = cleanUrl(value);
|
|
36
|
-
if (!target)
|
|
37
|
-
return false;
|
|
38
|
-
if (target.hostname === `deploy-preview-${pull.number}--${site}.netlify.app` && target.pathname === '/')
|
|
39
|
-
return true;
|
|
40
|
-
return target.hostname === 'app.netlify.com'
|
|
41
|
-
&& new RegExp(`^/(?:sites|projects)/${site}/deploys(?:/[a-zA-Z0-9_-]+)?/?$`).test(target.pathname);
|
|
42
|
-
}
|
|
43
|
-
/** Read a bounded, repository-scoped projection; never expose comments, command errors or credentials. */
|
|
44
|
-
export async function readNetlifyPreviews(owner, repo, dependencies = {}) {
|
|
45
|
-
const previews = [];
|
|
46
|
-
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(owner)
|
|
47
|
-
|| !/^[a-zA-Z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..') {
|
|
48
|
-
return { coverage: COVERAGE, previews, error: FAILURE };
|
|
49
|
-
}
|
|
50
|
-
const runGh = dependencies.runGh ?? (async (args) => {
|
|
51
|
-
const { stdout } = await execFileAsync('gh', args, {
|
|
52
|
-
encoding: 'utf8', timeout: 30_000, maxBuffer: 2 * 1024 * 1024, signal: dependencies.signal,
|
|
53
|
-
});
|
|
54
|
-
return stdout;
|
|
55
|
-
});
|
|
56
|
-
const base = `/repos/${owner}/${repo}`;
|
|
57
|
-
async function get(route, limit) {
|
|
58
|
-
if (dependencies.signal?.aborted)
|
|
59
|
-
throw new Error();
|
|
60
|
-
const raw = await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]);
|
|
61
|
-
if (dependencies.signal?.aborted)
|
|
62
|
-
throw new Error();
|
|
63
|
-
const data = JSON.parse(raw);
|
|
64
|
-
if (!Array.isArray(data))
|
|
65
|
-
throw new Error();
|
|
66
|
-
return data.slice(0, limit);
|
|
67
|
-
}
|
|
68
|
-
const [pullRead, commentRead] = await Promise.allSettled([
|
|
69
|
-
get(`${base}/pulls?state=all&sort=updated&direction=desc&per_page=20`, 20),
|
|
70
|
-
get(`${base}/issues/comments?sort=updated&direction=desc&per_page=100`, 100),
|
|
71
|
-
]);
|
|
72
|
-
if (pullRead.status === 'rejected')
|
|
73
|
-
return { coverage: COVERAGE, previews, error: FAILURE };
|
|
74
|
-
let incomplete = false;
|
|
75
|
-
const warnings = commentRead.status === 'rejected'
|
|
76
|
-
? ['Historical Netlify bot comments could not be read. Current-head status evidence is still checked.'] : [];
|
|
77
|
-
const pulls = new Map();
|
|
78
|
-
for (const item of pullRead.value) {
|
|
79
|
-
const pull = record(item);
|
|
80
|
-
const headSha = record(pull?.head)?.sha;
|
|
81
|
-
if (!pull || typeof pull.number !== 'number' || !Number.isSafeInteger(pull.number) || pull.number <= 0
|
|
82
|
-
|| (pull.state !== 'open' && pull.state !== 'closed') || typeof headSha !== 'string' || !/^[a-f0-9]{40}$/.test(headSha))
|
|
83
|
-
continue;
|
|
84
|
-
const issueUrl = `https://api.github.com${base}/issues/${pull.number}`;
|
|
85
|
-
if (!pulls.has(issueUrl))
|
|
86
|
-
pulls.set(issueUrl, { number: pull.number, state: pull.state, headSha });
|
|
87
|
-
}
|
|
88
|
-
const seen = new Set();
|
|
89
|
-
const statusesBySha = new Map();
|
|
90
|
-
for (const pull of pulls.values()) {
|
|
91
|
-
if (dependencies.signal?.aborted) {
|
|
92
|
-
incomplete = true;
|
|
93
|
-
break;
|
|
94
|
-
}
|
|
95
|
-
try {
|
|
96
|
-
if (!statusesBySha.has(pull.headSha)) {
|
|
97
|
-
statusesBySha.set(pull.headSha, get(`${base}/commits/${pull.headSha}/statuses?per_page=100`, 100));
|
|
98
|
-
}
|
|
99
|
-
for (const item of await statusesBySha.get(pull.headSha)) {
|
|
100
|
-
const status = record(item);
|
|
101
|
-
const site = typeof status?.context === 'string' ? CONTEXT.exec(status.context)?.[1] : undefined;
|
|
102
|
-
if (!site || seen.has(`${pull.number}:${site}`))
|
|
103
|
-
continue;
|
|
104
|
-
// GitHub returns newest first: even an invalid latest entry must hide older success.
|
|
105
|
-
seen.add(`${pull.number}:${site}`);
|
|
106
|
-
const state = STATES.find((state) => state === status?.state);
|
|
107
|
-
const canonical = urls(pull, site);
|
|
108
|
-
if (!state || !canonical || !validTarget(status?.target_url, pull, site))
|
|
109
|
-
continue;
|
|
110
|
-
previews.push({ pullRequest: pull.number, state: pull.state, headSha: pull.headSha, site,
|
|
111
|
-
status: state, ...canonical, source: 'commit-status', matchesHead: true });
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
incomplete = true;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (!dependencies.signal?.aborted && commentRead.status === 'fulfilled') {
|
|
119
|
-
for (const item of commentRead.value) {
|
|
120
|
-
const comment = record(item);
|
|
121
|
-
const author = record(comment?.user);
|
|
122
|
-
const pull = typeof comment?.issue_url === 'string' ? pulls.get(comment.issue_url) : undefined;
|
|
123
|
-
if (!pull || author?.login !== 'netlify[bot]' || author.type !== 'Bot'
|
|
124
|
-
|| typeof comment?.body !== 'string' || comment.body.length > 65_536)
|
|
125
|
-
continue;
|
|
126
|
-
for (const match of comment.body.matchAll(/https:\/\/[^\s<>"'()[\]`]+/gi)) {
|
|
127
|
-
const url = cleanUrl(match[0].replace(/[.,;:]+$/, ''));
|
|
128
|
-
const parts = url ? PREVIEW_HOST.exec(url.hostname) : null;
|
|
129
|
-
if (!url || url.pathname !== '/' || !parts || parts[1] !== String(pull.number))
|
|
130
|
-
continue;
|
|
131
|
-
const site = parts[2];
|
|
132
|
-
const canonical = urls(pull, site);
|
|
133
|
-
if (!canonical || seen.has(`${pull.number}:${site}`))
|
|
134
|
-
continue;
|
|
135
|
-
seen.add(`${pull.number}:${site}`);
|
|
136
|
-
previews.push({ pullRequest: pull.number, state: pull.state, headSha: pull.headSha, site,
|
|
137
|
-
status: null, ...canonical, source: 'bot-comment', matchesHead: false });
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
if (dependencies.signal?.aborted)
|
|
142
|
-
incomplete = true;
|
|
143
|
-
return { coverage: COVERAGE, previews, ...(warnings.length ? { warnings } : {}), ...(incomplete ? { error: FAILURE } : {}) };
|
|
144
|
-
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
function redirectGate(location, source) {
|
|
2
|
-
if (!location)
|
|
3
|
-
return 'redirected';
|
|
4
|
-
try {
|
|
5
|
-
const target = new URL(location, source);
|
|
6
|
-
if (target.protocol !== 'https:')
|
|
7
|
-
return 'redirected';
|
|
8
|
-
if (target.origin === 'https://vercel.com' && target.pathname === '/sso-api'
|
|
9
|
-
&& target.searchParams.has('url'))
|
|
10
|
-
return 'vercel-protection';
|
|
11
|
-
if ((target.hostname === 'vercel.com' || target.hostname.endsWith('.vercel.com'))
|
|
12
|
-
&& /^\/login(?:\/|$)/.test(target.pathname))
|
|
13
|
-
return 'vercel-protection';
|
|
14
|
-
if (target.hostname.endsWith('.cloudflareaccess.com'))
|
|
15
|
-
return 'cloudflare-access';
|
|
16
|
-
if (target.origin === source.origin && /^\/(?:login|sign-in|signin)(?:\/|$)/.test(target.pathname)) {
|
|
17
|
-
return 'app-sign-in';
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
catch {
|
|
21
|
-
// A malformed redirect reveals nothing about preview protection.
|
|
22
|
-
}
|
|
23
|
-
return 'redirected';
|
|
24
|
-
}
|
|
25
|
-
/** An unauthenticated HTTP check only: reachable does not prove this is the intended app. */
|
|
26
|
-
export async function probePreview(url, dependencies = {}) {
|
|
27
|
-
let target;
|
|
28
|
-
try {
|
|
29
|
-
target = new URL(url);
|
|
30
|
-
if (!isPlatformPreview(target)
|
|
31
|
-
|| /[?#\\\s]/.test(url) || !/^https:\/\//i.test(url))
|
|
32
|
-
throw new Error();
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return { status: null, gate: 'unavailable', error: 'invalid-preview-url' };
|
|
36
|
-
}
|
|
37
|
-
const controller = new AbortController();
|
|
38
|
-
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
39
|
-
const signal = dependencies.signal
|
|
40
|
-
? AbortSignal.any([controller.signal, dependencies.signal])
|
|
41
|
-
: controller.signal;
|
|
42
|
-
try {
|
|
43
|
-
signal.throwIfAborted();
|
|
44
|
-
const response = dependencies.fetch ? await dependencies.fetch(target, {
|
|
45
|
-
method: 'GET',
|
|
46
|
-
headers: { accept: 'text/html' },
|
|
47
|
-
credentials: 'omit',
|
|
48
|
-
redirect: 'manual',
|
|
49
|
-
signal,
|
|
50
|
-
}) : await publicPreviewRequest(target, signal);
|
|
51
|
-
const status = response.status;
|
|
52
|
-
const gate = status === 401 && response.headers.get('x-vercel-error') === 'UNAUTHORIZED'
|
|
53
|
-
? 'vercel-protection' : status >= 300 && status < 400
|
|
54
|
-
? redirectGate(response.headers.get('location'), target)
|
|
55
|
-
: status >= 200 && status < 300 ? 'reachable' : 'unavailable';
|
|
56
|
-
// Bodies and redirect details may contain credentials; never read or return them.
|
|
57
|
-
await response.body?.cancel().catch(() => undefined);
|
|
58
|
-
return { status, gate };
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
61
|
-
return { status: null, gate: 'unavailable', error: 'request-failed' };
|
|
62
|
-
}
|
|
63
|
-
finally {
|
|
64
|
-
clearTimeout(timeout);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
import { isPlatformPreview, publicPreviewRequest } from './public-preview-request.js';
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/** Register credential-shaped write fields before a message can be recorded.
|
|
2
|
-
* Do not trust the requested path: even a refused or malformed destination must
|
|
3
|
-
* keep its supplied credentials private. Values remain unchanged in memory.
|
|
4
|
-
*/
|
|
5
|
-
export function protectionWriteSecrets(input) {
|
|
6
|
-
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
7
|
-
return [];
|
|
8
|
-
const body = input.body;
|
|
9
|
-
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
10
|
-
return [];
|
|
11
|
-
return ['value', 'clientId', 'clientSecret']
|
|
12
|
-
.map((key) => body[key])
|
|
13
|
-
.filter((value) => typeof value === 'string' && value.length > 0)
|
|
14
|
-
.sort((a, b) => b.length - a.length);
|
|
15
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { lookup } from 'node:dns/promises';
|
|
2
|
-
import { request } from 'node:https';
|
|
3
|
-
import { BlockList, isIP } from 'node:net';
|
|
4
|
-
const blockedV4 = new BlockList();
|
|
5
|
-
for (const [address, prefix] of [
|
|
6
|
-
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
|
7
|
-
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
|
|
8
|
-
['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24],
|
|
9
|
-
['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
|
|
10
|
-
])
|
|
11
|
-
blockedV4.addSubnet(address, prefix, 'ipv4');
|
|
12
|
-
const publicV6 = new BlockList();
|
|
13
|
-
publicV6.addSubnet('2000::', 3, 'ipv6');
|
|
14
|
-
const blockedV6 = new BlockList();
|
|
15
|
-
// Conservatively exclude special-purpose, transition and documentation prefixes.
|
|
16
|
-
for (const [address, prefix] of [
|
|
17
|
-
['2001::', 23], ['2001:db8::', 32], ['2002::', 16], ['3fff::', 20],
|
|
18
|
-
])
|
|
19
|
-
blockedV6.addSubnet(address, prefix, 'ipv6');
|
|
20
|
-
export function isPlatformPreview(target) {
|
|
21
|
-
return target.protocol === 'https:' && !target.username && !target.password && !target.port
|
|
22
|
-
&& !/[?#]/.test(target.href)
|
|
23
|
-
&& /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:vercel|netlify)\.app$/.test(target.hostname);
|
|
24
|
-
}
|
|
25
|
-
function isPublicAddress(value) {
|
|
26
|
-
if (!value || typeof value.address !== 'string' || value.address.includes('%'))
|
|
27
|
-
return false;
|
|
28
|
-
const family = isIP(value.address);
|
|
29
|
-
if (family !== value.family)
|
|
30
|
-
return false;
|
|
31
|
-
if (family === 4)
|
|
32
|
-
return !blockedV4.check(value.address, 'ipv4');
|
|
33
|
-
return family === 6 && publicV6.check(value.address, 'ipv6') && !blockedV6.check(value.address, 'ipv6');
|
|
34
|
-
}
|
|
35
|
-
/** Resolve once, validate every answer, then connect only to the pinned public addresses. */
|
|
36
|
-
export async function publicPreviewRequest(target, signal, dependencies = {}) {
|
|
37
|
-
try {
|
|
38
|
-
if (!isPlatformPreview(target))
|
|
39
|
-
throw new Error();
|
|
40
|
-
signal.throwIfAborted();
|
|
41
|
-
let abortDns = () => { };
|
|
42
|
-
const aborted = new Promise((_resolve, reject) => {
|
|
43
|
-
abortDns = () => reject(new Error());
|
|
44
|
-
signal.addEventListener('abort', abortDns, { once: true });
|
|
45
|
-
});
|
|
46
|
-
const addresses = await Promise.race([
|
|
47
|
-
(dependencies.lookup ?? lookup)(target.hostname, { all: true, verbatim: true }), aborted,
|
|
48
|
-
]).finally(() => signal.removeEventListener('abort', abortDns));
|
|
49
|
-
signal.throwIfAborted();
|
|
50
|
-
if (!Array.isArray(addresses) || !addresses.length || !addresses.every(isPublicAddress))
|
|
51
|
-
throw new Error();
|
|
52
|
-
const pinned = addresses.map(({ address, family }) => ({ address, family }));
|
|
53
|
-
return await new Promise((resolve, reject) => {
|
|
54
|
-
const options = {
|
|
55
|
-
method: 'GET', headers: { accept: 'text/html' }, signal,
|
|
56
|
-
agent: false, autoSelectFamily: true, rejectUnauthorized: true,
|
|
57
|
-
lookup(hostname, options, callback) {
|
|
58
|
-
if (hostname !== target.hostname)
|
|
59
|
-
return callback(new Error('Preview request failed'), '');
|
|
60
|
-
if (options.all)
|
|
61
|
-
callback(null, pinned.map((address) => ({ ...address })));
|
|
62
|
-
else
|
|
63
|
-
callback(null, pinned[0].address, pinned[0].family);
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
const req = (dependencies.request ?? request)(target, options, (response) => {
|
|
67
|
-
response.once('error', () => { });
|
|
68
|
-
try {
|
|
69
|
-
const status = response.statusCode;
|
|
70
|
-
const headers = new Headers();
|
|
71
|
-
for (const name of ['location', 'x-vercel-error']) {
|
|
72
|
-
const value = response.headers[name];
|
|
73
|
-
if (typeof value === 'string')
|
|
74
|
-
headers.set(name, value);
|
|
75
|
-
}
|
|
76
|
-
if (!status)
|
|
77
|
-
reject(new Error());
|
|
78
|
-
else
|
|
79
|
-
resolve({ status, headers, body: null });
|
|
80
|
-
}
|
|
81
|
-
catch {
|
|
82
|
-
reject(new Error());
|
|
83
|
-
}
|
|
84
|
-
finally {
|
|
85
|
-
response.destroy();
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
req.once('error', () => reject(new Error()));
|
|
89
|
-
req.once('upgrade', (_response, socket) => { socket.destroy(); reject(new Error()); });
|
|
90
|
-
req.end();
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
throw new Error('Preview request failed');
|
|
95
|
-
}
|
|
96
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
-
const INTERVAL_MS = 30_000;
|
|
3
|
-
const DEADLINE_MS = 8 * 60_000;
|
|
4
|
-
/** One publication deadline per setup PR, including time spent checking the preview. */
|
|
5
|
-
export function createPublicationWait(dependencies = {}) {
|
|
6
|
-
const now = dependencies.now ?? (() => performance.now());
|
|
7
|
-
const pause = dependencies.sleep ?? ((ms, signal) => sleep(ms, undefined, { signal }));
|
|
8
|
-
const starts = new Map();
|
|
9
|
-
return {
|
|
10
|
-
begin(pr) {
|
|
11
|
-
if (!starts.has(pr))
|
|
12
|
-
starts.set(pr, now());
|
|
13
|
-
},
|
|
14
|
-
remainingMs(pr) {
|
|
15
|
-
const start = starts.get(pr);
|
|
16
|
-
return start === undefined ? null : Math.max(0, DEADLINE_MS - (now() - start));
|
|
17
|
-
},
|
|
18
|
-
async wait(pr, signal) {
|
|
19
|
-
const start = starts.get(pr);
|
|
20
|
-
if (start === undefined)
|
|
21
|
-
return null;
|
|
22
|
-
const result = (status) => {
|
|
23
|
-
const elapsed = Math.max(0, now() - start);
|
|
24
|
-
return {
|
|
25
|
-
status,
|
|
26
|
-
waited_seconds: Math.floor(elapsed / 1000),
|
|
27
|
-
remaining_seconds: Math.ceil(Math.max(0, DEADLINE_MS - elapsed) / 1000),
|
|
28
|
-
};
|
|
29
|
-
};
|
|
30
|
-
if (signal?.aborted)
|
|
31
|
-
return result('cancelled');
|
|
32
|
-
const remaining = DEADLINE_MS - (now() - start);
|
|
33
|
-
if (remaining <= 0)
|
|
34
|
-
return result('timed-out');
|
|
35
|
-
try {
|
|
36
|
-
await pause(Math.min(INTERVAL_MS, remaining), signal);
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
return result('cancelled');
|
|
40
|
-
}
|
|
41
|
-
if (signal?.aborted)
|
|
42
|
-
return result('cancelled');
|
|
43
|
-
return result(now() - start >= DEADLINE_MS ? 'timed-out' : 'retry');
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
|
-
}
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
import { constants } from 'node:fs';
|
|
3
|
-
import { lstat, open, realpath } from 'node:fs/promises';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
const METADATA_LIMIT = 16 * 1024;
|
|
6
|
-
const OUTPUT_LIMIT = 2 * 1024 * 1024;
|
|
7
|
-
function record(value) {
|
|
8
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
9
|
-
? value : undefined;
|
|
10
|
-
}
|
|
11
|
-
/** Only the root's small project-link metadata is read; CLI authentication stays with the CLI. */
|
|
12
|
-
async function linkedProject(cwd) {
|
|
13
|
-
try {
|
|
14
|
-
const root = await realpath(cwd);
|
|
15
|
-
const directory = join(root, '.vercel');
|
|
16
|
-
const directoryStat = await lstat(directory);
|
|
17
|
-
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || await realpath(directory) !== directory)
|
|
18
|
-
return;
|
|
19
|
-
const path = join(directory, 'project.json');
|
|
20
|
-
if (!(await lstat(path)).isFile())
|
|
21
|
-
return;
|
|
22
|
-
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
23
|
-
try {
|
|
24
|
-
const stat = await file.stat();
|
|
25
|
-
const currentDirectory = await lstat(directory);
|
|
26
|
-
const currentFile = await lstat(path);
|
|
27
|
-
if (!stat.isFile() || stat.size > METADATA_LIMIT
|
|
28
|
-
|| currentDirectory.dev !== directoryStat.dev || currentDirectory.ino !== directoryStat.ino
|
|
29
|
-
|| currentFile.isSymbolicLink() || currentFile.dev !== stat.dev || currentFile.ino !== stat.ino
|
|
30
|
-
|| await realpath(path) !== path)
|
|
31
|
-
return;
|
|
32
|
-
const buffer = Buffer.alloc(METADATA_LIMIT + 1);
|
|
33
|
-
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
34
|
-
if (bytesRead > METADATA_LIMIT)
|
|
35
|
-
return;
|
|
36
|
-
const metadata = record(JSON.parse(buffer.toString('utf8', 0, bytesRead)));
|
|
37
|
-
if (typeof metadata?.projectId !== 'string' || !/^prj_[A-Za-z0-9]{1,128}$/.test(metadata.projectId)
|
|
38
|
-
|| typeof metadata.orgId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(metadata.orgId))
|
|
39
|
-
return;
|
|
40
|
-
return { root, projectId: metadata.projectId, orgId: metadata.orgId };
|
|
41
|
-
}
|
|
42
|
-
finally {
|
|
43
|
-
await file.close();
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
/** Reuse one existing default automation bypass in memory, without exposing project responses or keys. */
|
|
51
|
-
export async function useExistingVercelBypass(input, dependencies) {
|
|
52
|
-
const { signal } = dependencies;
|
|
53
|
-
if (signal?.aborted)
|
|
54
|
-
return { status: 'cancelled' };
|
|
55
|
-
if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(input.owner)
|
|
56
|
-
|| !/^[a-zA-Z0-9_.-]{1,100}$/.test(input.repo) || input.repo === '.' || input.repo === '..') {
|
|
57
|
-
return { status: 'project-mismatch' };
|
|
58
|
-
}
|
|
59
|
-
const linked = await linkedProject(input.cwd);
|
|
60
|
-
if (signal?.aborted)
|
|
61
|
-
return { status: 'cancelled' };
|
|
62
|
-
if (!linked)
|
|
63
|
-
return { status: 'not-linked' };
|
|
64
|
-
const runVercel = dependencies.runVercel ?? ((args) => new Promise((resolve, reject) => {
|
|
65
|
-
const child = execFile('vercel', args, {
|
|
66
|
-
cwd: linked.root, encoding: 'utf8', timeout: 30_000, maxBuffer: OUTPUT_LIMIT, signal,
|
|
67
|
-
}, (error, stdout) => error ? reject(new Error('Vercel project lookup failed.')) : resolve(stdout));
|
|
68
|
-
child.stdin?.end();
|
|
69
|
-
}));
|
|
70
|
-
// teamId selects the linked team. Legacy personal projects omit it and still require accountId to match.
|
|
71
|
-
const route = `/v9/projects/${linked.projectId}${linked.orgId.startsWith('team_') ? `?teamId=${linked.orgId}` : ''}`;
|
|
72
|
-
let selected;
|
|
73
|
-
try {
|
|
74
|
-
const stdout = await runVercel(['api', route, '--method', 'GET', '--raw', '--non-interactive'], signal);
|
|
75
|
-
if (signal?.aborted)
|
|
76
|
-
return { status: 'cancelled' };
|
|
77
|
-
if (Buffer.byteLength(stdout, 'utf8') > OUTPUT_LIMIT)
|
|
78
|
-
return { status: 'unavailable' };
|
|
79
|
-
const project = record(JSON.parse(stdout));
|
|
80
|
-
const link = record(project?.link);
|
|
81
|
-
if (project?.id !== linked.projectId || project.accountId !== linked.orgId
|
|
82
|
-
|| !link || !['github', 'github-limited'].includes(String(link.type))
|
|
83
|
-
|| typeof link.org !== 'string' || link.org.toLowerCase() !== input.owner.toLowerCase()
|
|
84
|
-
|| typeof link.repo !== 'string' || link.repo.toLowerCase() !== input.repo.toLowerCase()) {
|
|
85
|
-
return { status: 'project-mismatch' };
|
|
86
|
-
}
|
|
87
|
-
const bypasses = record(project.protectionBypass);
|
|
88
|
-
if (!bypasses || Object.keys(bypasses).length > 128)
|
|
89
|
-
return { status: 'unavailable' };
|
|
90
|
-
const candidates = [];
|
|
91
|
-
for (const [value, metadata] of Object.entries(bypasses)) {
|
|
92
|
-
const bypass = record(metadata);
|
|
93
|
-
if (bypass?.scope !== 'automation-bypass')
|
|
94
|
-
continue;
|
|
95
|
-
if (!/^[A-Za-z0-9]{32}$/.test(value)
|
|
96
|
-
|| (bypass.isEnvVar !== undefined && typeof bypass.isEnvVar !== 'boolean'))
|
|
97
|
-
return { status: 'unavailable' };
|
|
98
|
-
// An omitted isEnvVar is the legacy default. Explicit false keys must never be chosen implicitly.
|
|
99
|
-
if (bypass.isEnvVar !== false)
|
|
100
|
-
candidates.push(value);
|
|
101
|
-
}
|
|
102
|
-
if (candidates.length > 1)
|
|
103
|
-
return { status: 'ambiguous' };
|
|
104
|
-
if (candidates.length === 0)
|
|
105
|
-
return { status: 'unavailable' };
|
|
106
|
-
selected = candidates[0];
|
|
107
|
-
dependencies.rememberSecret(selected);
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
return { status: signal?.aborted ? 'cancelled' : 'unavailable' };
|
|
111
|
-
}
|
|
112
|
-
if (signal?.aborted)
|
|
113
|
-
return { status: 'cancelled' };
|
|
114
|
-
try {
|
|
115
|
-
const status = await dependencies.save(selected);
|
|
116
|
-
return { status: ['saved', 'save-failed', 'partial-save', 'cancelled'].includes(status) ? status : 'save-failed' };
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
return { status: signal?.aborted ? 'cancelled' : 'save-failed' };
|
|
120
|
-
}
|
|
121
|
-
}
|