@impeccable/reviews 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +68 -0
  2. package/dist/agent.js +183 -0
  3. package/dist/bin.js +11 -0
  4. package/dist/fence.js +1071 -0
  5. package/dist/focus.js +98 -0
  6. package/dist/host-questions.js +71 -0
  7. package/dist/hosted.js +195 -0
  8. package/dist/http.js +36 -0
  9. package/dist/log.js +38 -0
  10. package/dist/login.js +276 -0
  11. package/dist/main.js +252 -0
  12. package/dist/netlify.js +333 -0
  13. package/dist/playbook/cases/ci-tunnel.md +21 -0
  14. package/dist/playbook/cases/first-preview-failure.md +28 -0
  15. package/dist/playbook/cases/netlify-previews.md +17 -0
  16. package/dist/playbook/cases/netlify-setup.md +24 -0
  17. package/dist/playbook/cases/preview-protection.md +21 -0
  18. package/dist/playbook/cases/pull-requests.md +31 -0
  19. package/dist/playbook/cases/sign-in.md +21 -0
  20. package/dist/playbook/cases/static-site.md +16 -0
  21. package/dist/playbook/cases/vercel-previews.md +16 -0
  22. package/dist/playbook/cases/vercel-setup.md +27 -0
  23. package/dist/playbook/cases/workflow-as-person.md +23 -0
  24. package/dist/playbook/github-previews.js +101 -0
  25. package/dist/playbook/netlify-pattern.js +24 -0
  26. package/dist/playbook/netlify-previews.js +144 -0
  27. package/dist/playbook/probe-preview.js +67 -0
  28. package/dist/playbook/protection-secrets.js +15 -0
  29. package/dist/playbook/public-preview-request.js +96 -0
  30. package/dist/playbook/publication-wait.js +46 -0
  31. package/dist/playbook/vercel-protection.js +121 -0
  32. package/dist/playbook.js +98 -0
  33. package/dist/preflight.js +211 -0
  34. package/dist/stages.js +34 -0
  35. package/dist/sync.js +142 -0
  36. package/dist/tools.js +2015 -0
  37. package/dist/transcript.js +139 -0
  38. package/dist/ui/bridge.js +145 -0
  39. package/dist/ui/frames.js +62 -0
  40. package/dist/ui/look.js +368 -0
  41. package/dist/ui/run-state.js +91 -0
  42. package/dist/ui/screens.js +544 -0
  43. package/dist/ui/sensitive.js +14 -0
  44. package/dist/vercel-bypass.js +136 -0
  45. package/dist/vercel.js +70 -0
  46. package/package.json +39 -0
package/dist/tools.js ADDED
@@ -0,0 +1,2015 @@
1
+ import { promisify } from 'node:util';
2
+ import { setTimeout as sleep } from 'node:timers/promises';
3
+ import { execFile, spawn } from 'node:child_process';
4
+ import { createHash, randomBytes } from 'node:crypto';
5
+ import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, symlink, unlink, writeFile } from 'node:fs/promises';
6
+ import { request } from 'node:http';
7
+ import { createServer } from 'node:net';
8
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
9
+ import { defineTool } from '@earendil-works/pi-coding-agent';
10
+ import { agreeToHostSetup, bindPreviewPullRequest, chooseHostTeam, classifyPreviewCommand, hostSetupAgreed, isSecretFile, observePreviewAttempts, parseCommand, previewRetryState } from './fence.js';
11
+ import { requestJson } from './http.js';
12
+ import { focusCheckup, focusState } from './focus.js';
13
+ import { HOST_QUESTIONS, HOST_SCREENS } from './host-questions.js';
14
+ import { previewHostName, provePreview, readHostedPreviews } from './hosted.js';
15
+ import { commandRunner, connectNetlifySite, netlifyStatus, netlifyTeams, readNetlifyPreviews, waitForNetlifyAccess } from './netlify.js';
16
+ import { connectVercelProject, vercelStatus, vercelTeams, waitForVercelAccess } from './vercel.js';
17
+ import { BYPASS_HEADER, passVercelLogin, readLink } from './vercel-bypass.js';
18
+ import { fitsTask, stagesOf } from './stages.js';
19
+ import { redact } from './log.js';
20
+ import { openBrowser } from './login.js';
21
+ import { TASK_STATUSES } from './sync.js';
22
+ import { askPerson, reportStatus, reportTasks, reportWait } from './ui/bridge.js';
23
+ import { SEE_THE_REVIEW } from './ui/look.js';
24
+ import { noteSensitiveAnswer } from './ui/sensitive.js';
25
+ /** The first way `value` breaks `schema`, or null when it fits. */
26
+ export function schemaError(schema, value, at = 'input') {
27
+ const types = schema.type === undefined ? [] : [schema.type].flat();
28
+ const kind = value === null ? 'null' : Array.isArray(value) ? 'array' : Number.isInteger(value) ? 'integer' : typeof value;
29
+ if (types.length && !types.some((t) => t === kind || (t === 'number' && kind === 'integer')))
30
+ return `${at} should be ${types.join(' or ')}`;
31
+ if (schema.enum && !schema.enum.includes(value))
32
+ return `${at} should be one of ${schema.enum.join(', ')}`;
33
+ if (typeof value === 'string' && schema.minLength !== undefined && value.trim().length < schema.minLength)
34
+ return `${at} should not be empty`;
35
+ if (typeof value === 'number' && schema.minimum !== undefined && value < schema.minimum)
36
+ return `${at} should be at least ${schema.minimum}`;
37
+ if (kind === 'array' && schema.items) {
38
+ for (const [i, item] of value.entries()) {
39
+ const error = schemaError(schema.items, item, `${at}[${i}]`);
40
+ if (error)
41
+ return error;
42
+ }
43
+ }
44
+ if (kind === 'object') {
45
+ const obj = value;
46
+ for (const key of schema.required ?? [])
47
+ if (obj[key] === undefined)
48
+ return `${at}.${key} is required`;
49
+ for (const [key, v] of Object.entries(obj)) {
50
+ const sub = schema.properties?.[key];
51
+ if (sub) {
52
+ const error = schemaError(sub, v, `${at}.${key}`);
53
+ if (error)
54
+ return error;
55
+ }
56
+ else if (schema.additionalProperties === false)
57
+ return `${at}.${key} is not allowed`;
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+ const MAX_ANSWER = 200_000;
63
+ /** What the model reads back: the API's JSON, or a short error. */
64
+ export function answerText(res) {
65
+ if (!res.ok)
66
+ return `Error${res.status ? ` ${res.status}` : ''}: ${res.error}`;
67
+ const text = typeof res.data === 'string' ? res.data : JSON.stringify(res.data, null, 2);
68
+ return text.length > MAX_ANSWER ? `${text.slice(0, MAX_ANSWER)}\n[cut at ${MAX_ANSWER} characters]` : text;
69
+ }
70
+ const endpointsOf = (instructions) => {
71
+ const endpoints = instructions?.endpoints;
72
+ return endpoints && typeof endpoints === 'object' ? endpoints : {};
73
+ };
74
+ /**
75
+ * The method and address for a write, from the instructions' own endpoints:
76
+ * only PUT and POST, only under /repos/{repo}/ for this repository. `path`
77
+ * can be the endpoint's tail ("preview"), its path, or its whole address.
78
+ */
79
+ export function resolveWrite(instructions, setupApi, repo, path) {
80
+ const api = new URL(setupApi);
81
+ const base = `${api.pathname.replace(/\/$/, '')}/repos/${encodeURIComponent(repo)}/`;
82
+ let pathname;
83
+ try {
84
+ const url = new URL(/^(\/|https?:)/i.test(path) ? path : `${base}${path.replace(/^\.?\//, '')}`, api.origin);
85
+ if (url.origin !== api.origin)
86
+ return { error: `${path} is not on the setup API. Writes go to ${api.origin}${base}…` };
87
+ pathname = url.pathname;
88
+ }
89
+ catch {
90
+ return { error: `"${path}" is not a path.` };
91
+ }
92
+ if (!pathname.startsWith(base))
93
+ return { error: `Writes go under ${base}…, and ${pathname} is outside it.` };
94
+ for (const line of Object.values(endpointsOf(instructions))) {
95
+ const m = typeof line === 'string' ? /^(PUT|POST)\s+(\S+)$/.exec(line.trim()) : null;
96
+ if (!m)
97
+ continue;
98
+ const template = m[2].replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]+/i, '');
99
+ if (template.replaceAll('{repo}', encodeURIComponent(repo)) === pathname)
100
+ return { method: m[1], url: `${api.origin}${pathname}` };
101
+ }
102
+ return { error: `No PUT or POST in the instructions' "endpoints" matches ${pathname}. Pick one from there.` };
103
+ }
104
+ const NO_INPUT = { type: 'object', properties: {}, additionalProperties: false };
105
+ /** The most a status line holds; longer text belongs in the report. */
106
+ export const STATUS_MAX = 70;
107
+ const STATUS_RULE = `Status lines are in the product voice: a present-tense verb phrase of at most ${STATUS_MAX} characters that says what is happening, such as "Running the build" or "Opening the setup pull request". No "I'm", "I am", "Let me", "I'll" or "trying to"; detail goes in the report. Nothing was saved: call impeccable_progress again with the status rephrased.`;
108
+ /** Why a status line breaks the voice, with the rule to rephrase it by; null when it keeps it. The line is never rewritten here. */
109
+ export function statusVoiceError(line) {
110
+ const text = line.trim();
111
+ const why = /^(i['’]m|i am|let me|i['’]ll)\b/i.test(text) || /\btrying to\b/i.test(text) ? 'it narrates like an assistant' : text.length > STATUS_MAX ? `it is ${text.length} characters long` : null;
112
+ return why ? `Refused: "${text}" does not fit the status line, because ${why}. ${STATUS_RULE}` : null;
113
+ }
114
+ /** 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. */
115
+ function stageError(status, tasks) {
116
+ const running = tasks.filter((t) => t.status === 'in_progress');
117
+ const again = 'Nothing was saved: call impeccable_progress again.';
118
+ if (running.length > 1)
119
+ return `Refused: ${running.map((t) => `"${t.title}"`).join(' and ')} are in progress at once. One task is in progress at a time. ${again}`;
120
+ const task = running[0];
121
+ if (!task || fitsTask(status, task.title))
122
+ return null;
123
+ return `Refused: the status "${status.trim()}" is about ${stagesOf(status).join(' and ')}, but the task in progress is "${task.title}". The status line is the detail of the task in progress: mark the task it is about in progress, or say what "${task.title}" is doing. ${again}`;
124
+ }
125
+ /** The line under the last screen's heading ("You're all set up", or "Settings saved" without a test drive), about the pull request that screen ends on: the setup pull request when there is one, else the test drive; and, where a host builds the previews and so no setup pull request was needed, one line saying so. */
126
+ export function resultLine(report) {
127
+ const lead = !report.testPr ? 'No test drive: the report says why.' : report.setupPr ? 'The setup pull request adds the workflow that lets Impeccable run on your previews. Merge it when you are ready.' : SEE_THE_REVIEW;
128
+ return report.setupPr || !report.hostedBy ? lead : `${lead} ${nothingToMerge(report.hostedBy)}`;
129
+ }
130
+ /** Said once, here and in the report, when the host builds the previews and the repository needed no change. */
131
+ export const nothingToMerge = (host) => `Nothing to merge: ${host} hosts your previews, so the repository needed no change.`;
132
+ const PROGRESS = {
133
+ type: 'object',
134
+ additionalProperties: false,
135
+ required: ['status', 'tasks'],
136
+ properties: {
137
+ status: { type: 'string', minLength: 1, description: 'The current [STATUS] line, without the tag.' },
138
+ tasks: {
139
+ type: 'array',
140
+ description: 'The whole task list, every time.',
141
+ items: {
142
+ type: 'object',
143
+ additionalProperties: false,
144
+ required: ['id', 'title', 'status'],
145
+ properties: { id: { type: 'string', minLength: 1 }, title: { type: 'string', minLength: 1 }, status: { type: 'string', enum: TASK_STATUSES } },
146
+ },
147
+ },
148
+ },
149
+ };
150
+ const REPORT = {
151
+ type: 'object',
152
+ additionalProperties: false,
153
+ required: ['markdown'],
154
+ properties: {
155
+ markdown: { type: 'string', minLength: 1, description: 'The report, as the playbook\'s report section gives it.' },
156
+ left: {
157
+ type: 'array',
158
+ description: `What waits on the person besides merging the setup pull request and closing the test drive: one line each, at most ${STATUS_MAX} characters, such as "Add the admin test account on the setup page". The terminal counts them. Leave it out when nothing does.`,
159
+ items: { type: 'string', minLength: 1 },
160
+ },
161
+ },
162
+ };
163
+ const WRITE = {
164
+ type: 'object',
165
+ additionalProperties: false,
166
+ required: ['path', 'body'],
167
+ properties: {
168
+ path: { type: 'string', minLength: 1, description: 'The endpoint\'s path under /repos/{repo}/ in the instructions, such as "preview".' },
169
+ body: { type: 'object', description: 'The body the instructions\' writes give for that endpoint.' },
170
+ },
171
+ };
172
+ const PULL_REQUEST = {
173
+ type: 'object',
174
+ additionalProperties: false,
175
+ required: ['number', 'url'],
176
+ properties: {
177
+ number: { type: 'integer', minimum: 1, description: 'The number gh pr create printed.' },
178
+ url: { type: 'string', minLength: 1, description: 'Its address, https://github.com/<owner>/<repo>/pull/<number>.' },
179
+ },
180
+ };
181
+ const TEST_PR = {
182
+ ...PULL_REQUEST,
183
+ properties: { ...PULL_REQUEST.properties, setup_pr: { ...PULL_REQUEST, description: 'The setup pull request, when there is one: this run\'s, or on a rerun the earlier run\'s.' } },
184
+ };
185
+ const HOSTED_PREVIEWS = {
186
+ type: 'object',
187
+ additionalProperties: false,
188
+ properties: {
189
+ branch: { type: 'string', minLength: 1, description: 'A branch of this run, once pushed and before its pull request exists, such as impeccable/test-drive: reads the deployments of its head only, and waits up to five minutes for one to finish building.' },
190
+ },
191
+ };
192
+ const NETLIFY_PREVIEWS = {
193
+ type: 'object',
194
+ additionalProperties: false,
195
+ properties: {
196
+ pull: { type: 'integer', minimum: 1, description: 'A pull request of this run, once pushed: reads its previews only, and waits up to five minutes for one to finish building.' },
197
+ },
198
+ };
199
+ const PROVE_PREVIEW = {
200
+ type: 'object',
201
+ additionalProperties: false,
202
+ required: ['url', 'expect'],
203
+ properties: {
204
+ url: { type: 'string', minLength: 1, description: 'An address impeccable_hosted_previews or impeccable_netlify_previews listed, or a page on it.' },
205
+ expect: { type: 'string', minLength: 1, description: 'Words the page shows that a file of this checkout holds, such as the app\'s name in its title.' },
206
+ },
207
+ };
208
+ const ASK = {
209
+ type: 'object',
210
+ additionalProperties: false,
211
+ required: ['id', 'prompt', 'kind'],
212
+ properties: {
213
+ id: { type: 'string', minLength: 1, description: 'A short id, such as "admin-password".' },
214
+ title: { type: 'string', minLength: 1, description: 'The dialog\'s title: the subject in a few words, such as "Admin account". A confirm\'s heading.' },
215
+ prompt: { type: 'string', minLength: 1, description: 'The question. A confirm\'s action line; an account\'s line under its title, such as "Reviews sign in with this account as admin."' },
216
+ hint: { type: 'string', minLength: 1, description: 'One line under the question that helps answer it. A confirm\'s text under its title, two or three short lines.' },
217
+ kind: {
218
+ type: 'string',
219
+ enum: ['text', 'single', 'multi', 'confirm', 'account'],
220
+ description: 'text: the person types the answer; single: they pick one option; multi: they pick any number; confirm: they go on or skip; account: one test account, its username and password on one screen, the password typed hidden.',
221
+ },
222
+ options: {
223
+ type: 'array',
224
+ description: 'single and multi: the choices, at least two.',
225
+ items: { type: 'object', additionalProperties: false, required: ['label', 'value'], properties: { label: { type: 'string', minLength: 1 }, value: { type: 'string', minLength: 1 } } },
226
+ },
227
+ prefill: { type: 'string', minLength: 1, description: 'text, not with sensitive, and account: what the (username) field holds when the dialog opens, such as the seed\'s username. The person can change it.' },
228
+ confirmLabel: { type: 'string', minLength: 1, description: 'confirm only: the way on, such as "Share test accounts".' },
229
+ skipLabel: { type: 'string', minLength: 1, description: 'confirm only: the other way, such as "Skip for now".' },
230
+ skippable: { type: 'boolean', description: 'Every question offers Skip, whatever this says.' },
231
+ sensitive: { type: 'boolean', description: 'text only: a password. The person types it hidden, and the answer\'s value is a reference ("secret:…") that impeccable_write turns back into what they typed.' },
232
+ },
233
+ };
234
+ const NETLIFY_CONNECT = {
235
+ type: 'object',
236
+ additionalProperties: false,
237
+ properties: {
238
+ command: { type: 'string', minLength: 1, description: "The build command Netlify runs, only where no netlify.toml sets one: the package's build script." },
239
+ publish: { type: 'string', minLength: 1, description: 'The folder the build writes the pages to, relative to the checkout, only where no netlify.toml sets one.' },
240
+ },
241
+ };
242
+ /** What schemaError cannot say about a question: options belong to single and multi, at least two, each value once, and each other field to its kinds. */
243
+ function askError(q) {
244
+ const values = (q.options ?? []).map((o) => o.value);
245
+ if (q.sensitive && q.kind !== 'text')
246
+ return 'input.sensitive is for text questions only';
247
+ if (q.prefill !== undefined && !(q.kind === 'account' || (q.kind === 'text' && !q.sensitive)))
248
+ return 'input.prefill is for account questions and text questions that are not sensitive';
249
+ if ((q.confirmLabel ?? q.skipLabel) !== undefined && q.kind !== 'confirm')
250
+ return 'input.confirmLabel and input.skipLabel are for confirm only';
251
+ if (q.kind === 'text' || q.kind === 'confirm' || q.kind === 'account')
252
+ return q.options ? 'input.options is for single and multi only' : null;
253
+ if (values.length < 2)
254
+ return `input.options needs at least two choices for ${q.kind}`;
255
+ return new Set(values).size === values.length ? null : 'input.options has a value twice';
256
+ }
257
+ /** Why `url` is not pull request `number` of owner/repo on GitHub; null when it is. */
258
+ function pullRequestError(url, number, owner, repo) {
259
+ const m = URL.canParse(url) ? /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/.exec(new URL(url).pathname) : null;
260
+ const ok = m && new URL(url).origin === 'https://github.com' && m[1].toLowerCase() === owner.toLowerCase() && m[2].toLowerCase() === repo.toLowerCase() && Number(m[3]) === number;
261
+ return ok ? null : `${url} is not pull request #${number} of ${owner}/${repo} on GitHub`;
262
+ }
263
+ /** This run's open pull request from `branch`, its head in this repository rather than a fork, with that head's name; or why it is not. */
264
+ async function runPullRequest(ctx, pr, branch, signal) {
265
+ const error = pullRequestError(pr.url, pr.number, ctx.owner, ctx.repo);
266
+ if (error)
267
+ return { problem: error };
268
+ const found = await ctx.viewPullRequest(pr.number);
269
+ if ('error' in found)
270
+ return { problem: `gh pr view ${pr.number} did not find it (${found.error})` };
271
+ if (!found.headRefName.startsWith(branch))
272
+ return { problem: `#${pr.number} comes from ${found.headRefName}, not from ${branch}` };
273
+ if (found.state !== 'OPEN')
274
+ return { problem: `#${pr.number} is ${found.state.toLowerCase()}, not open` };
275
+ // Impeccable reviews a draft only once it is marked ready, so the web page would follow a review that never starts.
276
+ if (found.isDraft)
277
+ return { problem: `#${pr.number} is still a draft: mark it ready (gh pr ready ${found.headRefName} --repo ${ctx.owner}/${ctx.repo}) once its preview is proven, then record it` };
278
+ if (!await (ctx.setupPullHeadReader ?? readSetupPullHead)(ctx.owner, ctx.repo, pr.number, found.headRefName, { signal })) {
279
+ return { problem: `the branch of #${pr.number} could not be verified in this repository; a fork's branch cannot be recorded` };
280
+ }
281
+ return { branch: found.headRefName };
282
+ }
283
+ const PREVIEW_COMMAND = { type: 'object', additionalProperties: false, required: ['command'], properties: {
284
+ command: { type: 'string', minLength: 1, description: 'A command of the repository\'s own scripts or tools, without cd.' },
285
+ directory: { type: 'string', minLength: 1, description: 'The folder it runs in, relative to the checkout; the root when left out.' },
286
+ } };
287
+ const LOCAL_PREVIEW = {
288
+ type: 'object', additionalProperties: false, required: ['steps', 'start', 'port', 'paths', 'host'],
289
+ properties: {
290
+ steps: { type: 'array', description: 'The steps before the app starts, in order: install, prepare, build, seed for a server; install, prepare, seed, build for a static site. Repeat a phase for another folder.', items: { ...PREVIEW_COMMAND, required: ['phase', 'command'], properties: { ...PREVIEW_COMMAND.properties, phase: { type: 'string', enum: ['install', 'prepare', 'build', 'seed'] } } } },
291
+ start: { ...PREVIEW_COMMAND, description: 'Server: the command that starts the app.' },
292
+ port: { type: 'integer', minimum: 1024, description: 'Server: the port the app listens on.' },
293
+ paths: { type: 'array', description: 'Server: up to eight routes to request, / among them.', items: { type: 'string', minLength: 1 } },
294
+ host: { type: 'string', minLength: 1, description: 'Server: a host name like the tunnel\'s, such as t-crewline.impeccable.reviews; the app must answer it as well as localhost.' },
295
+ env: { type: 'object', description: 'Literal, non-secret variables. DATABASE_URL is a relative SQLite file (file:./preview.db), or comes from service.' },
296
+ service: { type: 'string', enum: ['postgres', 'mysql'], description: 'A throwaway database container; the steps get its DATABASE_URL.' },
297
+ },
298
+ };
299
+ const STATIC_OUTPUT = {
300
+ type: 'object', additionalProperties: false, required: ['directory'], description: 'Static site: where the build writes the pages.', properties: {
301
+ directory: { type: 'string', minLength: 1, description: 'The folder, relative to the checkout; "." for the root.' },
302
+ basePath: { type: 'string', description: 'The path the site is served under, such as /docs, when it is not /.' },
303
+ entries: { type: 'array', description: 'Pages besides index.html that must exist.', items: { type: 'string', minLength: 1 } },
304
+ },
305
+ };
306
+ const STATIC_PREVIEW = {
307
+ type: 'object', additionalProperties: false, required: ['steps', 'output'], properties: {
308
+ steps: LOCAL_PREVIEW.properties.steps, output: STATIC_OUTPUT,
309
+ env: LOCAL_PREVIEW.properties.env, service: LOCAL_PREVIEW.properties.service,
310
+ },
311
+ };
312
+ // The tool schema exposes both alternatives; the helper validates the selected strict shape.
313
+ const PREVIEW_VERIFICATION = {
314
+ ...LOCAL_PREVIEW, required: ['steps'], properties: { ...LOCAL_PREVIEW.properties, output: STATIC_OUTPUT },
315
+ };
316
+ const contained = (path, root) => { const r = relative(root, path); return r !== '..' && !r.startsWith('../') && !isAbsolute(r); };
317
+ /** Canonical relative file paths only; URL encoding and alternate separators are ambiguous here. */
318
+ const staticRelativePath = (path) => path.length <= 400 && !/[\\%?#:\x00-\x1f\x7f]/.test(path) && path.split('/').every((part) => part.length > 0 && part !== '.' && part !== '..');
319
+ function staticOutputLayout(output) {
320
+ if (schemaError(STATIC_OUTPUT, output) || (output.directory !== '.' && !staticRelativePath(output.directory)))
321
+ return null;
322
+ const base = output.basePath || '/';
323
+ if (base.length > 200 || (base !== '/' && (!base.startsWith('/') || !staticRelativePath(base.slice(1)))))
324
+ return null;
325
+ if ((output.entries?.length ?? 0) > 7 || output.entries?.some((entry) => !staticRelativePath(entry)))
326
+ return null;
327
+ const prefix = base === '/' ? '' : base.slice(1) + '/';
328
+ return { directory: output.directory, entries: [...new Set(['index.html', ...(output.entries ?? [])])].map((entry) => prefix + entry) };
329
+ }
330
+ /** Entry availability only: no HTML/body parsing, routing, rendering or upload claim. */
331
+ export async function inspectStaticPreviewOutput(root, output) {
332
+ const fail = (reason) => ({ verified: false, reason, files: [] });
333
+ const layout = staticOutputLayout(output);
334
+ if (!layout)
335
+ return fail('Use canonical relative output and entry paths and a slash-prefixed base path.');
336
+ try {
337
+ root = await realpath(root);
338
+ const directory = resolve(root, layout.directory);
339
+ if (!contained(directory, root))
340
+ return fail('The static output leaves the isolated checkout.');
341
+ // Refuse every symlink along the selected paths: regular archive uploads need real entries.
342
+ const check = async (path, file) => {
343
+ const parts = relative(root, path).split('/').filter(Boolean);
344
+ let current = root;
345
+ for (const [index, part] of parts.entries()) {
346
+ if (!(await readdir(current)).includes(part))
347
+ return null;
348
+ current = join(current, part);
349
+ const info = await lstat(current);
350
+ if (info.isSymbolicLink() || !contained(await realpath(current), root))
351
+ return null;
352
+ if (index < parts.length - 1 || !file) {
353
+ if (!info.isDirectory())
354
+ return null;
355
+ }
356
+ else if (!info.isFile() || info.size === 0)
357
+ return null;
358
+ }
359
+ return lstat(path);
360
+ };
361
+ if (!await check(directory, false))
362
+ return fail('The static output must be a directory without symlink components.');
363
+ const files = [];
364
+ for (const entry of layout.entries) {
365
+ const path = resolve(directory, entry), info = await check(path, true);
366
+ if (!info)
367
+ return fail('A required static entry is empty, unsupported or reached through a symlink.');
368
+ files.push({ path: relative(root, path), bytes: info.size });
369
+ }
370
+ return { verified: true, files };
371
+ }
372
+ catch {
373
+ return fail('The output directory or a required nonempty entry file is missing or unavailable at the selected base path.');
374
+ }
375
+ }
376
+ const previewExcluded = (path) => path.split('/').some((p) => isSecretFile(p) || p.startsWith('.dev.vars') || /^(?:\.git|node_modules|\.venv|venv|\.wrangler|\.bundle|\.npmrc|\.yarnrc|\.pypirc|\.netrc|\.ssh|\.aws|\.config)$/.test(p) || /(?:^credentials(?:\.|$)|^master\.key$|\.(?:pem|p12|pfx|key)$|\.(?:db|sqlite|sqlite3)(?:-(?:wal|shm|journal))?$)/i.test(p));
377
+ const previewPhases = ['install', 'prepare', 'build', 'seed'];
378
+ const usesLocalD1 = (command) => {
379
+ const calls = (parseCommand(command) ?? []).filter(({ words }) => words.some((w, i) => w === 'wrangler' && words[i + 1] === 'd1'));
380
+ return calls.length > 0 && calls.every(({ words }) => words.includes('--local') && !words.some((w) => w.startsWith('--local=')));
381
+ };
382
+ /** Stop only a process group this invocation created, including shell grandchildren. */
383
+ async function stopPreviewChild(child) {
384
+ if (!child.pid)
385
+ return;
386
+ try {
387
+ process.kill(-child.pid, 'SIGTERM');
388
+ }
389
+ catch {
390
+ return;
391
+ }
392
+ await sleep(100);
393
+ try {
394
+ process.kill(-child.pid, 'SIGKILL');
395
+ }
396
+ catch { /* already stopped */ }
397
+ }
398
+ /** Local reproduction only: no credentials, external database, response body or process output leaves this helper. */
399
+ export async function verifyLocalPreview(input, root, signal) {
400
+ const probes = [];
401
+ const fail = (stage, reason) => ({ verified: false, stage, reason, probes });
402
+ if (process.platform === 'win32')
403
+ return fail('environment', 'Local preview verification is unavailable on Windows.');
404
+ const staticInput = input && typeof input === 'object' && 'output' in input ? input : undefined;
405
+ if (schemaError(staticInput ? STATIC_PREVIEW : LOCAL_PREVIEW, input))
406
+ return fail('input', 'Invalid local preview input.');
407
+ const serverInput = staticInput ? undefined : input;
408
+ const layout = staticInput ? staticOutputLayout(staticInput.output) : undefined;
409
+ if (staticInput && !layout)
410
+ return fail('input', 'Use canonical relative output and entry paths and a slash-prefixed base path.');
411
+ const generatedOutput = layout && input.steps.some((step) => step.phase === 'build') ? layout.directory : undefined;
412
+ if (signal?.aborted)
413
+ return fail('environment', 'Verification was cancelled.');
414
+ if (serverInput && (serverInput.port > 65535 || !serverInput.paths.length || serverInput.paths.length > 8 || !serverInput.paths.includes('/') || serverInput.paths.some((p) => !/^\/(?!\/)[^\r\n?#]*$/.test(p)) || !/^(?!localhost$)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z][a-z0-9.-]*$/i.test(serverInput.host)))
415
+ return fail('input', 'Use a valid local port, a foreign hostname and up to eight route paths including /.');
416
+ let last = -1;
417
+ if (input.steps.length > 16)
418
+ return fail('input', 'Use at most sixteen local verification steps.');
419
+ const phases = staticInput ? ['install', 'prepare', 'seed', 'build'] : previewPhases;
420
+ for (const step of input.steps) {
421
+ const next = phases.indexOf(step.phase);
422
+ if (next < last)
423
+ return fail('input', `Steps must follow ${phases.join(', ')} order.`);
424
+ last = next;
425
+ }
426
+ const supplied = input.env ?? {};
427
+ if (Object.keys(supplied).length > 64 || Object.entries(supplied).some(([key, value]) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof value !== 'string' || value.length > 2048 || /\$\{|\$[A-Za-z_]|[\r\n\0]/.test(value) || /(?:TOKEN|PASSWORD|PASSWD|SECRET|API_KEY|MASTER_KEY|CREDENTIAL|AUTHORIZATION|COOKIE)/i.test(key) || /^(?:PATH|HOME|TMP|TEMP|TMPDIR|SHELL|ENV|BASH_ENV|NODE_OPTIONS|NODE_PATH|PYTHONPATH|PYTHONSTARTUP|RUBYOPT|RUBYLIB|LD_.*|DYLD_.*|XDG_.*|GIT_.*|BUNDLE_.*|NPM_CONFIG_.*|PIP_.*)$/i.test(key)))
428
+ return fail('environment', 'Only literal non-secret preview variables are supported; credentials and secret references cannot be used locally.');
429
+ if (Object.keys(supplied).some((k) => k !== 'DATABASE_URL' && /^(?:DB_|DATABASE_|PGHOST|PGPORT|PGDATABASE|PGUSER|MYSQL_)/i.test(k)))
430
+ return fail('database', 'Use the verifier-owned database through DATABASE_URL; other database overrides are unsupported.');
431
+ if (serverInput && supplied.PORT !== undefined && supplied.PORT !== String(serverInput.port))
432
+ return fail('environment', 'The PORT variable must match the port being verified.');
433
+ const sqlitePath = /^(?:file:|sqlite3:|sqlite:\/\/\/)((?!\/)[A-Za-z0-9_./-]+)$/.exec(supplied.DATABASE_URL ?? '')?.[1];
434
+ const localSqlite = Boolean(sqlitePath && !sqlitePath.split('/').includes('..'));
435
+ if (!input.service && supplied.DATABASE_URL && !localSqlite)
436
+ return fail('database', 'DATABASE_URL must name a relative SQLite file or a verifier-owned database service.');
437
+ let scratch;
438
+ let server;
439
+ let container;
440
+ let containerStarted = false;
441
+ const ownership = randomBytes(16).toString('hex');
442
+ let stage = 'snapshot';
443
+ let env = { PATH: process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', CI: '1', NO_COLOR: '1', GIT_TERMINAL_PROMPT: '0' };
444
+ // Capture is reserved for filenames, package manifests and Docker metadata, never app logs.
445
+ const run = (command, args, cwd, capture = false, timeout = 120_000, cancellation = signal ?? null, processEnv = env) => new Promise((done) => {
446
+ if (cancellation?.aborted)
447
+ return done({ ok: false, output: '' });
448
+ let output = '', failed = false;
449
+ const child = spawn(command, args, { cwd, env: processEnv, detached: true, stdio: ['ignore', capture ? 'pipe' : 'ignore', 'ignore'] });
450
+ const cancel = () => { failed = true; void stopPreviewChild(child); };
451
+ const timer = setTimeout(cancel, timeout);
452
+ cancellation?.addEventListener('abort', cancel, { once: true });
453
+ child.stdout?.on('data', (chunk) => { if (output.length + chunk.length > 2_000_000)
454
+ cancel();
455
+ else
456
+ output += chunk.toString(); });
457
+ const finish = async (ok) => { clearTimeout(timer); cancellation?.removeEventListener('abort', cancel); await stopPreviewChild(child); done({ ok: ok && !failed, output }); };
458
+ child.once('error', () => { failed = true; });
459
+ child.once('close', (code) => void finish(code === 0));
460
+ });
461
+ const probe = (path, host) => new Promise((done) => {
462
+ const req = request({ hostname: '127.0.0.1', port: serverInput.port, path, headers: { host }, method: 'GET', timeout: 2000, signal }, (res) => { const status = res.statusCode ?? 0; res.destroy(); done(status); });
463
+ req.on('timeout', () => req.destroy());
464
+ req.on('error', () => done(0));
465
+ req.end();
466
+ });
467
+ try {
468
+ root = await realpath(root);
469
+ if (generatedOutput !== undefined) {
470
+ const output = resolve(root, generatedOutput);
471
+ // Remove only a separate output subtree from the copy, never source or a phase's cwd.
472
+ if (output === root)
473
+ return fail('output', 'A generated build needs a separate output directory.');
474
+ // Case-insensitive filesystems must not copy Dist as stale output for dist.
475
+ let parent = root;
476
+ for (const part of generatedOutput.split('/')) {
477
+ const names = await readdir(parent), next = join(parent, part);
478
+ const info = await lstat(next).catch((error) => { if (error.code === 'ENOENT')
479
+ return null; throw error; });
480
+ if (!info)
481
+ break; // No existing subtree below a genuinely absent component.
482
+ if (!names.includes(part) || info.isSymbolicLink() || !info.isDirectory())
483
+ return fail('output', 'Use an output directory with exact on-disk spelling and no symlink components.');
484
+ parent = next;
485
+ }
486
+ for (const step of input.steps) {
487
+ const cwd = await realpath(resolve(root, step.directory ?? '.'));
488
+ if (!contained(cwd, root) || contained(cwd, output))
489
+ return fail('output', 'The generated output overlaps a command directory or leaves the checkout.');
490
+ }
491
+ }
492
+ // Under /tmp, not the OS temp folder: the steps' temp folder is inside, and a socket there (tsx opens one) must fit the 103 bytes macOS allows.
493
+ scratch = await realpath(await mkdtemp(join('/tmp', 'impeccable-preview-')));
494
+ const snapshot = join(scratch, 'checkout'), home = join(scratch, 'home'), temp = join(scratch, 'tmp');
495
+ await Promise.all([snapshot, home, temp].map((p) => mkdir(p, { mode: 0o700 })));
496
+ env = { ...env, ...supplied, ...(serverInput ? { PORT: String(serverInput.port) } : {}), HOME: home, TMPDIR: temp, TMP: temp, TEMP: temp, XDG_CONFIG_HOME: join(home, '.config'), npm_config_cache: join(scratch, 'npm'), npm_config_userconfig: join(home, '.npmrc'), npm_config_globalconfig: join(scratch, 'global-npmrc'), PIP_CONFIG_FILE: '/dev/null', PIP_NO_INPUT: '1', PIP_DISABLE_PIP_VERSION_CHECK: '1', PIP_REQUIRE_VIRTUALENV: 'true', BUNDLE_PATH: join(scratch, 'bundle'), BUNDLE_APP_CONFIG: join(scratch, 'bundle-config') };
497
+ const listing = await run('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], root, true);
498
+ if (!listing.ok)
499
+ return fail(stage, 'Could not list checkout files.');
500
+ const files = [...new Set(listing.output.split('\0').filter(Boolean))];
501
+ if (files.length > 10_000)
502
+ return fail(stage, 'The checkout is too large for bounded local verification.');
503
+ let scriptsChanged = false;
504
+ for (const file of files) {
505
+ if (previewExcluded(file) || (generatedOutput !== undefined && (file === generatedOutput || file.startsWith(generatedOutput + '/'))))
506
+ continue;
507
+ // An explicitly named SQLite file may have no extension. Never copy its prior data.
508
+ const databaseName = sqlitePath?.split('/').at(-1);
509
+ if (databaseName && [databaseName, `${databaseName}-wal`, `${databaseName}-shm`, `${databaseName}-journal`].includes(file.split('/').at(-1)))
510
+ continue;
511
+ const source = resolve(root, file), target = resolve(snapshot, file);
512
+ if (!contained(source, root) || !contained(target, snapshot))
513
+ return fail(stage, 'A checkout path leaves the repository.');
514
+ const info = await lstat(source).catch(() => null);
515
+ if (!info)
516
+ continue; // a tracked deletion is part of the working checkout
517
+ const actual = await realpath(source);
518
+ if (!contained(actual, root))
519
+ return fail(stage, 'A checkout symlink leaves the repository.');
520
+ if (previewExcluded(relative(root, actual)))
521
+ continue;
522
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
523
+ if (info.isSymbolicLink()) {
524
+ if (file.split('/').at(-1) === 'package.json')
525
+ scriptsChanged = true;
526
+ await symlink(relative(dirname(target), resolve(snapshot, relative(root, actual))), target);
527
+ continue;
528
+ }
529
+ if (!info.isFile())
530
+ return fail(stage, 'The checkout contains an unsupported file.');
531
+ // Registry configuration can contain live credentials even when committed.
532
+ if (/^\.yarnrc\.ya?ml$/.test(file.split('/').at(-1)) && /(?:authToken|authIdent|password):/i.test(await readFile(source, 'utf8')))
533
+ continue;
534
+ await copyFile(source, target);
535
+ await chmod(target, info.mode & 0o777);
536
+ if (file.split('/').at(-1) === 'package.json') {
537
+ const baseline = await run('git', ['show', `HEAD:${file}`], root, true);
538
+ const scripts = (value) => Object.entries(JSON.parse(value).scripts ?? {}).sort(([a], [b]) => a.localeCompare(b));
539
+ scriptsChanged ||= !baseline.ok || JSON.stringify(scripts(baseline.output)) !== JSON.stringify(scripts(await readFile(source, 'utf8')));
540
+ }
541
+ }
542
+ const steps = [...input.steps, ...(serverInput ? [{ ...serverInput.start, phase: 'start' }] : [])];
543
+ const directories = [];
544
+ for (const step of steps) {
545
+ const cwd = await realpath(resolve(snapshot, step.directory ?? '.'));
546
+ if (!contained(cwd, snapshot))
547
+ return fail('commands', 'A command directory leaves the isolated checkout.');
548
+ directories.push(cwd);
549
+ if (classifyPreviewCommand(step.command, cwd, { scriptsChanged }).verdict !== 'allow')
550
+ return fail('commands', 'A preview command is outside the supported local verification commands.');
551
+ const segments = parseCommand(step.command) ?? [];
552
+ if (segments.some((s) => s.words[0] === 'cd'))
553
+ return fail('commands', 'Set each command directory explicitly instead of using cd inside commands.');
554
+ let localD1 = usesLocalD1(step.command);
555
+ const migration = /\b(?:migrate|migrations|db:prepare|db:push|db:migrate|db:seed|db push|db seed)\b/;
556
+ const djangoDatabase = (command) => (parseCommand(command) ?? []).some(({ words }) => {
557
+ const at = words.findIndex((w) => /(?:^|\/)manage\.py$/.test(w));
558
+ return at >= 0 && !['runserver', 'collectstatic', 'check'].includes(words[at + 1] ?? '');
559
+ });
560
+ let preparesDatabase = migration.test(step.command) || djangoDatabase(step.command);
561
+ for (const { words } of segments) {
562
+ if (!['npm', 'pnpm', 'yarn', 'bun'].includes(words[0] ?? ''))
563
+ continue;
564
+ const names = words[1] === 'run' ? [`pre${words[2]}`, words[2], `post${words[2]}`] : ['install', 'ci'].includes(words[1] ?? '') ? ['preinstall', 'install', 'postinstall', 'prepare'] : [];
565
+ if (!names.length)
566
+ continue;
567
+ const manifest = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8'));
568
+ const seen = new Set();
569
+ while (names.length) {
570
+ const name = names.pop();
571
+ if (seen.has(name))
572
+ continue;
573
+ seen.add(name);
574
+ const script = manifest.scripts?.[name] ?? '';
575
+ preparesDatabase ||= migration.test(script) || djangoDatabase(script);
576
+ if (/--remote\b|\b(?:deploy|publish)\b/.test(script.replace(/\bprisma\s+migrate\s+deploy\b/g, 'prisma migrate')))
577
+ return fail('commands', 'A selected package script deploys or touches a remote database.');
578
+ if (/\bwrangler\s+d1\b/.test(script) && !usesLocalD1(script))
579
+ return fail('commands', 'A selected D1 script must explicitly use --local.');
580
+ if (usesLocalD1(script))
581
+ localD1 = true;
582
+ for (const m of script.matchAll(/\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?([\w:.-]+)/g))
583
+ names.push(m[1]);
584
+ }
585
+ }
586
+ if ((step.phase === 'seed' || preparesDatabase) && !input.service && !localSqlite && !localD1)
587
+ return fail('database', 'Preparation and seed require an owned database service, a relative SQLite DATABASE_URL, or an explicit local D1 command.');
588
+ }
589
+ if (signal?.aborted)
590
+ return fail('environment', 'Verification was cancelled.');
591
+ const pythonDirectories = new Map();
592
+ for (const [index, step] of steps.entries()) {
593
+ const words = (parseCommand(step.command) ?? []).map((part) => part.words);
594
+ const calls = words.filter(([command]) => ['python', 'python3', '.venv/bin/python', '.venv/bin/python3', 'uvicorn'].includes(command ?? ''));
595
+ if (!calls.length)
596
+ continue;
597
+ const directory = directories[index], previous = pythonDirectories.get(directory);
598
+ const executable = calls.some(([command]) => command === 'python3') ? 'python3' : calls.some(([command]) => command === 'python') ? 'python' : undefined;
599
+ pythonDirectories.set(directory, {
600
+ executable: executable === 'python3' || (previous?.explicit && previous.executable === 'python3') ? 'python3' : executable ?? previous?.executable ?? 'python3',
601
+ explicit: Boolean(previous?.explicit || executable),
602
+ });
603
+ }
604
+ for (const [directory, python] of pythonDirectories) {
605
+ stage = 'environment';
606
+ let executable;
607
+ // A generic python alias may come from Python 3's venv; an explicit python3 stays explicit.
608
+ const candidates = !python.explicit ? ['python3', 'python'] : python.executable === 'python' ? ['python', 'python3'] : ['python3'];
609
+ for (const candidate of candidates) {
610
+ const found = await run('/bin/sh', ['-c', `command -v ${candidate}`], directory, true, 5000);
611
+ const path = found.output.trim();
612
+ if (found.ok && isAbsolute(path) && !/[\r\n\0]/.test(path)) {
613
+ executable = path;
614
+ break;
615
+ }
616
+ }
617
+ if (!executable)
618
+ return fail(stage, 'The required Python runtime or virtual environment is unavailable.');
619
+ // Precreate even for explicit bootstrap steps so every command in a compound step uses it.
620
+ if (!(await run(executable, ['-m', 'venv', '.venv'], directory)).ok)
621
+ return fail(stage, 'The required Python runtime or virtual environment is unavailable.');
622
+ }
623
+ const environmentFor = (directory) => {
624
+ if (!pythonDirectories.has(directory))
625
+ return env;
626
+ const venv = join(directory, '.venv');
627
+ return { ...env, VIRTUAL_ENV: venv, PATH: `${join(venv, 'bin')}:${env.PATH}` };
628
+ };
629
+ if (input.service) {
630
+ stage = 'database';
631
+ const postgres = input.service === 'postgres', port = postgres ? 5432 : 3306;
632
+ container = `impeccable-preview-${ownership}`;
633
+ const args = ['run', '--detach', '--rm', '--name', container, '--label', `impeccable.verifier=${ownership}`, '--publish', `127.0.0.1::${port}`, '--env', postgres ? 'POSTGRES_PASSWORD=postgres' : 'MYSQL_ROOT_PASSWORD=root', '--env', postgres ? 'POSTGRES_DB=preview' : 'MYSQL_DATABASE=preview', postgres ? 'postgres:16' : 'mysql:8'];
634
+ if (!(await run('docker', args, snapshot)).ok)
635
+ return fail(stage, 'A disposable database could not start; local Docker is required.');
636
+ containerStarted = true;
637
+ const mapped = await run('docker', ['port', container, `${port}/tcp`], snapshot, true, 10_000);
638
+ const match = /^127\.0\.0\.1:(\d+)\s*$/.exec(mapped.output);
639
+ if (!mapped.ok || !match)
640
+ return fail(stage, 'The disposable database did not publish a loopback port.');
641
+ env.DATABASE_URL = `${postgres ? 'postgresql://postgres:postgres' : 'mysql://root:root'}@127.0.0.1:${match[1]}/preview`;
642
+ let ready = false;
643
+ for (let i = 0; i < 30 && !signal?.aborted; i++) {
644
+ const check = postgres ? ['pg_isready', '-U', 'postgres', '-d', 'preview'] : ['mysqladmin', 'ping', '-h', '127.0.0.1', '-proot'];
645
+ if ((await run('docker', ['exec', container, ...check], snapshot, false, 5000)).ok) {
646
+ ready = true;
647
+ break;
648
+ }
649
+ await sleep(1000, undefined, { signal }).catch(() => { });
650
+ }
651
+ if (!ready)
652
+ return fail(stage, 'The disposable database was not ready; no preparation or seed ran.');
653
+ }
654
+ for (const [index, step] of input.steps.entries()) {
655
+ stage = step.phase;
656
+ if (!(await run('/bin/sh', ['-c', step.command], directories[index], false, 120_000, signal ?? null, environmentFor(directories[index]))).ok)
657
+ return fail(stage, signal?.aborted ? 'Verification was cancelled.' : 'The local step failed or exceeded two minutes; its output was not retained.');
658
+ }
659
+ if (staticInput) {
660
+ stage = 'output';
661
+ if (signal?.aborted)
662
+ return fail(stage, 'Verification was cancelled.');
663
+ const inspected = await inspectStaticPreviewOutput(snapshot, staticInput.output);
664
+ if (signal?.aborted)
665
+ return fail(stage, 'Verification was cancelled.');
666
+ return { ...inspected, ...(inspected.verified ? {} : { stage }), probes };
667
+ }
668
+ stage = 'start';
669
+ const free = await new Promise((done) => { const check = createServer(); check.once('error', () => done(false)); check.listen(serverInput.port, '127.0.0.1', () => check.close(() => done(true))); });
670
+ if (!free)
671
+ return fail(stage, 'The preview port is already in use; an existing service was not probed.');
672
+ server = spawn('/bin/sh', ['-c', serverInput.start.command], { cwd: directories.at(-1), env: environmentFor(directories.at(-1)), detached: true, stdio: 'ignore' });
673
+ let exited = false;
674
+ server.once('error', () => { exited = true; });
675
+ server.once('exit', () => { exited = true; });
676
+ let ready = false;
677
+ const deadline = Date.now() + 30_000;
678
+ while (!signal?.aborted && !exited && Date.now() < deadline) {
679
+ if (await probe('/', 'localhost')) {
680
+ ready = true;
681
+ break;
682
+ }
683
+ await sleep(250, undefined, { signal }).catch(() => { });
684
+ }
685
+ if (!ready || exited || signal?.aborted)
686
+ return fail(stage, signal?.aborted ? 'Verification was cancelled.' : 'The app did not become ready within thirty seconds.');
687
+ stage = 'probe';
688
+ for (const path of serverInput.paths)
689
+ for (const host of ['localhost', serverInput.host]) {
690
+ const status = await probe(path, host);
691
+ probes.push({ path, host, status });
692
+ if (signal?.aborted || exited)
693
+ return fail(stage, 'The app stopped or verification was cancelled.');
694
+ }
695
+ if (probes.some((p) => p.status < 200 || p.status >= 300))
696
+ return fail(stage, 'A route or foreign-host check was not successful; redirects are not followed and sign-in is not verified.');
697
+ return { verified: true, reason: 'Local HTTP checks passed; browser behavior, signed-in access and CI runtime versions remain unverified.', probes };
698
+ }
699
+ catch {
700
+ return fail(stage, signal?.aborted ? 'Verification was cancelled.' : 'Local verification could not complete safely; no subprocess output was retained.');
701
+ }
702
+ finally {
703
+ let cleanupFailed = false;
704
+ if (server)
705
+ await stopPreviewChild(server);
706
+ if (container && scratch) {
707
+ const owner = await run('docker', ['inspect', '--format', '{{index .Config.Labels "impeccable.verifier"}}', container], scratch, true, 10_000, null);
708
+ const owned = owner.ok && owner.output.trim() === ownership;
709
+ if (owned)
710
+ cleanupFailed = !(await run('docker', ['rm', '--force', container], scratch, false, 10_000, null)).ok;
711
+ else
712
+ cleanupFailed = containerStarted || !owner.ok;
713
+ // --rm may already have removed an exited service. Failed inspect alone
714
+ // cannot distinguish that success from an unavailable Docker daemon.
715
+ if (!owner.ok || (owned && cleanupFailed)) {
716
+ const remaining = await run('docker', ['ps', '--all', '--filter', `name=^/${container}$`, '--format', '{{.Names}}'], scratch, true, 10_000, null);
717
+ // A daemon that does not answer says nothing about a container that never started and that inspect never saw: the answer already returned (Docker is required) stands.
718
+ cleanupFailed = remaining.output.trim() !== '' || (!remaining.ok && (containerStarted || owned));
719
+ }
720
+ }
721
+ if (scratch)
722
+ await rm(scratch, { recursive: true, force: true }).catch(() => { cleanupFailed = true; });
723
+ if (cleanupFailed)
724
+ return fail('cleanup', 'An owned preview resource could not be removed; local verification is incomplete.');
725
+ }
726
+ }
727
+ /** The setup tools check their input and answer with evidence or a short error; none throws. */
728
+ const execFileAsync = promisify(execFile);
729
+ /**
730
+ * Whether the setup branch lies under the test drive's branch, as their remote-tracking refs in this checkout
731
+ * show GitHub: a push moves its ref only when it lands, so a correction pushed after the test drive was
732
+ * branched makes this false, and a push that failed changes nothing. Null when either ref is missing.
733
+ */
734
+ export async function driveCoversSetup(cwd, setupBranch, driveBranch) {
735
+ try {
736
+ await execFileAsync('git', ['merge-base', '--is-ancestor', `refs/remotes/origin/${setupBranch}`, `refs/remotes/origin/${driveBranch}`], { cwd, timeout: 10_000 });
737
+ return true;
738
+ }
739
+ catch (err) {
740
+ return err.code === 1 ? false : null;
741
+ }
742
+ }
743
+ const LIMIT = 20;
744
+ const WORKFLOW = '.github/workflows/impeccable-preview.yml';
745
+ const STATUSES = ['queued', 'requested', 'waiting', 'pending', 'in_progress', 'completed'];
746
+ const CONCLUSIONS = ['success', 'failure', 'cancelled', 'timed_out', 'action_required', 'neutral', 'skipped', 'stale', 'startup_failure'];
747
+ const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
748
+ const positive = (v) => typeof v === 'number' && Number.isSafeInteger(v) && v > 0;
749
+ const sha = (v) => typeof v === 'string' && /^[0-9a-f]{40}$/.test(v);
750
+ const date = (v) => typeof v === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(v) && Number.isFinite(Date.parse(v)) ? new Date(v).toISOString() : null;
751
+ const statusOf = (v) => STATUSES.find((s) => s === v);
752
+ const conclusionOf = (v) => v === null ? null : CONCLUSIONS.find((s) => s === v);
753
+ const setupBranch = (v) => /^impeccable\/setup(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?$/.test(v) && !v.includes('..') && !v.endsWith('.') && !v.endsWith('.lock');
754
+ const testDriveBranch = (v) => /^impeccable\/test-drive(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?$/.test(v) && !v.includes('..') && !v.endsWith('.') && !v.endsWith('.lock');
755
+ /** Verify head ownership before an immutable binding; never return raw GitHub output. */
756
+ export async function readSetupPullHead(owner, repo, pr, branch, deps = {}) {
757
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(owner)
758
+ || !/^[A-Za-z0-9_.-]{1,100}$/.test(repo) || ['.', '..'].includes(repo)
759
+ || !positive(pr) || !(setupBranch(branch) || testDriveBranch(branch)) || deps.signal?.aborted)
760
+ return false;
761
+ try {
762
+ const args = ['api', '--hostname', 'github.com', '--method', 'GET', `/repos/${owner}/${repo}/pulls/${pr}`];
763
+ const raw = deps.runGh ? await deps.runGh(args) : (await execFileAsync('gh', args, {
764
+ encoding: 'utf8', timeout: 30_000, maxBuffer: 256 * 1024, signal: deps.signal,
765
+ env: { ...process.env, GH_PROMPT_DISABLED: '1' },
766
+ })).stdout;
767
+ if (deps.signal?.aborted || raw.length > 256 * 1024)
768
+ return false;
769
+ const pull = object(JSON.parse(raw)), head = object(pull?.head), base = object(pull?.base);
770
+ const sameRepo = (value) => {
771
+ const repository = object(value), fullName = `${owner}/${repo}`.toLowerCase();
772
+ return typeof repository?.full_name === 'string' && repository.full_name.toLowerCase() === fullName
773
+ && (repository.url === undefined || (typeof repository.url === 'string'
774
+ && repository.url.toLowerCase() === `https://api.github.com/repos/${fullName}`));
775
+ };
776
+ return pull?.number === pr && pull.state === 'open' && head?.ref === branch && sha(head.sha)
777
+ && sameRepo(head.repo) && sameRepo(base?.repo);
778
+ }
779
+ catch {
780
+ return false;
781
+ }
782
+ }
783
+ /** Only generated, recognizable labels become a phase. Names never leave memory. */
784
+ function stageOf(value) {
785
+ if (typeof value !== 'string')
786
+ return null;
787
+ const names = {
788
+ Prepare: 'prepare', 'Seed data': 'seed', 'Start the app': 'start',
789
+ 'Upload to Impeccable': 'upload', 'Tunnel to Impeccable and wait for the review': 'tunnel',
790
+ 'Choose pnpm version': 'setup', 'Install Zola': 'setup',
791
+ };
792
+ if (Object.hasOwn(names, value))
793
+ return names[value];
794
+ if (/^Run actions\/setup-(?:node|python|ruby|go|java|dotnet)@[^\s]+$/.test(value)
795
+ || /^Run (?:pnpm\/action-setup|oven-sh\/setup-bun|ruby\/setup-ruby)@[^\s]+$/.test(value))
796
+ return 'setup';
797
+ if (/^Run (?:npm|pnpm|yarn|bun) (?:install|ci)(?: |$)/.test(value))
798
+ return 'install';
799
+ if (/^Run (?:npm|pnpm|yarn|bun) run build(?: |$)/.test(value))
800
+ return 'build';
801
+ return null;
802
+ }
803
+ /**
804
+ * Safe bounded evidence, not a retry counter or a claim the preview rendered.
805
+ * Reads PR twice to detect head changes during collection. Never downloads logs.
806
+ * Historical runs without a provable PR association are omitted and flagged.
807
+ */
808
+ export async function readPreviewRuns(owner, repo, pr, branch, deps) {
809
+ const out = { pull: null, runs: [], currentRunId: null,
810
+ coverage: { limit: LIMIT, truncated: false, unassociatedHistory: false, incomplete: false } };
811
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(owner)
812
+ || !/^[A-Za-z0-9_.-]{1,100}$/.test(repo) || ['.', '..'].includes(repo)
813
+ || !positive(pr) || !setupBranch(branch))
814
+ return { ...out, error: 'invalid-input' };
815
+ const fullName = `${owner}/${repo}`;
816
+ const base = `/repos/${owner}/${repo}`;
817
+ const apiRepo = `https://api.github.com${base}`;
818
+ const sameRepo = (v) => {
819
+ const r = object(v);
820
+ return !!r && (r.full_name === fullName || r.url === apiRepo)
821
+ && (r.full_name === undefined || r.full_name === fullName)
822
+ && (r.url === undefined || r.url === apiRepo);
823
+ };
824
+ const runGh = deps.runGh ?? (async (args) => {
825
+ const { stdout } = await execFileAsync('gh', args, { encoding: 'utf8', timeout: 30_000,
826
+ maxBuffer: 2 * 1024 * 1024, signal: deps.signal });
827
+ return stdout;
828
+ });
829
+ async function get(route) {
830
+ if (deps.signal?.aborted)
831
+ throw new Error();
832
+ const text = await runGh(['api', '--hostname', 'github.com', '--method', 'GET', route]);
833
+ if (deps.signal?.aborted || text.length > 2 * 1024 * 1024)
834
+ throw new Error();
835
+ const result = object(JSON.parse(text));
836
+ if (!result)
837
+ throw new Error();
838
+ return result;
839
+ }
840
+ function readPull(p) {
841
+ const head = object(p.head), baseRef = object(p.base);
842
+ return p.number === pr && p.state === 'open' && head?.ref === branch && sha(head.sha)
843
+ && sameRepo(head.repo) && sameRepo(baseRef?.repo) ? { number: pr, head: head.sha, branch } : null;
844
+ }
845
+ function collection(p, key) {
846
+ if (!Array.isArray(p[key]) || typeof p.total_count !== 'number' || !Number.isSafeInteger(p.total_count)
847
+ || p.total_count < p[key].length)
848
+ throw new Error();
849
+ if (p.total_count > LIMIT || p[key].length > LIMIT)
850
+ out.coverage.truncated = true;
851
+ return p[key].slice(0, LIMIT);
852
+ }
853
+ let workflowId;
854
+ function runOf(v) {
855
+ const invalid = () => { out.coverage.incomplete = true; return null; };
856
+ const r = object(v);
857
+ if (!r || !positive(r.id) || !positive(r.run_attempt) || !sha(r.head_sha)
858
+ || r.head_branch !== branch || r.event !== 'pull_request' || r.workflow_id !== workflowId
859
+ || typeof r.path !== 'string' || r.path.split('@')[0] !== WORKFLOW
860
+ || !sameRepo(r.repository) || !sameRepo(r.head_repository))
861
+ return invalid();
862
+ const status = statusOf(r.status), conclusion = conclusionOf(r.conclusion), updated = date(r.updated_at);
863
+ if (!status || conclusion === undefined || !updated)
864
+ return invalid();
865
+ const association = Array.isArray(r.pull_requests) ? r.pull_requests.map(object).find((p) => p?.number === pr
866
+ && object(p.head)?.ref === branch && sameRepo(object(p.head)?.repo) && sameRepo(object(p.base)?.repo)
867
+ && sha(object(p.head)?.sha)) : undefined;
868
+ // Empty arrays are normal for real runs. An explicit contradictory association is not.
869
+ if (!Array.isArray(r.pull_requests) || (r.pull_requests.length && !association)) {
870
+ out.coverage.unassociatedHistory = true;
871
+ return null;
872
+ }
873
+ const known = deps.knownRuns?.get(r.id);
874
+ if (known && known.workflowHead !== r.head_sha)
875
+ return invalid();
876
+ let head = known && sha(known.head) ? known.head
877
+ : r.head_sha === out.pull.head ? r.head_sha : undefined;
878
+ if (!head && association)
879
+ head = object(association.head).sha;
880
+ if (!head) {
881
+ out.coverage.unassociatedHistory = true;
882
+ return null;
883
+ }
884
+ return { id: r.id, attempt: r.run_attempt, head, workflowHead: r.head_sha, stage: null,
885
+ status, conclusion, url: `https://github.com/${fullName}/actions/runs/${r.id}`,
886
+ startedAt: date(r.run_started_at), updatedAt: updated };
887
+ }
888
+ try {
889
+ out.pull = readPull(await get(`${base}/pulls/${pr}`));
890
+ if (!out.pull)
891
+ return { ...out, error: 'pull-unavailable' };
892
+ const workflow = await get(`${base}/actions/workflows/impeccable-preview.yml`);
893
+ if (!positive(workflow.id) || workflow.path !== WORKFLOW)
894
+ throw new Error();
895
+ workflowId = workflow.id;
896
+ const listed = await get(`${base}/actions/workflows/${workflowId}/runs?branch=${encodeURIComponent(branch)}&event=pull_request&per_page=${LIMIT}`);
897
+ const unique = new Map();
898
+ for (const value of collection(listed, 'workflow_runs')) {
899
+ const r = runOf(value);
900
+ if (r && (!unique.has(r.id) || unique.get(r.id).attempt < r.attempt))
901
+ unique.set(r.id, r);
902
+ }
903
+ out.runs = [...unique.values()].sort((a, b) => b.id - a.id);
904
+ const current = out.runs.filter((r) => r.head === out.pull.head)
905
+ .sort((a, b) => Date.parse(b.startedAt ?? b.updatedAt) - Date.parse(a.startedAt ?? a.updatedAt) || b.id - a.id)[0];
906
+ if (current) {
907
+ out.currentRunId = current.id;
908
+ // Attempt-specific metadata prevents a previous attempt's timing from being used.
909
+ const detailed = runOf(await get(`${base}/actions/runs/${current.id}/attempts/${current.attempt}`));
910
+ if (!detailed || detailed.id !== current.id || detailed.attempt !== current.attempt
911
+ || detailed.head !== current.head || detailed.workflowHead !== current.workflowHead)
912
+ throw new Error();
913
+ Object.assign(current, detailed);
914
+ if (current.conclusion === 'failure' || current.conclusion === 'startup_failure') {
915
+ const jobs = await get(`${base}/actions/runs/${current.id}/attempts/${current.attempt}/jobs?per_page=${LIMIT}`);
916
+ const failed = collection(jobs, 'jobs').map(object).filter((j) => j?.conclusion === 'failure');
917
+ // Several app jobs must not assign one sibling's stage to the selected app.
918
+ if (failed.length === 1 && failed[0].run_id === current.id && failed[0].head_sha === current.workflowHead
919
+ && Array.isArray(failed[0].steps) && failed[0].steps.length <= 100) {
920
+ const stages = failed[0].steps.map(object).filter((s) => s?.conclusion === 'failure').map((s) => stageOf(s?.name));
921
+ if (stages.length && stages.every((s) => s !== null && s === stages[0]))
922
+ current.stage = stages[0];
923
+ }
924
+ }
925
+ }
926
+ const finalPull = readPull(await get(`${base}/pulls/${pr}`));
927
+ if (!finalPull || finalPull.head !== out.pull.head)
928
+ return { ...out, pull: finalPull,
929
+ runs: [], currentRunId: null, error: 'head-changed' };
930
+ return out;
931
+ }
932
+ catch {
933
+ // Retain no partial data with an unchecked final head; never echo gh exceptions.
934
+ return { ...out, pull: null, runs: [], currentRunId: null,
935
+ coverage: { ...out.coverage, incomplete: true }, error: 'evidence-unavailable' };
936
+ }
937
+ }
938
+ const previewMemories = new WeakMap();
939
+ const PREVIEW_RUN = { type: 'object', additionalProperties: false,
940
+ properties: { appKey: { type: 'string', minLength: 1, description: 'The app\'s key from the state; needed only when the repository has several apps.' } } };
941
+ const PREVIEW_FIELDS = new Set(['runtimeVersion', 'installCommand', 'prepareCommand', 'buildCommand', 'seedCommand', 'startCommand', 'outputDir', 'port', 'env', 'service', 'intendedMethod']);
942
+ const PREVIEW_STAGES = ['setup', 'install', 'prepare', 'build', 'seed', 'start', 'tunnel', 'upload'];
943
+ /** Map known failure signatures to fixed prose; arbitrary log text never leaves memory. */
944
+ function previewReason(raw) {
945
+ if (typeof raw !== 'string')
946
+ return 'The detailed cause was not verified; inspect the linked run.';
947
+ if (/Entry page not found|Cannot open|No such file|ENOENT/i.test(raw))
948
+ return 'Expected output or an entry file was not found.';
949
+ if (/command not found|Cannot find module|Module not found/i.test(raw))
950
+ return 'A required command or module was not found.';
951
+ if (/lockfile|package.manager|corepack/i.test(raw))
952
+ return 'The package manager or lockfile could not be used.';
953
+ if (/ECONNREFUSED|did not answer/i.test(raw))
954
+ return 'The application did not answer its readiness check.';
955
+ if (/EADDRINUSE/i.test(raw))
956
+ return 'The configured port was already in use.';
957
+ if (/forbidden|blocked host|403/i.test(raw))
958
+ return 'The preview request was refused; check access and the forwarded host.';
959
+ return 'The detailed cause was not verified; inspect the linked run.';
960
+ }
961
+ // Workflow operations keep raw subprocess output in memory.
962
+ /** Keep authentication available, but never redirect repository reads or persist transport traces. */
963
+ export function workflowGitEnvironment() {
964
+ const env = { ...process.env };
965
+ for (const key of Object.keys(env))
966
+ if (/^GIT_TRACE/.test(key) || /^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(key)
967
+ || ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_NAMESPACE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY',
968
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_CURL_VERBOSE', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG',
969
+ 'GIT_CEILING_DIRECTORIES', 'GIT_DISCOVERY_ACROSS_FILESYSTEM', 'GIT_PREFIX', 'GIT_ASKPASS', 'SSH_ASKPASS'].includes(key))
970
+ delete env[key];
971
+ return Object.assign(env, { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', GCM_INTERACTIVE: 'never',
972
+ GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never', GIT_SSH_VARIANT: 'ssh',
973
+ GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=yes',
974
+ // Removing the variables alone still permits globally configured trace2 targets.
975
+ GIT_TRACE2: '0', GIT_TRACE2_EVENT: '0', GIT_TRACE2_PERF: '0' });
976
+ }
977
+ const record = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
978
+ export function workflowDefaultBranch(value) {
979
+ if (typeof value !== 'string' || !value || value.length > 255 || value === '@' || value.startsWith('-')
980
+ || /[\s~^:?*\[\\\x00-\x1f\x7f]/.test(value) || value.includes('..') || value.includes('@{'))
981
+ return null;
982
+ return value.split('/').every((part) => part && !part.startsWith('.') && !part.endsWith('.') && !part.endsWith('.lock')) ? value : null;
983
+ }
984
+ function scopeEvidence(value) {
985
+ if (typeof value !== 'string' || value.length > 2048 || !value.trim())
986
+ return 'unknown';
987
+ const scopes = value.split(',').map((part) => part.trim().replace(/^'([^']*)'$/, '$1'));
988
+ if (!scopes.length || scopes.some((scope) => !/^[a-z][a-z0-9_:-]*$/.test(scope)))
989
+ return 'unknown';
990
+ return scopes.includes('workflow') ? 'present' : 'missing';
991
+ }
992
+ export function workflowRemoteEvidence(raw, owner, repo) {
993
+ const lines = raw.trim().split(/\r?\n/);
994
+ if (!raw.trim() || lines.length > 16)
995
+ return { transport: 'unknown', originMatches: null };
996
+ const results = lines.map((line) => {
997
+ let path, transport;
998
+ const scp = /^git@github\.com:([^\s?#]+)$/.exec(line);
999
+ if (scp) {
1000
+ path = scp[1];
1001
+ transport = 'ssh';
1002
+ }
1003
+ else {
1004
+ let url;
1005
+ try {
1006
+ url = new URL(line);
1007
+ }
1008
+ catch {
1009
+ return { transport: 'unknown', matches: null };
1010
+ }
1011
+ if (!['https:', 'ssh:'].includes(url.protocol))
1012
+ return { transport: 'unknown', matches: null };
1013
+ transport = url.protocol === 'https:' ? 'https' : 'ssh';
1014
+ if (url.hostname !== 'github.com' || url.search || url.hash
1015
+ || (transport === 'https' && url.port && url.port !== '443')
1016
+ || (transport === 'ssh' && (url.username !== 'git' || url.password || (url.port && url.port !== '22'))))
1017
+ return { transport, matches: false };
1018
+ path = url.pathname.replace(/^\//, '');
1019
+ }
1020
+ const normalized = path.replace(/\.git$/, '');
1021
+ return { transport, matches: normalized.toLowerCase() === `${owner}/${repo}`.toLowerCase() };
1022
+ });
1023
+ const transports = new Set(results.map((r) => r.transport));
1024
+ return { transport: transports.has('unknown') ? 'unknown' : transports.size > 1 ? 'mixed' : results[0].transport,
1025
+ originMatches: results.some((r) => r.matches === false) ? false : results.some((r) => r.matches === null) ? null : true };
1026
+ }
1027
+ /** Read-only account/branch/transport evidence. Neither SSH nor gh authentication proves a workflow push is authorized. */
1028
+ export async function readWorkflowAccess(owner, repo, root, dependencies = {}) {
1029
+ const out = { authenticated: null, defaultBranch: null, transport: 'unknown', originMatches: null,
1030
+ workflowScope: 'unknown', scopeEvidence: 'github-cli', needsWorkflowScope: false,
1031
+ gitIdentity: 'unverified', workflowWriteAccess: 'unverified', issues: [] };
1032
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(owner)
1033
+ || !/^[A-Za-z0-9_.-]{1,100}$/.test(repo) || ['.', '..'].includes(repo) || !isAbsolute(root))
1034
+ return { ...out, issues: ['invalid-input'] };
1035
+ if (dependencies.signal?.aborted)
1036
+ return { ...out, issues: ['cancelled'] };
1037
+ const run = dependencies.run ?? (async (binary, args, options) => {
1038
+ const { stdout } = await execFileAsync(binary, args, { cwd: options.cwd, signal: options.signal,
1039
+ timeout: 30_000, maxBuffer: 256 * 1024, encoding: 'utf8',
1040
+ env: workflowGitEnvironment() });
1041
+ return stdout;
1042
+ });
1043
+ async function read(binary, args) {
1044
+ const text = await run(binary, args, { cwd: root, signal: dependencies.signal });
1045
+ if (dependencies.signal?.aborted || typeof text !== 'string' || text.length > 256 * 1024)
1046
+ throw new Error();
1047
+ return text;
1048
+ }
1049
+ const [auth, branch, remote] = await Promise.allSettled([
1050
+ read('gh', ['auth', 'status', '--active', '--hostname', 'github.com', '--json', 'hosts']),
1051
+ read('gh', ['repo', 'view', `github.com/${owner}/${repo}`, '--json', 'defaultBranchRef']),
1052
+ read('git', ['remote', 'get-url', '--push', '--all', 'origin']),
1053
+ ]);
1054
+ if (dependencies.signal?.aborted)
1055
+ return { ...out, issues: ['cancelled'] };
1056
+ try {
1057
+ if (auth.status !== 'fulfilled')
1058
+ throw new Error();
1059
+ const hosts = record(record(JSON.parse(auth.value))?.hosts), accounts = hosts?.['github.com'];
1060
+ if (!Array.isArray(accounts) || accounts.length > 100)
1061
+ throw new Error();
1062
+ const rows = accounts.map(record);
1063
+ if (rows.some((account) => !account || typeof account.active !== 'boolean' || account.host !== 'github.com' || typeof account.state !== 'string'))
1064
+ throw new Error();
1065
+ const active = rows.filter((account) => account.active === true);
1066
+ if (active.length === 0)
1067
+ out.authenticated = false;
1068
+ else if (active.length !== 1)
1069
+ out.issues.push('authentication-ambiguous');
1070
+ else if (active[0].state === 'success') {
1071
+ out.authenticated = true;
1072
+ out.workflowScope = scopeEvidence(active[0].scopes);
1073
+ }
1074
+ else if (active[0].state === 'failure' || active[0].state === 'error')
1075
+ out.authenticated = false;
1076
+ else
1077
+ throw new Error();
1078
+ }
1079
+ catch {
1080
+ out.issues.push('authentication-unavailable');
1081
+ }
1082
+ try {
1083
+ if (branch.status !== 'fulfilled')
1084
+ throw new Error();
1085
+ out.defaultBranch = workflowDefaultBranch(record(record(JSON.parse(branch.value))?.defaultBranchRef)?.name);
1086
+ if (!out.defaultBranch)
1087
+ throw new Error();
1088
+ }
1089
+ catch {
1090
+ out.issues.push('default-branch-unavailable');
1091
+ }
1092
+ if (remote.status === 'fulfilled') {
1093
+ Object.assign(out, workflowRemoteEvidence(remote.value, owner, repo));
1094
+ if (out.originMatches === false)
1095
+ out.issues.push('push-remote-mismatch');
1096
+ else if (out.originMatches === null)
1097
+ out.issues.push('push-remote-unavailable');
1098
+ }
1099
+ else
1100
+ out.issues.push('push-remote-unavailable');
1101
+ out.needsWorkflowScope = out.authenticated === true && out.transport === 'https' && out.originMatches === true && out.workflowScope === 'missing';
1102
+ return out;
1103
+ }
1104
+ /**
1105
+ * Refresh only origin/<default>, after workflow_access proved its push target.
1106
+ * Fetch URL is independently checked; redirects/config rewrites are not guessed.
1107
+ * This does not checkout a branch or prove push/workflow-write authorization.
1108
+ */
1109
+ export async function refreshWorkflowBase(owner, repo, defaultBranch, root, dependencies = {}) {
1110
+ const branch = workflowDefaultBranch(defaultBranch);
1111
+ const fail = (reason) => ({ status: 'unverified', branch, sha: null, reason });
1112
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(owner)
1113
+ || !/^[A-Za-z0-9_.-]{1,100}$/.test(repo) || ['.', '..'].includes(repo) || !branch || !isAbsolute(root))
1114
+ return fail('invalid-input');
1115
+ if (dependencies.signal?.aborted)
1116
+ return fail('cancelled');
1117
+ const env = workflowGitEnvironment();
1118
+ const run = dependencies.run ?? (async (args, options) => {
1119
+ const { stdout } = await execFileAsync('git', args, { ...options, encoding: 'utf8', timeout: 30_000, maxBuffer: 256 * 1024 });
1120
+ return stdout;
1121
+ });
1122
+ async function read(args) {
1123
+ if (dependencies.signal?.aborted)
1124
+ throw new Error();
1125
+ const text = await run(args, { cwd: root, signal: dependencies.signal, env });
1126
+ if (dependencies.signal?.aborted || typeof text !== 'string' || text.length > 256 * 1024)
1127
+ throw new Error();
1128
+ return text;
1129
+ }
1130
+ let stage = 'fetch-remote-unverified';
1131
+ try {
1132
+ const urls = await read(['remote', 'get-url', '--all', 'origin']);
1133
+ if (workflowRemoteEvidence(urls, owner, repo).originMatches !== true)
1134
+ return fail(stage);
1135
+ stage = 'fetch-unverified';
1136
+ const destination = `refs/remotes/origin/${branch}`;
1137
+ await read(['-c', 'credential.interactive=false', '-c', 'core.askPass=', 'fetch', '--quiet', '--no-tags',
1138
+ '--no-prune', '--no-prune-tags', '--no-recurse-submodules', '--no-write-fetch-head',
1139
+ '--no-auto-maintenance', '--no-write-commit-graph', '--refmap=', 'origin', `refs/heads/${branch}:${destination}`]);
1140
+ stage = 'reference-unverified';
1141
+ const sha = (await read(['rev-parse', '--verify', '--end-of-options', `${destination}^{commit}`])).trim();
1142
+ if (!/^[0-9a-f]{40}$/.test(sha))
1143
+ return fail(stage);
1144
+ return { status: 'refreshed', branch, sha };
1145
+ }
1146
+ catch {
1147
+ return fail(dependencies.signal?.aborted ? 'cancelled' : stage);
1148
+ }
1149
+ }
1150
+ export function createWorkflowSynchronizer() {
1151
+ const CURRENT = '.github/workflows/impeccable-preview.yml';
1152
+ const RETIRED = '.github/workflows/pristine-preview.yml';
1153
+ const MAX_BYTES = 1_000_000;
1154
+ const standings = ['absent', 'current', 'out-of-date', 'older-generation', 'retired', 'unknown'];
1155
+ const obj = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : null;
1156
+ const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
1157
+ const equal = (a, b) => a === null || b === null ? a === b : a.equals(b);
1158
+ class Refusal extends Error {
1159
+ }
1160
+ function refuse(message) { throw new Refusal(message); }
1161
+ /** Every raw Git byte, including errors, stays in memory and is never logged. */
1162
+ async function git(cwd, args, signal) {
1163
+ return new Promise((resolve, reject) => {
1164
+ execFile('git', ['-c', 'core.fsmonitor=false', ...args], {
1165
+ cwd, signal, timeout: 10_000, maxBuffer: MAX_BYTES, encoding: 'buffer',
1166
+ env: workflowGitEnvironment(),
1167
+ }, (error, stdout) => error ? reject(new Refusal('The local workflow state could not be checked.')) : resolve(stdout));
1168
+ });
1169
+ }
1170
+ /** Reject every existing symlink/directory mismatch; only these fixed paths enter. */
1171
+ async function pathState(root, path) {
1172
+ const segments = path.split('/');
1173
+ let absolute = root;
1174
+ for (let i = 0; i < segments.length; i++) {
1175
+ absolute = join(absolute, segments[i]);
1176
+ let stat;
1177
+ try {
1178
+ stat = await lstat(absolute);
1179
+ }
1180
+ catch (error) {
1181
+ if (error.code === 'ENOENT')
1182
+ return { bytes: null, mode: 0o644 };
1183
+ throw error;
1184
+ }
1185
+ if (stat.isSymbolicLink())
1186
+ refuse('A workflow path contains a symbolic link. Nothing was changed.');
1187
+ if (i < segments.length - 1 && !stat.isDirectory())
1188
+ refuse('A workflow parent is not a directory. Nothing was changed.');
1189
+ if (i === segments.length - 1) {
1190
+ if (!stat.isFile() || stat.size > MAX_BYTES)
1191
+ refuse('The local workflow is not a supported regular file. Nothing was changed.');
1192
+ return { bytes: await readFile(absolute), mode: stat.mode & 0o777 };
1193
+ }
1194
+ }
1195
+ throw new Error('Unreachable fixed workflow path');
1196
+ }
1197
+ async function snapshot(root, path, signal) {
1198
+ const file = await pathState(root, path);
1199
+ const [index, status] = await Promise.all([
1200
+ git(root, ['ls-files', '--stage', '-z', '--', path], signal),
1201
+ git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames', '--', path], signal),
1202
+ ]);
1203
+ const entries = index.toString('utf8').split('\0').filter(Boolean);
1204
+ const entry = entries[0];
1205
+ const match = entry ? /^(100644|100755) ([a-f0-9]{40}|[a-f0-9]{64}) 0\t(.+)$/.exec(entry) : null;
1206
+ if (entries.length > 1 || (entry && (!match || match[3] !== path)))
1207
+ refuse('The workflow index has an unsupported entry or unresolved conflict. Nothing was changed.');
1208
+ return { ...file, index: entry ?? '', indexBytes: match ? await git(root, ['cat-file', 'blob', match[2]], signal) : null, dirty: status.length > 0 };
1209
+ }
1210
+ function checkOwnership(file, owned) {
1211
+ if (!owned) {
1212
+ // An ignored untracked file may have empty status; existence/index parity
1213
+ // also prevents adopting it as an Impeccable-owned file.
1214
+ if (file.dirty || !equal(file.bytes, file.indexBytes))
1215
+ refuse('The workflow has local edits. Keep those edits and resolve them before syncing.');
1216
+ return;
1217
+ }
1218
+ const ownIndex = equal(file.indexBytes, owned.bytes)
1219
+ && (owned.bytes === null ? !file.index : file.index.startsWith(owned.mode & 0o111 ? '100755 ' : '100644 '));
1220
+ if (!equal(file.bytes, owned.bytes) || (file.bytes && file.mode !== owned.mode)
1221
+ || (file.index !== owned.baselineIndex && !ownIndex))
1222
+ refuse('The workflow changed since this run wrote it. Keep those edits and resolve them before syncing.');
1223
+ }
1224
+ function offeredPreview(state, repoName) {
1225
+ const repos = obj(state)?.repos;
1226
+ const matches = Array.isArray(repos) ? repos.map(obj).filter((repo) => repo?.name === repoName) : [];
1227
+ const preview = matches.length === 1 ? obj(obj(matches[0]?.items)?.preview) : null;
1228
+ const standing = preview?.workflowStanding;
1229
+ if (!preview || preview.status === 'not-selected' || preview.status === 'skipped'
1230
+ || !standings.includes(standing))
1231
+ refuse('The selected repository has no verified workflow state. Nothing was changed.');
1232
+ if (standing === 'unknown')
1233
+ refuse('The current workflow could not be read. Its drift is unknown; nothing was changed.');
1234
+ return { preview, standing: standing };
1235
+ }
1236
+ function offeredBytes(preview) {
1237
+ const file = obj(preview.file);
1238
+ if (file?.path !== CURRENT || typeof file.content !== 'string' || !file.content.length)
1239
+ refuse('State did not offer the canonical workflow. Nothing was invented or changed.');
1240
+ const bytes = Buffer.from(file.content, 'utf8');
1241
+ if (bytes.length > MAX_BYTES || bytes.toString('utf8') !== file.content)
1242
+ refuse('The offered workflow is not supported UTF-8 content. Nothing was changed.');
1243
+ const methods = [preview.intendedMethod, ...(Array.isArray(preview.apps) ? preview.apps.map((app) => obj(app)?.method) : [])];
1244
+ const tunnel = methods.includes('ci-tunnel') || /cloudflared\s+tunnel|\/api\/tunnels(?:\/|\b)/.test(file.content);
1245
+ if (/trycloudflare\\?\.com|cloudflared\s+tunnel\s+--url|audience=pristine\.style\b|https?:\/\/pristine\.style\/api\//i.test(file.content)
1246
+ || (tunnel && (!file.content.includes('/api/tunnels/named') || !/cloudflared\s+tunnel\s+run\b/.test(file.content)))) {
1247
+ refuse('State offered an unsupported or retired tunnel workflow. Nothing was changed.');
1248
+ }
1249
+ return bytes;
1250
+ }
1251
+ const owned = new Map();
1252
+ let queue = Promise.resolve();
1253
+ return (cwd, repo, readState, signal) => {
1254
+ const run = async () => {
1255
+ const actions = [];
1256
+ let temporary;
1257
+ try {
1258
+ signal?.throwIfAborted();
1259
+ const root = await realpath(cwd);
1260
+ const topLevel = (await git(root, ['rev-parse', '--show-toplevel'], signal)).toString('utf8').trim();
1261
+ if (await realpath(topLevel) !== root)
1262
+ refuse('Run workflow sync from the selected repository root. Nothing was changed.');
1263
+ const { preview, standing } = offeredPreview(await readState(), repo);
1264
+ const retired = preview.workflowRetiredPath;
1265
+ if (retired != null && retired !== RETIRED && retired !== CURRENT)
1266
+ refuse('State named an unsupported retired workflow path. Nothing was changed.');
1267
+ // Retired CURRENT is replaced in place, never deleted after replacement.
1268
+ const removeLegacy = retired === RETIRED;
1269
+ const desired = standing === 'current' ? null : offeredBytes(preview);
1270
+ const paths = removeLegacy ? [CURRENT, RETIRED] : [CURRENT];
1271
+ const before = new Map();
1272
+ for (const path of paths) {
1273
+ const file = await snapshot(root, path, signal);
1274
+ checkOwnership(file, owned.get(`${root}/${path}`));
1275
+ before.set(path, file);
1276
+ }
1277
+ if (desired === null && before.get(CURRENT).bytes === null)
1278
+ refuse('State says current, but no canonical workflow is present locally. Refresh the default branch before continuing.');
1279
+ // Validate both candidates before any mutation, then check again to
1280
+ // catch ordinary edits made while the fresh state/Git reads ran.
1281
+ for (const path of paths) {
1282
+ const now = await snapshot(root, path, signal), was = before.get(path);
1283
+ if (!equal(now.bytes, was.bytes) || now.mode !== was.mode || now.index !== was.index || now.dirty !== was.dirty)
1284
+ refuse('A workflow changed while it was being checked. Nothing was changed.');
1285
+ }
1286
+ signal?.throwIfAborted();
1287
+ const current = before.get(CURRENT);
1288
+ if (desired !== null && !equal(current.bytes, desired)) {
1289
+ const target = join(root, CURRENT);
1290
+ await mkdir(dirname(target), { recursive: true });
1291
+ await pathState(root, CURRENT);
1292
+ temporary = join(dirname(target), `.impeccable-${randomBytes(12).toString('hex')}.tmp`);
1293
+ await writeFile(temporary, desired, { flag: 'wx', mode: current.mode });
1294
+ await chmod(temporary, current.mode);
1295
+ await rename(temporary, target);
1296
+ temporary = undefined;
1297
+ owned.set(`${root}/${CURRENT}`, { bytes: desired, mode: current.mode, baselineIndex: current.index });
1298
+ actions.push({ path: CURRENT, action: 'written', bytes: desired.length, sha256: digest(desired) });
1299
+ }
1300
+ const legacy = before.get(RETIRED);
1301
+ if (removeLegacy && legacy?.bytes !== null) {
1302
+ await unlink(join(root, RETIRED));
1303
+ owned.set(`${root}/${RETIRED}`, { bytes: null, mode: 0o644, baselineIndex: legacy.index });
1304
+ actions.push({ path: RETIRED, action: 'removed' });
1305
+ }
1306
+ let committedMatches = null;
1307
+ if (desired !== null) {
1308
+ try {
1309
+ const entry = (await git(root, ['ls-tree', '-z', 'HEAD', '--', CURRENT], signal)).toString('utf8');
1310
+ if (!entry)
1311
+ committedMatches = false;
1312
+ else {
1313
+ const match = /^(?:100644|100755) blob ([a-f0-9]{40}|[a-f0-9]{64})\t\.github\/workflows\/impeccable-preview\.yml\0$/.exec(entry);
1314
+ if (match)
1315
+ committedMatches = equal(await git(root, ['cat-file', 'blob', match[1]], signal), desired);
1316
+ }
1317
+ }
1318
+ catch { /* local writes can succeed while commit evidence is unavailable */ }
1319
+ }
1320
+ let stagedMatches = desired === null ? null : equal(current.indexBytes, desired);
1321
+ let retiredCommitted = null;
1322
+ const retiredStaged = removeLegacy ? !legacy.index : null;
1323
+ if (removeLegacy) {
1324
+ try {
1325
+ retiredCommitted = (await git(root, ['ls-tree', '-z', 'HEAD', '--', RETIRED], signal)).length === 0;
1326
+ }
1327
+ catch { /* Absence in the working tree does not prove a committed deletion. */ }
1328
+ if (stagedMatches === true)
1329
+ stagedMatches = retiredStaged;
1330
+ if (committedMatches === true)
1331
+ committedMatches = retiredCommitted;
1332
+ }
1333
+ return { ok: true, standing, actions, localOnly: true, stagedMatches, committedMatches,
1334
+ ...(removeLegacy ? { retiredRemoval: { path: RETIRED, staged: retiredStaged, committed: retiredCommitted } } : {}) };
1335
+ }
1336
+ catch (error) {
1337
+ return { ok: false, actions, reason: error instanceof Refusal ? error.message : 'The workflow could not be synchronized. Inspect the local files before continuing.' };
1338
+ }
1339
+ finally {
1340
+ if (temporary)
1341
+ await unlink(temporary).catch(() => { });
1342
+ }
1343
+ };
1344
+ const result = queue.then(run, run);
1345
+ queue = result.then(() => undefined, () => undefined);
1346
+ return result;
1347
+ };
1348
+ }
1349
+ export function createTools(ctx) {
1350
+ const text = (t, terminate = false) => ({ content: [{ type: 'text', text: t }], details: undefined, ...(terminate ? { terminate } : {}) });
1351
+ const retry = previewRetryState(ctx.log);
1352
+ retry.repository ??= `${ctx.owner}/${ctx.repo}`;
1353
+ /** The branches of the recorded test drive and setup pull request, as GitHub named them. */
1354
+ let recordedBranches;
1355
+ retry.recordedPr ??= ctx.sync.payload.setup_pr;
1356
+ let memory = previewMemories.get(ctx.log);
1357
+ if (!memory) {
1358
+ memory = { active: false, knownRuns: new Map(), attempts: new Map(), changes: new Set() };
1359
+ previewMemories.set(ctx.log, memory);
1360
+ }
1361
+ const previews = memory;
1362
+ const attempts = () => [...previews.attempts.values()].sort((a, b) => a.runId - b.runId || a.attempt - b.attempt);
1363
+ const budget = () => ({ used: retry.used, pushReservations: retry.pushReservations, remaining: Math.max(0, 3 - retry.used), historyKnown: retry.historyKnown });
1364
+ const attemptTasks = () => attempts().map((a, i) => ({ id: `preview-attempt-${a.runId}-${a.attempt}`,
1365
+ title: `Preview job attempt ${i + 1}`, status: a.status === 'success' ? 'completed' : a.status === 'running' ? 'in_progress' : 'failed' }));
1366
+ const withAttempts = (tasks) => [...tasks.filter((t) => !t.id.startsWith('preview-attempt-')), ...attemptTasks()];
1367
+ async function observe(signal) {
1368
+ const pr = retry.pr;
1369
+ const reservationsAtStart = retry.pushReservations;
1370
+ let observation;
1371
+ try {
1372
+ observation = await (ctx.previewRunReader ?? readPreviewRuns)(ctx.owner, ctx.repo, pr.number, pr.branch, { signal, knownRuns: previews.knownRuns });
1373
+ }
1374
+ catch {
1375
+ observation = { pull: null, runs: [], currentRunId: null,
1376
+ coverage: { limit: 20, truncated: false, incomplete: true, unassociatedHistory: false }, error: 'evidence-unavailable' };
1377
+ }
1378
+ const complete = reservationsAtStart === retry.pushReservations && !observation.error && !observation.coverage.truncated && !observation.coverage.incomplete && !observation.coverage.unassociatedHistory;
1379
+ observePreviewAttempts(retry, observation.runs.map((r) => ({ runId: r.id, attempt: r.attempt })), complete);
1380
+ if (!observation.error)
1381
+ for (const run of observation.runs) {
1382
+ if (previews.knownRuns.size < LIMIT || previews.knownRuns.has(run.id))
1383
+ previews.knownRuns.set(run.id, { head: run.head, workflowHead: run.workflowHead });
1384
+ else
1385
+ retry.historyKnown = false;
1386
+ }
1387
+ return observation;
1388
+ }
1389
+ /** What the person typed into sensitive questions, by the reference the model holds instead. */
1390
+ const typed = new Map();
1391
+ /** The origins of the successful Preview deployments of pull requests GitHub listed, and of the built deploy previews Netlify's evidence listed, which a proof may open; whether one answered with this app; and the address pattern a Netlify proof answered. */
1392
+ const listedPreviews = new Set();
1393
+ const netlifyListed = new Set();
1394
+ let hostedPreviewProven = false;
1395
+ let provenPattern;
1396
+ /** The host whose preview a proof answered with this app, and the one whose previews impeccable_write then saved as the method. */
1397
+ let provenHost;
1398
+ let savedHost;
1399
+ /**
1400
+ * A host's build, waited for on the screen as the task in progress with its timer: `read` again every so often until
1401
+ * `built` says the evidence is final, or the wait runs out. The status line names the host and what it builds.
1402
+ */
1403
+ async function awaitBuild(status, read, built, signal) {
1404
+ let evidence = await read();
1405
+ if (built(evidence))
1406
+ return evidence;
1407
+ const wait = ctx.previewWait ?? { totalMs: 300_000, everyMs: 15_000 };
1408
+ said(status);
1409
+ reportWait({ since: Date.now(), limitMs: wait.totalMs });
1410
+ try {
1411
+ for (const end = Date.now() + wait.totalMs; !built(evidence) && Date.now() < end; evidence = await read()) {
1412
+ await sleep(wait.everyMs, undefined, { signal });
1413
+ }
1414
+ }
1415
+ finally {
1416
+ reportWait(null);
1417
+ }
1418
+ return evidence;
1419
+ }
1420
+ /** What a wait for a pull request's build is for: the test drive's preview, or another pull request's. */
1421
+ const subjectOf = async (pull) => {
1422
+ const found = await ctx.viewPullRequest(pull);
1423
+ return 'headRefName' in found && testDriveBranch(found.headRefName) ? "the test drive's preview" : `the preview of #${pull}`;
1424
+ };
1425
+ /** Of the origins listed, those of deployments Vercel's own bot made: the only addresses a bypass secret is ever sent to, since a deployment anyone with write access can record names any address. */
1426
+ const vercelMade = new Set();
1427
+ /** The address impeccable_prove_preview met Vercel's login at, and the words it looked for there. */
1428
+ let gated;
1429
+ /** The bypass secret saved in this run, for the proof of a Vercel address to send; never for the model. It is one project's, so it goes only to the origins impeccable_vercel_bypass proved it on, never to another project's deployment of this repository. */
1430
+ let bypassSecret;
1431
+ const bypassOrigins = new Set();
1432
+ /** What impeccable_vercel_bypass answered, less the secret: a skip is asked once, so that answer stands; the saved one is what an address the secret opens later gets. */
1433
+ let bypassOutcome;
1434
+ let bypassSaved;
1435
+ // Keep ownership of files for this one run; never recreate it per call.
1436
+ const workflowSynchronizer = ctx.workflowSynchronizer ?? createWorkflowSynchronizer();
1437
+ const workflowRefusal = (reason) => ({ ok: false, reason, actions: [] });
1438
+ const workflowReady = (access) => access.authenticated === true
1439
+ && access.originMatches === true && access.defaultBranch !== null && !access.needsWorkflowScope;
1440
+ async function workflowPreparation(pi, signal) {
1441
+ signal?.throwIfAborted();
1442
+ const access = await (ctx.workflowAccessReader ?? readWorkflowAccess)(ctx.owner, ctx.repo, pi.cwd, { signal });
1443
+ signal?.throwIfAborted();
1444
+ const base = workflowReady(access)
1445
+ ? await (ctx.workflowBaseRefresher ?? refreshWorkflowBase)(ctx.owner, ctx.repo, access.defaultBranch, pi.cwd, { signal })
1446
+ : null;
1447
+ signal?.throwIfAborted();
1448
+ return { ...access, base };
1449
+ }
1450
+ // This call deliberately omits requestJson's logger: its network-error catch
1451
+ // logs raw exception text. Only fixed failures leave this owned boundary.
1452
+ async function freshWorkflowState(pi, signal) {
1453
+ try {
1454
+ signal?.throwIfAborted();
1455
+ const read = async (token) => {
1456
+ signal?.throwIfAborted();
1457
+ const result = await requestJson(`${ctx.setupApi}/state`, { token });
1458
+ signal?.throwIfAborted();
1459
+ return result;
1460
+ };
1461
+ let result = await read(await ctx.auth.token(pi));
1462
+ if (result.status === 401)
1463
+ result = await read(await ctx.auth.renew(pi));
1464
+ if (!result.ok)
1465
+ throw new Error();
1466
+ return result.data;
1467
+ }
1468
+ catch {
1469
+ throw new Error('The current workflow state could not be read. Nothing was synchronized.');
1470
+ }
1471
+ }
1472
+ async function workflowAccessTool(pi, signal) {
1473
+ try {
1474
+ return JSON.stringify(await workflowPreparation(pi, signal));
1475
+ }
1476
+ catch {
1477
+ return JSON.stringify(workflowRefusal(signal?.aborted
1478
+ ? 'Workflow access was cancelled.' : 'Workflow access could not be checked. Nothing was synchronized.'));
1479
+ }
1480
+ }
1481
+ /** A status line from a tool, on the screen and the web page as the model's own [STATUS] lines go. */
1482
+ const said = (status) => {
1483
+ ctx.sync.update({ status });
1484
+ reportStatus(status);
1485
+ };
1486
+ /** A screen a tool puts up on its own: the web page shows the run waiting on it, as on the model's questions, until it is answered. */
1487
+ const ask = async (screen) => {
1488
+ ctx.sync.update({ phase: 'waiting', question: { id: screen.id, prompt: screen.prompt } });
1489
+ const answer = await askPerson({ ...screen, skippable: true });
1490
+ ctx.sync.update({ phase: 'running', question: null });
1491
+ return answer;
1492
+ };
1493
+ /** Each host's sign-in this run: confirmed on its screen, or found signed in already, so the screen shows once; skipped, so nothing more is asked for that host. */
1494
+ const hostSignIns = new Map();
1495
+ /** The teams a host's sign-in offered, each by name and slug: the choices a `<host>-team` question shows, whatever the model lists, so the pick the fence takes is a slug. */
1496
+ const teamChoices = new Map();
1497
+ const HOST_NAMES = { netlify: 'Netlify', vercel: 'Vercel' };
1498
+ const hostCli = (host, pi, signal) => {
1499
+ const runner = (browser = false) => (ctx.hostRunner ?? commandRunner)(host, pi.cwd, { signal, browser, timeoutMs: browser ? 300_000 : 30_000 });
1500
+ const cli = runner();
1501
+ if (host === 'vercel') {
1502
+ return {
1503
+ status: () => vercelStatus(cli),
1504
+ login: () => runner(true)(['login']),
1505
+ // The CLI's current scope is the team; one team is the team; several with none current are the person's pick.
1506
+ team: async () => {
1507
+ const teams = await vercelTeams(cli);
1508
+ const one = teams.find((t) => t.current) ?? (teams.length === 1 ? teams[0] : undefined);
1509
+ return one ? { team: one.value, choices: [] } : { choices: teams.map(({ label, value }) => ({ label, value })) };
1510
+ },
1511
+ };
1512
+ }
1513
+ let teams = [];
1514
+ return {
1515
+ status: async () => { const status = await netlifyStatus(cli); teams = status.teams; return status; },
1516
+ login: () => runner(true)(['login']),
1517
+ // The CLI creates the site in the person's own current team; several teams are the person's pick, which the fence takes for `--account-slug`.
1518
+ team: async () => ({ choices: teams.length > 1 ? await netlifyTeams(cli) : [] }),
1519
+ };
1520
+ };
1521
+ /**
1522
+ * A host's sign-in, on `impeccable_ask` with the id `<host>-setup`: a CLI signed in already opens the setup with no
1523
+ * screen; else the sign-in screen once, and on its yes the CLI's login in the browser, checked with the CLI, a retry
1524
+ * screen when it did not complete, and after two failed retries a skip. The fence opens on the sign-in found or
1525
+ * completed (and on the yes for the CLI's install alone): never on anything the model writes.
1526
+ */
1527
+ async function hostSignIn(host, pi, signal) {
1528
+ const cli = hostCli(host, pi, signal);
1529
+ const signedIn = async () => {
1530
+ agreeToHostSetup(ctx.log, `${host}-setup`);
1531
+ hostSignIns.set(host, 'confirmed');
1532
+ const { team, choices } = await cli.team();
1533
+ if (team)
1534
+ chooseHostTeam(ctx.log, `${host}-team`, team);
1535
+ teamChoices.set(host, choices);
1536
+ return { confirmed: true, signedIn: true, ...(team ? { team } : {}), ...(choices.length > 1 ? { teams: choices } : {}) };
1537
+ };
1538
+ if (hostSignIns.get(host) === 'skipped')
1539
+ return { skipped: true };
1540
+ let status = await cli.status();
1541
+ if (status.cli === 'signed-in')
1542
+ return signedIn();
1543
+ if (!hostSignIns.has(host)) {
1544
+ const answer = await ask({ id: `${host}-setup`, ...HOST_QUESTIONS[`${host}-setup`] });
1545
+ if (answer.skipped) {
1546
+ hostSignIns.set(host, 'skipped');
1547
+ return { skipped: true };
1548
+ }
1549
+ hostSignIns.set(host, 'confirmed');
1550
+ }
1551
+ // The CLI is installed through the fence's package-manager rule, which this yes opens; then this is asked again.
1552
+ if (status.cli === 'missing') {
1553
+ agreeToHostSetup(ctx.log, `${host}-setup`);
1554
+ return { confirmed: true, cli: 'missing' };
1555
+ }
1556
+ for (let attempt = 0;; attempt++) {
1557
+ said(`Opening the browser for ${HOST_NAMES[host]}'s login`);
1558
+ try {
1559
+ await cli.login();
1560
+ }
1561
+ catch {
1562
+ // What the status says decides.
1563
+ }
1564
+ status = await cli.status();
1565
+ if (status.cli === 'signed-in')
1566
+ return signedIn();
1567
+ if (attempt === 2) {
1568
+ hostSignIns.set(host, 'skipped');
1569
+ return { skipped: true, reason: `The ${HOST_NAMES[host]} sign-in did not complete.` };
1570
+ }
1571
+ if ((await ask(HOST_SCREENS[`${host}-signin-retry`])).skipped) {
1572
+ hostSignIns.set(host, 'skipped');
1573
+ return { skipped: true };
1574
+ }
1575
+ }
1576
+ }
1577
+ /**
1578
+ * The one-time access a host's GitHub app needs when it cannot reach the repository: a screen that opens the app's page
1579
+ * on GitHub, a wait of up to five minutes for the host to reach it (`reaches`, tried again every so often), a retry
1580
+ * screen, and after two failed retries the connection left to the person.
1581
+ */
1582
+ async function hostAccess(host, result, reaches, signal) {
1583
+ const name = HOST_NAMES[host];
1584
+ for (let retries = 0; result.access && !signal?.aborted; retries++) {
1585
+ if ((await ask(HOST_SCREENS[retries ? `${host}-access-retry` : `${host}-access`])).skipped)
1586
+ return { ...result, reason: `The person skipped giving ${name}'s GitHub app access to the repository.` };
1587
+ (ctx.openBrowser ?? openBrowser)(result.access);
1588
+ said(`Waiting for ${name} to reach the repository`);
1589
+ result = await reaches();
1590
+ if (signal?.aborted)
1591
+ return { ...result, reason: `The run stopped while ${name} was waited for.` };
1592
+ if (result.access && retries === 2)
1593
+ return { ...result, reason: `${name}'s GitHub app still cannot reach the repository.` };
1594
+ }
1595
+ return result;
1596
+ }
1597
+ /** The connection of the linked Netlify site with this repository, through Netlify's site settings and its GitHub app, with the app's access asked when it cannot read the repository. */
1598
+ async function netlifyConnect(pi, settings, signal) {
1599
+ // The site's settings change on the host: only once the person opened the Netlify setup, as the fence holds for the CLI.
1600
+ if (!hostSetupAgreed(ctx.log, 'netlify'))
1601
+ return { connected: false, reason: 'The Netlify sign-in comes first: impeccable_ask with the id netlify-setup.' };
1602
+ const netlify = (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal });
1603
+ const gh = (ctx.hostRunner ?? commandRunner)('gh', pi.cwd, { signal });
1604
+ const clone = ctx.hostWait?.clone;
1605
+ const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, settings, signal, ...(clone ? { wait: clone } : {}) });
1606
+ // A build every 30 s until one reads the repository: the person is giving the app access meanwhile.
1607
+ return hostAccess('netlify', result, async () => (await waitForNetlifyAccess(result.site.id, { netlify, signal, wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 30_000 }, ...(clone ? { clone } : {}) })
1608
+ ? { connected: true, site: result.site, how: 'github-app' } : result), signal);
1609
+ }
1610
+ /** The connection of the linked Vercel project with this repository, `vercel git connect`, with the app's access asked when Vercel's GitHub app cannot reach the repository. */
1611
+ async function vercelConnect(pi, signal) {
1612
+ if (!hostSetupAgreed(ctx.log, 'vercel'))
1613
+ return { connected: false, reason: 'The Vercel sign-in comes first: impeccable_ask with the id vercel-setup.' };
1614
+ const vercel = (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal });
1615
+ const result = await connectVercelProject(vercel);
1616
+ // Tried again every 20 s until the app reaches the repository: the person is giving it access meanwhile.
1617
+ return hostAccess('vercel', result, () => waitForVercelAccess(vercel, { wait: ctx.hostWait?.access ?? { totalMs: 300_000, everyMs: 20_000 }, signal }), signal);
1618
+ }
1619
+ async function syncWorkflowTool(pi, signal) {
1620
+ try {
1621
+ const access = await workflowPreparation(pi, signal);
1622
+ if (!workflowReady(access))
1623
+ return JSON.stringify(workflowRefusal(access.needsWorkflowScope ? 'The active GitHub CLI account is missing workflow scope for HTTPS. Recheck access after updating it.'
1624
+ : 'Workflow access is not ready. Recheck authentication, origin and the default branch.'));
1625
+ if (access.base?.status !== 'refreshed' || access.base.branch !== access.defaultBranch || !access.base.sha)
1626
+ return JSON.stringify(workflowRefusal('The current default branch could not be refreshed. Nothing was synchronized.'));
1627
+ signal?.throwIfAborted();
1628
+ return JSON.stringify(await workflowSynchronizer(pi.cwd, ctx.repo, () => freshWorkflowState(pi, signal), signal));
1629
+ }
1630
+ catch {
1631
+ return JSON.stringify(workflowRefusal(signal?.aborted
1632
+ ? 'Workflow synchronization was cancelled.' : 'The workflow could not be synchronized. Inspect the local files before continuing.'));
1633
+ }
1634
+ }
1635
+ function tool(name, label, description, schema, run) {
1636
+ return defineTool({
1637
+ name,
1638
+ label,
1639
+ description,
1640
+ parameters: schema,
1641
+ async execute(_id, params, signal, _onUpdate, pi) {
1642
+ const error = schemaError(schema, params);
1643
+ if (error)
1644
+ return text(`Invalid input: ${error}.`);
1645
+ try {
1646
+ const out = await run(params, pi, signal);
1647
+ return typeof out === 'string' ? text(out) : text(out.text, out.terminate);
1648
+ }
1649
+ catch (err) {
1650
+ ctx.log.write(`${name} failed`, err instanceof Error ? err.message : String(err));
1651
+ return text(`Error: ${err instanceof Error ? err.message : String(err)}`);
1652
+ }
1653
+ },
1654
+ });
1655
+ }
1656
+ /** A setup API call with the current setup token, and once more after signing in again if the API refuses it. */
1657
+ const call = async (pi, url, init = {}) => {
1658
+ const res = await requestJson(url, { ...init, token: await ctx.auth.token(pi) }, ctx.log);
1659
+ return res.status === 401 ? requestJson(url, { ...init, token: await ctx.auth.renew(pi) }, ctx.log) : res;
1660
+ };
1661
+ /** A read of the setup API, trimmed to this setup before the model sees it. */
1662
+ const get = async (pi, path, focus) => {
1663
+ const res = await call(pi, `${ctx.setupApi}${path}`);
1664
+ return answerText(res.ok ? { ...res, data: focus(res.data) } : res);
1665
+ };
1666
+ return [
1667
+ 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)),
1668
+ 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)),
1669
+ 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))),
1670
+ 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) => {
1671
+ if (!retry.pr)
1672
+ return 'Refused: record the setup pull request with impeccable_test_pr first.';
1673
+ if (p.appKey !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(p.appKey))
1674
+ return 'Refused: use the app key from setup state.';
1675
+ previews.active = true;
1676
+ retry.requireHistory = true;
1677
+ const observation = await observe(signal);
1678
+ const state = await call(pi, `${ctx.setupApi}/state`);
1679
+ const repos = object(state.data)?.repos;
1680
+ const repo = Array.isArray(repos) ? repos.map(object).find((r) => r?.name === ctx.repo) : undefined;
1681
+ const preview = object(object(repo?.items)?.preview);
1682
+ const apps = Array.isArray(preview?.apps) ? preview.apps.map(object).filter((a) => a) : [];
1683
+ const declarations = Array.isArray(repo?.apps) ? repo.apps.map(object).filter((a) => a?.enabled !== false) : [];
1684
+ const selectedKey = p.appKey;
1685
+ const onlyKey = apps.length === 1 && typeof apps[0]?.key === 'string' ? apps[0].key : typeof preview?.app === 'string' ? preview.app : declarations.length === 1 && typeof declarations[0]?.key === 'string' ? declarations[0].key : 'main';
1686
+ const selected = apps.length ? (apps.length > 1 && !selectedKey ? undefined : apps.find((a) => a?.key === (selectedKey ?? onlyKey))) : !selectedKey || selectedKey === onlyKey ? preview : undefined;
1687
+ if (!state.ok || !selected || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(selectedKey ?? onlyKey)
1688
+ || (!apps.length && declarations.length > 1 && !preview?.app))
1689
+ return JSON.stringify({ ...observation, attempts: attempts(), budget: budget(), diagnosis: 'The selected app state could not be verified.' });
1690
+ const appKey = selectedKey ?? onlyKey;
1691
+ previews.selectedApp = appKey;
1692
+ let recordedChanges = false;
1693
+ for (const run of observation.runs) {
1694
+ // Never expand arbitrary external rerun counts into an unbounded UI.
1695
+ const observedAttempts = Array.from({ length: Math.min(run.attempt, LIMIT - 1) }, (_, i) => i + 1);
1696
+ if (run.attempt >= LIMIT)
1697
+ observedAttempts.push(run.attempt);
1698
+ for (const attempt of observedAttempts) {
1699
+ const key = `${run.id}/${attempt}`, previous = previews.attempts.get(key);
1700
+ if (!previous && previews.attempts.size >= LIMIT) {
1701
+ retry.historyKnown = false;
1702
+ continue;
1703
+ }
1704
+ if (previous && (attempt !== run.attempt || run.id !== observation.currentRunId))
1705
+ continue;
1706
+ const current = attempt === run.attempt;
1707
+ const failed = current && run.status === 'completed' && run.conclusion !== 'success';
1708
+ let stage = apps.length < 2 && current ? run.stage : null;
1709
+ let reason = !current ? 'This earlier attempt was not observed.' : failed
1710
+ ? 'The detailed cause was not verified; inspect the linked run.'
1711
+ : run.status === 'completed' ? 'The job passed; preview access still needs verification.' : 'The preview job is still running.';
1712
+ if (current && run.id === observation.currentRunId) {
1713
+ const last = object(selected.lastRun);
1714
+ const at = typeof last?.at === 'string' ? Date.parse(/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(last.at) ? last.at.replace(' ', 'T') + 'Z' : last.at) : NaN;
1715
+ if (last?.runUrl === run.url && run.startedAt && at >= Date.parse(run.startedAt)
1716
+ && at <= Date.parse(run.updatedAt) + 60_000 && at <= Date.now() + 60_000
1717
+ && PREVIEW_STAGES.includes(last.failedAt) && run.conclusion !== 'success') {
1718
+ stage = last.failedAt;
1719
+ reason = previewReason(last.reason);
1720
+ }
1721
+ }
1722
+ const diagnostic = { appKey, stage, reason };
1723
+ recordedChanges ||= !previous && current && run.id === observation.currentRunId;
1724
+ const entry = { runId: run.id, attempt, head: run.head, url: run.url,
1725
+ status: !current ? 'unverified' : failed ? 'failed' : run.status === 'completed' ? 'success' : 'running', stage, reason,
1726
+ changes: previous?.changes ?? (run.id === observation.currentRunId ? [...previews.changes] : []),
1727
+ diagnostics: [...(previous?.diagnostics ?? []).filter((d) => d.appKey !== appKey), diagnostic] };
1728
+ previews.attempts.set(key, entry);
1729
+ }
1730
+ }
1731
+ if (recordedChanges)
1732
+ previews.changes.clear();
1733
+ const tasks = withAttempts(ctx.sync.payload.tasks);
1734
+ ctx.sync.update({ tasks });
1735
+ reportTasks(tasks);
1736
+ return JSON.stringify({ ...observation, attempts: attempts(), budget: budget() });
1737
+ }),
1738
+ tool('impeccable_state', 'Setup state', 'Read the setup state again, trimmed to this setup: each repository\'s scan, preview and sign-in items, and the setup page\'s other cards in notPartOfThisSetup.', NO_INPUT, (_p, pi) => get(pi, '/state', focusState)),
1739
+ tool('impeccable_checkup', 'Checkup', 'Run the checkup for this setup\'s items: it reads GitHub again and answers status (complete, waiting or incomplete), next (what to do, in order), forPerson (what only the person can do) and waitingFor (what needs time).', NO_INPUT, (_p, pi) => get(pi, '/checkup', focusCheckup)),
1740
+ tool('impeccable_write', 'Write a setting', 'Write a setting through the setup API, at an endpoint the instructions list and with the method they give.', WRITE, async (p, pi, signal) => {
1741
+ const target = resolveWrite(ctx.instructions, ctx.setupApi, ctx.repo, p.path);
1742
+ if ('error' in target)
1743
+ return `Refused: ${target.error}`;
1744
+ if (/\/(preview-auth\/[^/]+|preview-protection)$/.test(target.url))
1745
+ return 'Refused: a Vercel bypass secret is saved by impeccable_vercel_bypass, which opens the preview with it first, and never through this tool. Nothing was saved.';
1746
+ if (target.url.endsWith('/preview') && object(p.body)?.intendedMethod === 'preview-deploy' && !hostedPreviewProven) {
1747
+ 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.";
1748
+ }
1749
+ const saved = object(p.body);
1750
+ if (target.url.endsWith('/preview') && saved && 'previewUrlPattern' in saved && (typeof saved.previewUrlPattern !== 'string' || saved.previewUrlPattern.trim() !== provenPattern)) {
1751
+ return 'Refused: the only address pattern saved is the one impeccable_prove_preview answered for a Netlify preview. Nothing was saved.';
1752
+ }
1753
+ const testAccounts = target.url.endsWith('/test-accounts');
1754
+ let body = p.body;
1755
+ if (testAccounts) {
1756
+ // Only what the person typed into a hidden question is ever sent as a password.
1757
+ const list = object(body)?.accounts;
1758
+ if (!Array.isArray(list) || !list.length)
1759
+ return 'Refused: "accounts" lists at least one account. When the person skipped every role, write nothing: an empty list would delete the saved ones.';
1760
+ if (list.some((account) => !typed.has(String(object(account)?.password)))) {
1761
+ return 'Refused: each "password" is the reference impeccable_ask gave for a password the person typed, never a password read from a file, the seed or the chat.';
1762
+ }
1763
+ body = { ...object(body), accounts: list.map((account) => ({ ...object(account), password: typed.get(String(object(account)?.password)) })) };
1764
+ }
1765
+ const checkedHead = previews.active && target.url.endsWith('/latest-pull-check') ? await observe(signal) : undefined;
1766
+ const response = await call(pi, target.url, { method: target.method, body });
1767
+ if (target.url.endsWith('/preview') && response.ok) {
1768
+ const keys = object(object(response.data)?.keys);
1769
+ if (keys?.intendedMethod === 'stored' && ['ci-tunnel', 'static-upload'].includes(String(object(p.body)?.intendedMethod)))
1770
+ retry.requireHistory = true;
1771
+ if (object(p.body)?.intendedMethod === 'preview-deploy')
1772
+ savedHost = provenHost;
1773
+ for (const key of Object.keys(object(p.body) ?? {}))
1774
+ if (PREVIEW_FIELDS.has(key) && keys?.[key] === 'stored')
1775
+ previews.changes.add(key);
1776
+ }
1777
+ if (previews.active && target.url.endsWith('/latest-pull-check')) {
1778
+ if (response.status === 429)
1779
+ return JSON.stringify({ waiting: true, outcome: 'rate-limited', retryAfterSeconds: 30 });
1780
+ if (response.status === 409 && object(response.data)?.outcome === 'no-preview-running')
1781
+ return JSON.stringify({ waiting: true, outcome: 'no-preview-running' });
1782
+ const after = await observe(signal);
1783
+ const check = object(object(response.data)?.check), pull = object(check?.pull);
1784
+ const appKey = object(p.body)?.appKey;
1785
+ if (!response.ok || checkedHead?.error || after.error || !after.pull || checkedHead?.pull?.head !== after.pull.head
1786
+ || !pull || pull.number !== retry.pr?.number || pull.headSha !== after.pull.head
1787
+ || (appKey !== undefined && check?.appKey !== appKey) || (previews.selectedApp && appKey !== previews.selectedApp))
1788
+ return JSON.stringify({ verified: false, reason: 'The preview check did not match the recorded pull request, current head and selected app.' });
1789
+ const outcome = ['reached', 'sign-in', 'not-found', 'no-preview', 'timed-out', 'unreachable', 'unverified'].find((o) => o === check?.outcome);
1790
+ return JSON.stringify({ verified: outcome === 'reached', outcome: outcome ?? 'unverified', pull: { number: pull.number, headSha: pull.headSha } });
1791
+ }
1792
+ // A test account never comes back to the model, even if the API echoed it.
1793
+ return testAccounts ? redact(answerText(response), typed.values()) : answerText(response);
1794
+ }),
1795
+ 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. 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) => {
1796
+ // A skip never stops the run, so every question offers one.
1797
+ const asked = p;
1798
+ const host = /^(netlify|vercel)-(setup|team)$/.exec(asked.id);
1799
+ if (host?.[2] === 'setup')
1800
+ return JSON.stringify(await hostSignIn(host[1], pi, signal));
1801
+ if (host && (teamChoices.get(host[1])?.length ?? 0) < 2)
1802
+ return `Invalid input: ${asked.id} is asked only when the ${HOST_NAMES[host[1]]} sign-in answered teams to choose from.`;
1803
+ const fixed = Object.hasOwn(HOST_QUESTIONS, asked.id) ? HOST_QUESTIONS[asked.id] : undefined;
1804
+ // What the person reads in a fixed question's choice is what is recorded: an option's value is its label, whatever value the model gave it; a host's teams are the ones its sign-in offered, each read by its name and recorded by its slug.
1805
+ const options = host ? teamChoices.get(host[1]) : asked.options?.map(({ label }) => ({ label, value: label }));
1806
+ const q = { ...(fixed ? { id: asked.id, ...fixed, options } : asked), skippable: true };
1807
+ const error = askError(q);
1808
+ if (error)
1809
+ return `Invalid input: ${error}.`;
1810
+ const answer = await askPerson(q);
1811
+ if (answer.skipped)
1812
+ return JSON.stringify({ skipped: true });
1813
+ if (q.kind === 'multi')
1814
+ return JSON.stringify({ values: answer.values ?? [] });
1815
+ if (q.kind === 'confirm') {
1816
+ agreeToHostSetup(ctx.log, q.id);
1817
+ return JSON.stringify({ confirmed: true });
1818
+ }
1819
+ if (q.kind === 'single')
1820
+ chooseHostTeam(ctx.log, q.id, answer.value ?? '');
1821
+ if (q.kind !== 'account' && !q.sensitive)
1822
+ return JSON.stringify({ value: answer.value ?? '' });
1823
+ // The model never holds what the person typed as a password: it gets a reference, which impeccable_write turns back.
1824
+ const reference = `secret:${randomBytes(6).toString('hex')}`;
1825
+ typed.set(reference, (q.kind === 'account' ? answer.password : answer.value) ?? '');
1826
+ return JSON.stringify(q.kind === 'account' ? { username: answer.username ?? '', password: reference } : { value: reference });
1827
+ }),
1828
+ tool('impeccable_progress', 'Progress', 'Show the person where things stand, here and on the web page: the [STATUS] line and the whole task list. It refuses, saving nothing, a status line out of the playbook\'s voice, more than one task in progress, and a status line about another task than the one in progress.', PROGRESS, async (p) => {
1829
+ const status = p.status.trim();
1830
+ const tasks = withAttempts(p.tasks);
1831
+ const voice = statusVoiceError(status) ?? stageError(status, p.tasks.filter((t) => !t.id.startsWith('preview-attempt-')));
1832
+ if (voice)
1833
+ return voice;
1834
+ ctx.sync.update({ phase: 'running', status, tasks });
1835
+ reportStatus(status);
1836
+ reportTasks(tasks);
1837
+ return 'Saved.';
1838
+ }),
1839
+ tool('impeccable_test_pr', 'Record the pull requests', 'Record the test drive as soon as it is open, so the web page follows its review, and again with setup_pr once the setup pull request is open. Each must be an open pull request from this repository\'s own impeccable/ branch; the setup pull request, once recorded, cannot change.', TEST_PR, async (p, _pi, signal) => {
1840
+ const drive = { number: p.number, url: p.url.trim() };
1841
+ const given = p.setup_pr;
1842
+ const setup = given && { number: given.number, url: given.url.trim() };
1843
+ if (setup?.number === drive.number)
1844
+ return `Refused: #${drive.number} cannot be both the test drive and the setup pull request.`;
1845
+ const found = await runPullRequest(ctx, drive, 'impeccable/test-drive', signal);
1846
+ if ('problem' in found)
1847
+ return `Refused: ${found.problem}.`;
1848
+ let setupBranch = recordedBranches?.setup;
1849
+ if (setup) {
1850
+ // The setup pull request carries the preview attempts, so its binding cannot change.
1851
+ const head = await runPullRequest(ctx, setup, 'impeccable/setup', signal);
1852
+ if ('problem' in head)
1853
+ return `Refused: ${head.problem}.`;
1854
+ const bound = bindPreviewPullRequest(retry, { ...setup, branch: head.branch });
1855
+ if (bound.verdict !== 'allow')
1856
+ return `Refused: ${bound.reason}.`;
1857
+ setupBranch = head.branch;
1858
+ }
1859
+ ctx.sync.update({ test_pr: drive, ...(setup ? { setup_pr: setup } : {}) });
1860
+ recordedBranches = { drive: found.branch, setup: setupBranch };
1861
+ const res = await ctx.sync.flush();
1862
+ const what = `the test drive #${drive.number}${setup ? ` and the setup pull request #${setup.number}` : ''}`;
1863
+ return res?.ok
1864
+ ? `Recorded ${what}. The web page follows the test drive's review from now on.`
1865
+ : `Recorded ${what} here, but the web page did not take it yet (${res?.error ?? 'no answer'}). The report links them too.`;
1866
+ }),
1867
+ tool('impeccable_report', 'Report', 'Finish the run: save the report, which the web page shows, and what is left for the person; the terminal then shows the outcome and the command exits.', REPORT, async (p, pi, signal) => {
1868
+ const left = (p.left ?? []).map((line) => line.trim());
1869
+ const long = left.find((line) => line.length > STATUS_MAX);
1870
+ if (long)
1871
+ return `Refused: "${long}" is longer than ${STATUS_MAX} characters. Each line of "left" is one short line the person can act on; the detail belongs in the report. Nothing was saved: call impeccable_report again.`;
1872
+ // The web page keeps the task list as the report leaves it, so no task may still read as waiting or running (#83).
1873
+ const open = ctx.sync.payload.tasks.filter((t) => !t.id.startsWith('preview-attempt-') && (t.status === 'pending' || t.status === 'in_progress'));
1874
+ if (open.length)
1875
+ return `Refused: the task list still shows ${open.map((t) => `"${t.title}" as ${t.status.replace('_', ' ')}`).join(', ')}. Call impeccable_progress with every task's final status (completed, skipped or failed), then impeccable_report again. Nothing was saved.`;
1876
+ let markdown = p.markdown;
1877
+ if (previews.active && retry.pr) {
1878
+ await observe(signal);
1879
+ 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(', ')}.` : ''}`);
1880
+ markdown += `\n\n## Preview attempts\n\n${lines.length ? lines.join('\n') : 'No workflow attempt could be verified.'}\n\n${retry.pushReservations} push reservation(s); at least ${retry.used} reserved or observed attempt(s). Failed or uncertain pushes retain their reservation.`;
1881
+ // The run never waits for the review, so it cannot vouch for the current head's.
1882
+ markdown += "\nThe current head's review was not verified.";
1883
+ if (!left.includes('Watch the review on the test drive'))
1884
+ left.push('Watch the review on the test drive');
1885
+ }
1886
+ const { phase, status, question, report_markdown, test_pr } = ctx.sync.payload;
1887
+ // A test drive branched before the setup branch was corrected ran on the old head: it shows nothing about this one.
1888
+ const covered = test_pr && recordedBranches?.setup
1889
+ ? await (ctx.driveCoversSetup ?? driveCoversSetup)(pi.cwd, recordedBranches.setup, recordedBranches.drive) : null;
1890
+ const drive = covered === false ? undefined : test_pr;
1891
+ ctx.sync.update({ phase: 'completed', status: 'Setup finished', question: null, report_markdown: markdown, test_pr: drive });
1892
+ const res = await ctx.sync.flush();
1893
+ if (res && res.status >= 400 && res.status < 500) {
1894
+ // A definite no: the run is still open on the server, so the session goes back to what it was and the agent can send the report again.
1895
+ ctx.sync.update({ phase, status, question, report_markdown, test_pr });
1896
+ return `Error: the report was refused (${res.status}: ${res.error}). Fix what it says and call impeccable_report again.`;
1897
+ }
1898
+ // Otherwise final: a completed write whose answer was lost may still have landed, so it is never taken back.
1899
+ ctx.onReport({ markdown, left, testPr: drive, setupPr: ctx.sync.payload.setup_pr, ...(savedHost ? { hostedBy: savedHost } : {}) });
1900
+ pi.shutdown();
1901
+ const saved = res?.ok ? 'Saved.' : `The web page did not take it (${res?.error ?? 'no answer'}).`;
1902
+ return { text: `${saved} The terminal shows the summary and the command exits.`, terminate: true };
1903
+ }),
1904
+ 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, whether Vercel made it and the pull requests whose commit it built. An error says nothing either way. 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 (a project\'s first deployment is called Production, whatever branch it builds, and counts). It changes nothing on the host and proves nothing about the page.', HOSTED_PREVIEWS, async (p, _pi, signal) => {
1905
+ const branch = p.branch;
1906
+ const read = () => (ctx.hostedPreviewReader ?? readHostedPreviews)(ctx.owner, ctx.repo, { signal, branch });
1907
+ // With `branch` the evidence holds only the deployments of its head, and Vercel's is its preview whatever environment it is called: the first deployment of a
1908
+ // project is its Production one, even from a branch. Another integration's deployment of the same commit says nothing about it. Without it a preview is a Preview.
1909
+ const preview = (d) => (branch !== undefined ? d.vercel : /preview/i.test(d.environment ?? ''));
1910
+ // A head's preview is still building until it has a final status.
1911
+ const final = (evidence) => Boolean(evidence.error) || evidence.deployments.some((d) => preview(d) && (d.state === 'success' || d.state === 'failure' || d.state === 'error'));
1912
+ const evidence = branch !== undefined ? await awaitBuild(`Waiting for Vercel to build ${testDriveBranch(branch) ? "the test drive's preview" : `the preview of ${branch}`}`, read, final, signal) : await read();
1913
+ listedPreviews.clear();
1914
+ vercelMade.clear();
1915
+ for (const d of evidence.deployments) {
1916
+ if (d.state === 'success' && d.url && preview(d) && (d.pullRequests.length || branch !== undefined)) {
1917
+ const { origin } = new URL(d.url);
1918
+ listedPreviews.add(origin);
1919
+ if (d.vercel)
1920
+ vercelMade.add(origin);
1921
+ }
1922
+ }
1923
+ return JSON.stringify(evidence);
1924
+ }),
1925
+ 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). 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. It changes nothing on Netlify and proves nothing about the page.", NETLIFY_PREVIEWS, async (p, pi, signal) => {
1926
+ const pull = p.pull;
1927
+ const read = () => (ctx.netlifyPreviewReader ?? readNetlifyPreviews)(ctx.owner, ctx.repo, pi.cwd, { signal, pull });
1928
+ // A pull request's preview is still building until Netlify's deploy of it is built or has failed.
1929
+ const states = ['success', 'ready', 'failure', 'error'];
1930
+ const final = (evidence) => Boolean(evidence.error) || evidence.previews.some((preview) => states.includes(preview.state ?? ''));
1931
+ const evidence = pull !== undefined ? await awaitBuild(`Waiting for Netlify to build ${await subjectOf(pull)}`, read, final, signal) : await read();
1932
+ netlifyListed.clear();
1933
+ for (const p of evidence.previews)
1934
+ if (p.state === 'success' || p.state === 'ready')
1935
+ netlifyListed.add(new URL(p.url).origin);
1936
+ return JSON.stringify(evidence);
1937
+ }),
1938
+ tool('impeccable_prove_preview', 'Check a hosted preview', 'Open a preview address impeccable_hosted_previews or impeccable_netlify_previews listed for a pull request or a pushed branch (a successful Preview deployment, or a deploy preview that is ready), or a page on it, as a visitor signed in nowhere, and prove it: it answers, and the page shows words a file of this checkout holds. proven true lets impeccable_write save intendedMethod "preview-deploy"; for Netlify it answers the address pattern to save beside it. gate "vercel-protection" or "netlify-protection" means the host\'s protection stands in front of it. Nothing of the page comes back.', PROVE_PREVIEW, async (p, pi, signal) => {
1939
+ const url = p.url;
1940
+ if (!URL.canParse(url) || !(listedPreviews.has(new URL(url).origin) || netlifyListed.has(new URL(url).origin)))
1941
+ return 'Refused: open the address of a preview that impeccable_hosted_previews or impeccable_netlify_previews listed for a pull request or a pushed branch, or a page on it.';
1942
+ const proof = await provePreview(url, p.expect, pi.cwd, { fetch: ctx.previewFetch, signal, ...(bypassSecret && bypassOrigins.has(new URL(url).origin) ? { bypass: bypassSecret } : {}) });
1943
+ hostedPreviewProven ||= proof.proven;
1944
+ if (proof.proven)
1945
+ provenHost = previewHostName(url) ?? provenHost;
1946
+ if (proof.proven && proof.pattern)
1947
+ provenPattern = proof.pattern;
1948
+ if (proof.gate === 'vercel-protection')
1949
+ gated = { url, expect: p.expect };
1950
+ return JSON.stringify(proof);
1951
+ }),
1952
+ tool('impeccable_netlify_connect', 'Connect the repository to Netlify', "Connect the Netlify site this folder is linked to with this repository, through the site's settings and Netlify's GitHub app, so that pull requests build deploy previews. The run does it; when Netlify's app cannot read the repository, this asks the person once for its access on GitHub and waits. connected true means Netlify read the repository; how \"already\" means the site was built from it before. A connection left undone answers why, and the GitHub page for the person in access.", NETLIFY_CONNECT, async (p, pi, signal) => {
1953
+ const command = p.command;
1954
+ const publish = p.publish;
1955
+ if (command !== undefined && (command.length > 200 || /[\r\n\0]/.test(command)))
1956
+ return 'Refused: the build command is one line of at most 200 characters.';
1957
+ if (publish !== undefined && publish !== '.' && !staticRelativePath(publish))
1958
+ return 'Refused: publish is a folder relative to the checkout, such as "dist".';
1959
+ return JSON.stringify(await netlifyConnect(pi, { command, publish }, signal));
1960
+ }),
1961
+ tool('impeccable_vercel_connect', 'Connect the repository to Vercel', "Connect the Vercel project this folder is linked to with this repository (vercel git connect), so that pull requests get preview deployments. The run does it; when Vercel's GitHub app cannot reach the repository, this asks the person once for its access on GitHub and waits. connected true means the repository is connected, or was already. A connection left undone answers why, and the GitHub page for the person in access.", NO_INPUT, async (_p, pi, signal) => JSON.stringify(await vercelConnect(pi, signal))),
1962
+ tool('impeccable_vercel_bypass', "Vercel's login", 'Get past the Vercel login that impeccable_prove_preview met (gate "vercel-protection") in front of a preview Vercel made, the way Vercel documents for tools: a bypass secret of the Vercel project, sent to it in a header. It does the rest with the Vercel CLI signed in as the person: it finds the project\'s secret labelled "Impeccable reviews", or shows one screen asking to create one, or, when the CLI cannot, one screen taking a pasted one; opens the preview with it; and saves it for reviews. Answers {"status": …}: saved (the preview is proven, so the method may be saved; secret says whether it was created, existing or typed, and project which Vercel project holds it), skipped (the person skipped the screen; reason says what came before), refused (no secret opened the preview; reason says what the address answered) or save-failed (the setup API did not take it all; reason says how far it got: call it once more). No secret ever comes back.', NO_INPUT, async (_p, pi, signal) => {
1963
+ if (!gated || !vercelMade.has(new URL(gated.url).origin))
1964
+ 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.';
1965
+ if (bypassOutcome?.status === 'skipped')
1966
+ return JSON.stringify(bypassOutcome);
1967
+ const at = gated;
1968
+ const origin = new URL(at.url).origin;
1969
+ // A secret in hand already: an address of the same project opens with it, and no other project's does.
1970
+ if (bypassSaved && bypassSecret && (await provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: bypassSecret })).proven) {
1971
+ bypassOrigins.add(origin);
1972
+ return JSON.stringify(bypassSaved);
1973
+ }
1974
+ /** The store took this attempt's secret: what was in hand no longer matches it, unless the setting followed. */
1975
+ let stored = false;
1976
+ const { value, ...told } = await passVercelLogin({
1977
+ run: (ctx.hostRunner ?? commandRunner)('vercel', pi.cwd, { signal }),
1978
+ link: () => readLink(pi.cwd),
1979
+ consent: async () => !(await ask(HOST_SCREENS['vercel-bypass'])).skipped,
1980
+ paste: async () => {
1981
+ const answer = await ask(HOST_SCREENS['vercel-bypass-key']);
1982
+ return answer.skipped ? undefined : answer.value?.trim();
1983
+ },
1984
+ prove: (secret) => provePreview(at.url, at.expect, pi.cwd, { fetch: ctx.previewFetch, signal, bypass: secret }),
1985
+ save: async (secret) => {
1986
+ const base = `${ctx.setupApi}/repos/${encodeURIComponent(ctx.repo)}`;
1987
+ if (!(await call(pi, `${base}/preview-auth/protection-header`, { method: 'PUT', body: { value: secret } })).ok)
1988
+ return 'The setup API did not store the secret.';
1989
+ stored = true;
1990
+ if (!(await call(pi, `${base}/preview-protection`, { method: 'PUT', body: { method: 'header', headerName: BYPASS_HEADER } })).ok)
1991
+ return 'The setup API stored the secret, but not the setting that sends it: it is turned on under Deployment protection on the setup page.';
1992
+ return null;
1993
+ },
1994
+ remember: noteSensitiveAnswer,
1995
+ });
1996
+ bypassOutcome = { ...told, ...(told.status === 'saved' ? { proven: true } : {}) };
1997
+ if (value) {
1998
+ // What is in hand follows the store: a new secret is the one saved now, and the origins the last one opened no longer stand.
1999
+ bypassSecret = value;
2000
+ bypassOrigins.clear();
2001
+ bypassOrigins.add(origin);
2002
+ hostedPreviewProven = true;
2003
+ provenHost = 'Vercel';
2004
+ bypassSaved = bypassOutcome;
2005
+ }
2006
+ else if (told.status === 'save-failed' && stored) {
2007
+ // The store holds the secret this attempt wrote without the setting that sends it: nothing in hand stands until a save goes through. A store the attempt never changed keeps the earlier one good.
2008
+ bypassSecret = undefined;
2009
+ bypassOrigins.clear();
2010
+ bypassSaved = undefined;
2011
+ }
2012
+ return JSON.stringify(bypassOutcome);
2013
+ }),
2014
+ ];
2015
+ }