@impeccable/reviews 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,6 +24,7 @@ Keys: Enter goes on, Esc cancels the intro or the sign-in and skips a question (
24
24
  | Flag | Default | What it does |
25
25
  | --- | --- | --- |
26
26
  | `--install-dir <path>` | the folder the command is run in | The folder to set up, a checkout of the repository or a folder inside one, so the command runs from anywhere: `npx @impeccable/reviews --install-dir ~/code/your-repo`. A relative path is taken from the folder the command is run in; `IMPECCABLE_REVIEWS_INSTALL_DIR` does the same when the option is absent. |
27
+ | `--app <key>` | asked | On a repository with several apps, sets up that one app, by its key (such as `storefront`): one confirm screen names it instead of the list of apps, and only that app is touched. Without it, every run asks, with the apps that are on already picked. |
27
28
  | `--host <origin>` | `https://impeccable.reviews` | Impeccable's address. Plain `http` only for localhost. |
28
29
  | `--model <id>` | the model the service hands out (Sonnet 5.5 today) | The model the setup agent runs on, through Impeccable's gateway. |
29
30
  | `--effort <level>` | `high` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` or `max`. |
package/dist/bin.js CHANGED
File without changes
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { cloudflarePreviewsFact, cloudflarePreviewsPage } from './host-previews.js';
3
+ import { cloudflarePreviewsFact, cloudflarePreviewsPage, folderOf } from './host-previews.js';
4
4
  import { commandRunner } from './netlify.js';
5
5
  import { ownFolder, ownNpmCache } from './sandbox.js';
6
6
  /**
@@ -300,8 +300,16 @@ async function fromCf(named, cf, repository, settings, signal) {
300
300
  const listed = await listCloudflareProjects(cf, accounts, signal);
301
301
  const built = listed.filter(({ project: p }) => {
302
302
  const source = record(record(p.source)?.config);
303
- return same(source?.owner, repository.owner) && same(source?.repo_name, repository.repo);
303
+ // For one app, only a project whose root directory is the app's folder.
304
+ return same(source?.owner, repository.owner) && same(source?.repo_name, repository.repo) && (repository.directory === undefined || folderOf(record(p.build_config)?.root_dir) === repository.directory);
304
305
  });
306
+ if (repository.directory !== undefined) {
307
+ // One app's project is the one project that builds its folder: none, or several, is told, and nothing else is read.
308
+ const matching = built.flatMap(({ project: p }) => (typeof p.name === 'string' ? [p.name] : []));
309
+ if (built.length !== 1)
310
+ return { cli, rows: [], failed: false, matching };
311
+ named = matching[0];
312
+ }
305
313
  // Of several projects built from this repository, the one the addresses or the config name; else one that builds the pull requests, since any that does previews them; else the first.
306
314
  // A project the checkout names (its config, or the README's pages.dev address) that is connected to no repository at all is this one's to connect; one built from another repository is not.
307
315
  const unconnected = ({ project: p }) => !record(p.source) && ((named !== undefined && p.name === named) || (typeof p.subdomain === 'string' && repository.readme.includes(p.subdomain)));
@@ -393,9 +401,22 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
393
401
  const github = await fromGitHub(owner, repo, gh, deps.pull, deps.branch);
394
402
  const config = await wranglerConfig(cwd);
395
403
  const readme = plain ? await readmeAddresses(cwd) : { pages: [] };
396
- const named = github.previews.map((p) => pagesProject(p.url)).find(Boolean) ?? (config?.kind === 'pages' ? config.name : undefined);
404
+ // For one app's folder, an address or the root's config names no app's project: only the project's settings do.
405
+ const app = deps.directory !== undefined && plain;
406
+ // `project`, one app's told by an earlier read, is the one read: never the project the first check run names, which may be another app's.
407
+ const named = app ? undefined : deps.project ?? github.previews.map((p) => pagesProject(p.url)).find(Boolean) ?? (config?.kind === 'pages' ? config.name : undefined);
397
408
  // cf installs itself through npx on a machine without it, which takes a while the first time. The project's settings are read with the plain evidence, not while a build is waited for.
398
- const cf = await fromCf(named, deps.cf ?? cfRunner(), { owner, repo, heads: github.heads.map((h) => h.sha), readme: readme.pages }, plain, deps.signal);
409
+ const cf = await fromCf(named, deps.cf ?? cfRunner(), { owner, repo, heads: github.heads.map((h) => h.sha), readme: readme.pages, ...(app ? { directory: deps.directory } : {}) }, plain, deps.signal);
410
+ const where = deps.directory ? `the folder ${deps.directory}` : "the repository's root";
411
+ const unmatched = !app ? undefined
412
+ : cf.cli !== 'signed-in' ? `Which Cloudflare Pages project builds ${where} cannot be told: cf here is not signed in with Pages access, and only a project's settings name the folder it builds (its root directory).`
413
+ : (cf.matching?.length ?? 0) > 1 ? `Several Cloudflare Pages projects build ${where} (${cf.matching.join(', ')}), so which one is this app's cannot be told.`
414
+ : !cf.project ? `No Cloudflare Pages project of this repository builds ${where} (its root directory), as far as cf here can see.` : undefined;
415
+ if (unmatched)
416
+ github.previews = [];
417
+ // A preview's address names the project's own host, which a taken name suffixes (crewline-x1y.pages.dev), not always its name.
418
+ else if (app)
419
+ github.previews = github.previews.filter((p) => pagesProject(p.url) === (cf.subdomain ? pagesProject(`https://${cf.subdomain}`) : cf.project));
399
420
  const project = cf.project ?? named;
400
421
  // A Worker built from the repository: its check runs, the config or the README say so, and the check runs say how its newest build went.
401
422
  const script = github.worker?.script ?? (config?.kind === 'worker' ? config.name : undefined);
@@ -437,6 +458,7 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
437
458
  ...(cf.account ? { account: cf.account } : {}),
438
459
  ...(worker ? { worker } : {}),
439
460
  ...(githubApp ? { githubApp } : {}),
461
+ ...(unmatched ? { unmatched } : {}),
440
462
  };
441
463
  }
442
464
  /** The Pages project as `cf pages get` prints it, in the first of the sign-in's accounts that holds one of that name; null when none does, or cf could not read it. */
@@ -13,6 +13,22 @@ import { readSiteCall } from './netlify-password.js';
13
13
  */
14
14
  /** The branch the test drive pushes, whose build is the preview the setting must allow. */
15
15
  export const TEST_DRIVE_BRANCH = 'impeccable/test-drive';
16
+ /**
17
+ * A folder of the repository as a host's project names the one it builds (Vercel's `rootDirectory`, Netlify's `base`,
18
+ * Cloudflare Pages' `root_dir`) or the setup state names an app's (`directory`): relative to the repository's root, no
19
+ * leading `./` or `/` and no trailing `/`, the root itself being '' (none, `.` and `/` alike). Undefined for anything
20
+ * else, which matches no app.
21
+ */
22
+ export function folderOf(value) {
23
+ if (value === null || value === undefined)
24
+ return '';
25
+ if (typeof value !== 'string' || value.length > 400 || /[\\\0]/.test(value))
26
+ return undefined;
27
+ const folder = value.trim().replace(/^(\.\/|\/)+/, '').replace(/\/+$/, '');
28
+ if (folder === '.' || folder === '')
29
+ return '';
30
+ return folder.split('/').every((part) => part && part !== '.' && part !== '..') ? folder : undefined;
31
+ }
16
32
  const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
17
33
  const name = (value) => (typeof value === 'string' && /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(value) ? value : undefined);
18
34
  const rules = (value) => (Array.isArray(value) ? value.filter((v) => typeof v === 'string') : []);
@@ -224,8 +224,28 @@ export function sentTo(screen, url) {
224
224
  const line = `This run sends it first to ${url}, to prove it opens before anything saves it.`;
225
225
  return screen.kind === 'confirm' ? { ...screen, hint: `${screen.hint} ${line}` } : { ...screen, prompt: `${screen.prompt} ${line}` };
226
226
  }
227
+ /** The apps question on a repository with several apps, worded here: the tool lists the apps the state has, each picked as it is on, so every run asks it the same way. */
228
+ export const APPS_QUESTION = {
229
+ id: 'apps',
230
+ kind: 'multi',
231
+ title: 'This repository has several apps',
232
+ prompt: 'Which apps should get Impeccable previews?',
233
+ hint: 'Each draws pages of its own; untick any that should not get previews.',
234
+ };
235
+ /** The apps question's hint when the command was started below the repository's top: `where` is an app's folder ("Tally Board's folder") or the folder itself. */
236
+ export const startedIn = (where) => `You started in ${where}. Impeccable sets up from the top of the repository.`;
237
+ /** The apps question when the command was started for one app (`--app`): no list, one confirm screen naming that app, and the list behind Skip for someone who wants another app (#286). */
238
+ export const namedAppQuestion = (app) => ({
239
+ id: APPS_QUESTION.id,
240
+ kind: 'confirm',
241
+ title: APPS_QUESTION.title,
242
+ hint: `You're setting up ${app.name} (${app.folder || '/'}); the other apps stay as they are.`,
243
+ prompt: `Set up ${app.name}?`,
244
+ confirmLabel: `Set up ${app.name}`,
245
+ skipLabel: 'Pick other apps',
246
+ });
227
247
  /** The ids whose screens the tool puts up itself, when it needs the person, and takes down: the web page follows those, not the model's call. */
228
- export const TOOL_SHOWN = new Set(['netlify-setup', 'vercel-setup']);
248
+ export const TOOL_SHOWN = new Set(['netlify-setup', 'vercel-setup', APPS_QUESTION.id]);
229
249
  /** A question as the person reads it: the fixed words for a host's id, else the model's own. */
230
250
  export function hostQuestion(q) {
231
251
  const fixed = Object.hasOwn(HOST_QUESTIONS, q.id) ? HOST_QUESTIONS[q.id] : undefined;
package/dist/hosted.js CHANGED
@@ -168,11 +168,11 @@ async function checkoutHolds(root, words) {
168
168
  return false;
169
169
  }
170
170
  }
171
- /** Whether a file the checkout tracks names the address's origin as such, whole (a full stop after it is the sentence's): not as the tail of a longer host name, such as a deploy preview's of the same site. */
172
- export async function checkoutNames(root, origin) {
171
+ /** Whether a file the checkout tracks (in `folder`, one app's, when given) names the address's origin as such, whole (a full stop after it is the sentence's): not as the tail of a longer host name, such as a deploy preview's of the same site. */
172
+ export async function checkoutNames(root, origin, folder = '') {
173
173
  const escaped = origin.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
174
174
  try {
175
- await execFileAsync('git', ['grep', '--quiet', '--ignore-case', '--extended-regexp', '-e', `(^|[^A-Za-z0-9.-])${escaped}([^A-Za-z0-9.-]|\\.([^A-Za-z0-9]|$)|$)`, '--', '.', ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000, env: gitEnvironment() });
175
+ await execFileAsync('git', ['grep', '--quiet', '--ignore-case', '--extended-regexp', '-e', `(^|[^A-Za-z0-9.-])${escaped}([^A-Za-z0-9.-]|\\.([^A-Za-z0-9]|$)|$)`, '--', `:(literal)${folder || '.'}`, ':(exclude,glob)**/.env*'], { cwd: root, timeout: 30_000, env: gitEnvironment() });
176
176
  return true;
177
177
  }
178
178
  catch {
package/dist/main.js CHANGED
@@ -16,7 +16,7 @@ import { scanRecorder } from './scan.js';
16
16
  import { createReporter, reportingOff } from './report.js';
17
17
  import { createSessionSync, pageStatus, plainReport, syncExtension } from './sync.js';
18
18
  import { checkPushAccess, readPushAccess } from './push-access.js';
19
- import { createTools, ownPullRequest, resultLine } from './tools.js';
19
+ import { appsSummary, createTools, ownPullRequest, resultLine } from './tools.js';
20
20
  import { transcriptPaths } from './transcript.js';
21
21
  import { askPerson } from './ui/bridge.js';
22
22
  import { atExit, closeScreens, helpMark, onScreenExit, recordFrames, showIntro, showOutro, showPullRequest, showRun, showSignIn } from './ui/screens.js';
@@ -193,6 +193,11 @@ export async function main(argv) {
193
193
  const setupPage = state.data.account?.setupPage;
194
194
  await nothingToSetUp(liveSync, reporter, 'skipped_repo', 'Repository skipped', SKIPPED, [...(typeof setupPage === 'string' && onHost(setupPage) ? [{ label: 'Setup page', url: setupPage }] : []), webPage]);
195
195
  }
196
+ // --app names one app of a repository with several: a key that is none of them stops here, with the keys there are, rather than asking about apps it did not mean.
197
+ const appKeys = (state.data?.repos ?? []).find((r) => r?.name === token.repository.name)?.apps?.flatMap((a) => (typeof a?.key === 'string' ? [a.key] : [])) ?? [];
198
+ if (flags.app && severalApps(state.data) && !appKeys.includes(flags.app)) {
199
+ throw new UserError(`--app ${flags.app} is not an app of ${slug}. Its apps are ${appKeys.join(', ')}. Run it again with one of them, or without --app.`);
200
+ }
196
201
  // The setup pushes as the person, so whether git may push is asked of GitHub now, before anything is created.
197
202
  const push = await checkPushAccess(slug, () => readPushAccess(checkout.root), async (screen) => {
198
203
  liveSync.update({ phase: 'waiting', question: { id: screen.id, prompt: screen.prompt } });
@@ -230,14 +235,17 @@ export async function main(argv) {
230
235
  const saved = await settle(liveSync.flush(), 5000);
231
236
  const { testPr } = report;
232
237
  const setupPr = shownSetupPr(report.setupPr);
238
+ // On a repository with several apps, "You're all set up" means every app this run picked is: one left unproven is named, with its one step (#286).
239
+ const apps = appsSummary(report.apps ?? []);
240
+ const allSetUp = Boolean(testPr) && !apps.notReady.length;
233
241
  // One pull request a screen, each opened in the browser and put on the clipboard as its screen appears: the test drive, then the setup pull request.
234
242
  const hand = { open: openBrowser, copy: copyText };
235
243
  if (testPr && setupPr)
236
244
  await showPullRequest(testPr.url, hand);
237
245
  await showOutro({
238
- outcome: testPr ? 'done' : 'partial',
239
- title: testPr ? "You're all set up" : 'Settings saved',
240
- result: resultLine({ ...report, setupPr }),
246
+ outcome: allSetUp ? 'done' : 'partial',
247
+ title: allSetUp ? "You're all set up" : apps.notReady.length ? `${apps.notReady.join(' and ')} ${apps.notReady.length === 1 ? 'is' : 'are'} not set up` : 'Settings saved',
248
+ result: [resultLine({ ...report, setupPr }), apps.line].filter(Boolean).join(' '),
241
249
  link: (setupPr ?? testPr)?.url,
242
250
  links: [
243
251
  ...(testPr ? [{ label: 'Test drive, watch the review there', url: testPr.url }] : []),
@@ -245,7 +253,7 @@ export async function main(argv) {
245
253
  ...(setupPr ? [{ label: testPr ? 'Setup pull request, merge this' : 'Setup pull request', url: setupPr.url }] : []),
246
254
  { label: 'Web page', url: page },
247
255
  ],
248
- todo: report.left,
256
+ todo: [...apps.todo, ...report.left],
249
257
  warnings: [...removed, ...(saved && !saved.ok ? [`The web page missed the last update (${saved.error}), so it may lag behind this.`] : [])],
250
258
  }, hand);
251
259
  // The web page has the report; when it missed it, the report stays here instead.
@@ -300,6 +308,9 @@ export async function main(argv) {
300
308
  previewWait: previewWait(),
301
309
  sandbox,
302
310
  pushAccess: push.outcome,
311
+ severalApps: severalApps(state.data),
312
+ ...(flags.app ? { app: flags.app } : {}),
313
+ start: checkout.start,
303
314
  }),
304
315
  extensions: [
305
316
  fenceExtension(checkout.root, log, checkout.protectedBranches, scratch, scans),
package/dist/netlify.js CHANGED
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { setTimeout as sleep } from 'node:timers/promises';
5
5
  import { promisify } from 'node:util';
6
- import { netlifyPreviewsFact, pickPreviewsFact } from './host-previews.js';
6
+ import { folderOf, netlifyPreviewsFact, pickPreviewsFact } from './host-previews.js';
7
7
  import { gitEnvironment } from './sandbox.js';
8
8
  /**
9
9
  * Netlify: the deploy previews this repository's pull requests get (from
@@ -124,7 +124,7 @@ async function fromGitHub(owner, repo, gh, only) {
124
124
  return { previews: found, heads: new Map(heads.map((head) => [head.number, head.sha])), failed };
125
125
  }
126
126
  /** What the Netlify CLI on this machine knows, when it is signed in: the sites built from this repository, and their deploy previews. */
127
- async function fromCli(owner, repo, netlify, only) {
127
+ async function fromCli(owner, repo, netlify, only, directory) {
128
128
  const { cli } = await netlifyStatus(netlify);
129
129
  const addresses = new Map();
130
130
  if (cli !== 'signed-in')
@@ -140,6 +140,9 @@ async function fromCli(owner, repo, netlify, only) {
140
140
  // A site is this repository's only when it is built from it: the folder's own link to a site says nothing of that.
141
141
  if (typeof site.name !== 'string' || typeof site.id !== 'string' || !new RegExp(`^${SITE}$`).test(site.name) || typeof path !== 'string' || path.toLowerCase() !== slug)
142
142
  continue;
143
+ // For one app, only the site whose base directory is the app's folder.
144
+ if (directory !== undefined && folderOf(record(site.build_settings)?.base) !== directory)
145
+ continue;
143
146
  sites.push(site.name);
144
147
  const fact = netlifyPreviewsFact(site);
145
148
  if (fact)
@@ -174,7 +177,16 @@ export async function readNetlifyPreviews(owner, repo, cwd, deps = {}) {
174
177
  if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(owner) || !/^[A-Za-z0-9_.-]{1,100}$/.test(repo) || repo === '.' || repo === '..') {
175
178
  return { sites: [], previews: [], cli: 'missing', error: "Netlify's previews could not be read." };
176
179
  }
177
- const [github, cli] = await Promise.all([fromGitHub(owner, repo, deps.gh ?? commandRunner('gh', cwd, { signal: deps.signal }), deps.pull), fromCli(owner, repo, deps.netlify ?? commandRunner('netlify', cwd, { signal: deps.signal }), deps.pull)]);
180
+ const [read, cli] = await Promise.all([fromGitHub(owner, repo, deps.gh ?? commandRunner('gh', cwd, { signal: deps.signal }), deps.pull), fromCli(owner, repo, deps.netlify ?? commandRunner('netlify', cwd, { signal: deps.signal }), deps.pull, deps.directory)]);
181
+ // For one app's folder, GitHub's record counts only for the site the CLI read as building it: a site's name says nothing of its folder.
182
+ const where = deps.directory ? `the folder ${deps.directory}` : "the repository's root";
183
+ const unmatched = deps.directory === undefined ? undefined
184
+ : cli.cli !== 'signed-in' ? `Which Netlify site builds ${where} cannot be told: the Netlify CLI here is not signed in, and only a site's settings name the folder it builds (its base directory).`
185
+ : cli.sites.length > 1 ? `Several Netlify sites build ${where} (${cli.sites.join(', ')}), so which one is this app's cannot be told.`
186
+ : !cli.sites.length && !cli.failed ? `No Netlify site of this repository builds ${where} (its base directory), as far as the Netlify CLI here can see.` : undefined;
187
+ const github = deps.directory === undefined ? read : { ...read, previews: unmatched ? [] : read.previews.filter((p) => cli.sites.includes(p.site)) };
188
+ if (unmatched)
189
+ Object.assign(cli, { sites: [], previews: [], addresses: new Map(), pullRequestPreviews: undefined });
178
190
  // A deploy of an older commit than its pull request's head says nothing of the head's own preview: a commit status, which names the head, outranks it, and a wait for that pull request's preview ignores it.
179
191
  const older = (p) => p.from === 'netlify cli' && Boolean(p.sha) && github.heads.has(p.pullRequest) && github.heads.get(p.pullRequest) !== p.sha;
180
192
  // Of a pull request's status and the CLI's deploy of the same commit the later change is the truer, a status winning a tie; an older commit's deploy comes next, a bot's comment, which names no commit and changes no state, last.
@@ -197,6 +209,7 @@ export async function readNetlifyPreviews(owner, repo, cwd, deps = {}) {
197
209
  ...(github.failed || cli.failed ? { error: "Some of Netlify's previews could not be read: what is missing is unknown, not absent." } : {}),
198
210
  // The site's settings ride with the plain evidence, not with a wait for one pull request's preview.
199
211
  ...(deps.pull === undefined && cli.pullRequestPreviews ? { pullRequestPreviews: cli.pullRequestPreviews } : {}),
212
+ ...(unmatched ? { unmatched } : {}),
200
213
  };
201
214
  }
202
215
  const CLONE_WAIT = { totalMs: 60_000, everyMs: 5_000 };
@@ -1,19 +1,18 @@
1
1
  ### When this applies
2
2
 
3
- The state's repository has an `apps` item: the scan proposed several apps, folders that each draw pages of their own, and the rest of the setup is asked only once the apps are answered. The scan proposes them, so while the scan item's `scan` is `running` the item may not be there yet: read the state again before taking the repository for one app. The person is asked once, this question, and nothing else about apps. These steps come before the playbook's step 3; steps 3 to 6 then go app by app.
3
+ The state's repository has an `apps` item: the scan proposed several apps, folders that each draw pages of their own, and the rest of the setup is asked only once the apps are answered. The scan proposes them, so while the scan item's `scan` is `running` the item may not be there yet: read the state again before taking the repository for one app. The person is asked once a run, this question, and nothing else about apps: **every run asks it**, however the setup page or an earlier run answered, since a run sets up the apps picked in it; as "The aim" says, it is the one question about apps. These steps come before the playbook's step 3, under the Apps task; steps 3 to 6 then go app by app.
4
4
 
5
5
  ### Steps
6
6
 
7
- 1. **Confirm the proposal** as it stands, while the item's status is `needs-you`: impeccable_write to `apps` with the `confirm` action the item's instructions give. Its answer lists every app of the repository, on and off, with its `key`, `name`, `pathPrefixes` and `enabled`; the one the scan switched on is the repository's default. Done when it answered `apps`.
8
- - **An item that is `done` or `skipped`** was answered before, on the setup page or by an earlier run (a run that stopped at the question left the scan's default, as a skip does): write nothing and ask nothing; the state's apps that are `enabled` are the chosen ones. Step 4.
9
- 2. **Ask which apps get previews**, once, with impeccable_ask: `id: "apps"`, `kind: "multi"`, `title` *"This repository has several apps"*, `prompt` *"Which apps should get Impeccable previews?"*, `hint` *"Each draws pages of its own; untick any that should not get previews."*, and `options` one per app the answer listed: `value` its `key`, `label` its `name` and folder (*"Storefront · apps/storefront"*), `selected: true` on every one. The repository's own app (`main`) is among them only where the Worker kept it as a product of its own, with pages outside every app's folder: its label is its name and `/`. Done when it answered `values`, or a skip.
10
- 3. **Write the picks**, one action a call as the item's instructions give them: `enable` each chosen app that is off, then `disable` each app that is on and was not chosen. The last app that is on cannot be switched off (409), which is why the enables come first. **After Skip**, write nothing more: the app the scan switched on stays the one that is on. Done when the last answer's `apps` show `enabled` true for every chosen app and false for the rest, or, after Skip, the confirm's answer stands.
7
+ 1. **Confirm the proposal** as it stands, while the item's status is `needs-you`: impeccable_write to `apps` with the `confirm` action the item's instructions give. Its answer lists every app of the repository, on and off, with its `key`, `name`, `pathPrefixes` and `enabled`; the one the scan switched on is the repository's default. An item that is `done` or `skipped` was answered before (on the setup page, or by an earlier run): no confirm, and step 2 all the same. Done when it answered `apps`, or the item was answered before.
8
+ 2. **Ask which apps this run sets up**, on every run, with impeccable_ask: `id: "apps"`, `kind: "multi"`, and the `prompt` *"Which apps should get Impeccable previews?"*. The tool words the question and lists the state's apps (the repository's own, `main`, only where the Worker kept it as a product of its own), each picked as it is on, or every one after this run's confirm. Mark the Apps task in progress first. Every write and host tool for an app is refused until it is answered. Done when it answered `values`, `named` (the command was started for that one app with `--app`, from its card on the dashboard: the tool showed one confirm screen naming that app instead of the list, and the list all the same when the person asked for other apps there), or a skip, whose `on` lists the apps that are on: the ones this run sets up. After `named`, that app is the one this run sets up: every host read, key, proof, method and check is its own, and no other app is mentioned past that screen.
9
+ 3. **Write the picks**, one action a call as the item's instructions give them: `enable` each chosen app that is off, then `disable` each app that is on and was not chosen. The last app that is on cannot be switched off (409), which is why the enables come first. **After `named`**, `enable` that app when it is off, and switch no other off. **After Skip**, write nothing more: the apps that are on stay on. Done when the last answer's `apps` show `enabled` true for every chosen app and false for the rest (after `named`, true for that app), or, after Skip, nothing was written.
11
10
  4. **Read the state again** with impeccable_state. Done when the preview item's `apps` names each chosen app's `directory`, `build` and `method` (none yet for an app not set up), and `perApp` its status.
12
- 5. **Set each chosen app up**, one after the other, as the playbook's steps 3 and 4 say for that app alone, `appKey` on every write and check (`preview`, `test-accounts`, `latest-pull-check`, `connection-check`): its method by the case that applies to it (server-previews when its pages need a server, static-site when they do not, a host's case when the host deploys that app as a project of its own, proven on that app's own address), and its own sign-in when the scan item's `accessByApp` says it has one, each account question's title naming the app (*"Order desk admin account"*). Done when every chosen app's method is saved, or its preview skipped as server-previews says, and its sign-in saved or skipped, and no other app got a write, a build or a question.
13
- 6. **The pull requests** as pull-requests says, once for the repository: the setup commit takes the state's one workflow `file` (a job for each chosen static site whose preview Impeccable builds) once every chosen app's method and build settings are saved, or its preview skipped as server-previews says; the test drive adds its page to every chosen app with a preview (an app whose preview was skipped gets none, and goes under "What was skipped, and why"), in that app's own framework and linked from that app's own public page, so one review renders each of them on its own preview; that case's step 3 proves each chosen app with the input that proved it; its step 6 checks once, with the `appKey` of the first chosen app with a preview, and the other apps' previews go under "What is not verified". Done when pull-requests' steps are done.
11
+ 5. **Set each chosen app up**, one after the other, as the playbook's steps 3 and 4 say for that app alone, `appKey` on every write and check (`preview`, `test-accounts`, `latest-pull-check`, `connection-check`): its method by the case that applies to it (server-previews when its pages need a server, static-site when they do not, a host's case when the host deploys that app as a project of its own, proven on that app's own address: impeccable_hosted_previews, impeccable_netlify_previews and impeccable_cloudflare_previews take its `appKey` and list only the previews of the project or site that builds its folder, so another app's address, project or key never stands in for it; their `unmatched` says why none can be told, and that app goes as server-previews or static-site says, with the reason under "What is not verified"), and its own sign-in when the scan item's `accessByApp` says it has one, each account question's title naming the app (*"Order desk admin account"*). **The lock in front of each app's previews is that app's own**: when its proof answers a `gate`, preview-protection's steps run for that app, with its `appKey`, before any other app's step (the tools refuse the next app's reads and writes until its key tool was called), and the key goes to that app's own project or site; a key saved for one app says nothing about another's, and the state's `keySaved` speaks for one app at most. Done when every chosen app's method is saved, or its preview skipped as server-previews says, and its sign-in saved or skipped, and no other app got a write, a build or a question.
12
+ 6. **The pull requests** as pull-requests says, once for the repository: the setup commit takes the state's one workflow `file` (a job for each chosen static site whose preview Impeccable builds) once every chosen app's method and build settings are saved, or its preview skipped as server-previews says; the test drive adds its page to every chosen app with a preview (an app whose preview was skipped gets none, and goes under "What was skipped, and why"), in that app's own framework and linked from that app's own public page, so one review renders each of them on its own preview; that case's step 3 proves each chosen app with the input that proved it; its step 4 proves the test drive's page on each chosen app's own preview, with that app's key where its lock stands in front; its step 6 checks each chosen app with a preview, with its `appKey`, one call each. Done when pull-requests' steps are done.
14
13
  - **A chosen app whose preview was skipped** (server-previews): it stays on, with no method, no job in the workflow and no test drive page. It holds nothing back: the other apps' setup commit, test drive and checks go on as above, and its checkup entry left in `next` counts as done for the playbook's step 5. The report says, on its line and under "What was skipped, and why", that it gets no preview until its host deploys them.
15
14
  - **A host previews some chosen apps and Impeccable builds the others**: a host's case speaks for its own app. The setup commit carries the workflow for the others and the test drive branches from it, and pull-requests' step 4 goes each app's way: the host's app proven on the host's build, the others by the workflow. The repository goes without a workflow and a setup pull request only where a host previews every chosen app.
16
- 7. **Report per app**: under "What was configured, and by whom", one line per app the question offered: on or off and why (picked, unticked, the scan's default after a skip), its method, its sign-in, what was verified; an app left out is under "What was skipped, and why" as well, with the setup page as where it is turned on. Done when every app is on one line.
15
+ 7. **Report per app**: under "What was configured, and by whom", one line per app the question offered: on or off and why (picked, unticked, named with `--app`, on already after a skip), its method, its sign-in, what was verified; an app left out is under "What was skipped, and why" as well, with the setup page as where it is turned on. impeccable_report adds a section of its own, "Each app, as this run left it", from what the run saved and proved, and the last screen says "You're all set up" only when every chosen app is: an app whose preview met a lock with no key saved, or got no method, is named there with its one step, so the report's lines say the same. Done when every app is on one line.
17
16
 
18
17
  ### Reference
19
18
 
@@ -21,3 +20,4 @@ The state's repository has an `apps` item: the scan proposed several apps, folde
21
20
  - **Ask nothing else about apps**: names, a split or a merge are the person's on the setup page. A proposal that looks wrong (a shared package, a missing folder) goes into the report, never into a `split`.
22
21
  - **Status lines** name the app (*"Proving the storefront's preview"*), under the playbook's tasks.
23
22
  - The checkup's entries for an app read "<repository> (<app>): …", and its `apps` check is answered by step 1.
23
+ - **Each app's host key** (Vercel's bypass secret, Netlify's site password, Cloudflare's service token) is saved for that app alone, by the tool that gets it in place, and preview-protection's step 2 checks it with that app's `appKey`.
@@ -13,14 +13,14 @@ impeccable_prove_preview answered a `gate`: the host's own lock stands in front
13
13
  - Any other `refused` on Netlify whose `reason` says to prove or try a page a visitor opens, or that the page asks for the app's own sign-in past the lock: the site password (Netlify's own, or the one typed) got past the lock to the app's own sign-in on the page you proved, or opened neither the header nor Netlify's form, or Netlify did not answer. Prove a page a visitor opens without signing in, as netlify-previews' step 1 says, and call the tool again; the consent is not asked a second time. Refused again, step 3.
14
14
  - Any other `skipped` or `refused`: nothing was saved; `reason` says what stopped it. Step 3.
15
15
  - `save-failed`: the setup API did not take it all; `reason` says how far it got. Call the tool once more; when it fails again, step 3, with "Save the Vercel bypass secret on the setup page", "Save the Netlify site password on the setup page" or "Save the Cloudflare service token on the setup page" in `left`.
16
- 2. **Check it from Impeccable's side**: impeccable_write to `connection-check` with `{"appKey": "main"}`, and report what it answered. Then go on with the case that sent you here: the proof stands, so save the method as vercel-previews', netlify-previews' or cloudflare-previews' step 2 says, Cloudflare's with the tool's `pattern`. Done when the method is saved.
16
+ 2. **Check it from Impeccable's side**: impeccable_write to `connection-check` with `{"appKey": "main"}` (on a repository with several apps, the app's own key: the tool saved the key for that app), and report what it answered. Then go on with the case that sent you here: the proof stands, so save the method as vercel-previews', netlify-previews' or cloudflare-previews' step 2 says, Cloudflare's with the tool's `pattern`. Done when the method is saved.
17
17
  3. **When nothing was saved**, save no method and set up no other preview, whatever the checkup's `next` says, and open no test drive (pull-requests' "No preview, no test drive"). Say under "What is not verified" that the previews are behind the host's lock, with the tool's `reason`, and name the settings page where the person makes the key, or makes the Netlify site public (the report's bullet below); put the person's one step in `left`, the settings action alone and never running the setup again (for Netlify's team login, none: the person was asked in this run, so `left` stays empty and the report names the Visitor access page); end the report's "Not part of this setup" line with the lock the tool met: "Vercel's Deployment Protection in front of the previews", "Netlify's password in front of the previews", "Netlify's team login in front of the previews", or "Cloudflare Access in front of the previews". Done when the report says so.
18
18
 
19
19
  ### Reference
20
20
 
21
21
  - **The report** names, under "What was configured, and by whom", the Vercel project that holds the secret named "Impeccable reviews" (`project`), whether this run created it, found it there or the person typed it, and where it is revoked: the project's Settings → Deployment Protection → Protection Bypass for Automation; or the Netlify site (`site`) whose password is stored, whether this run read it from the site or the person typed it, and where it is changed: the site's Project configuration → General → Visitor access; or, with `opened`, the Netlify site made public, by this run on the person's yes or by the person on Netlify's page (`madePublic`), so anyone with its address opens it, and where it is made private again: the same Visitor access; or the service token named "Impeccable reviews", whether this run created it or the person typed it, the Access application whose policy allows it (`application`), when a created one runs out (`expires`), and where it is revoked: Zero Trust → Access controls → Service credentials → Service Tokens; with `apiToken`, the Cloudflare API token the person made for the run is theirs to delete (dash.cloudflare.com/profile/api-tokens). Revoking, regenerating or changing it stops reviews until a new one is saved on the setup page.
22
22
  - **The tool does the writes**: the key as Impeccable's credential for the lock, and the setting that sends it in the host's header (`x-vercel-protection-bypass`, `Authorization`, or `CF-Access-Client-Id` and `CF-Access-Client-Secret`), both through the setup API, so impeccable_write refuses those two. The state's `preview-protection` item words the same calls, and shows `keySaved` once they are done.
23
- - **A key saved before** (the item shows `keySaved` and a `check` that `passed`): the lock was handled by an earlier run or the person. Run step 2's check and go on; call the tool only when that check fails.
23
+ - **A key saved before** (the item shows `keySaved` and a `check` that `passed`): the lock was handled by an earlier run or the person. Run step 2's check and go on; call the tool only when that check fails. On a repository with several apps, the item's `keySaved` speaks for one app at most: an app whose own proof answered a `gate` gets step 1 with its `appKey`, whatever another app's key did, and its step 2 check is its own.
24
24
  - **The key is never yours**: it goes from the command's own process to the host and to Impeccable, and into no message, status line, file, command or report. `vercel project protection`, `vercel api`, `vercel curl` and `netlify api` print it, and the fence refuses them.
25
25
  - **The app's own sign-in** is another card: a preview opened past the lock proves nothing about it.
26
26
  - **Status lines** belong to the Preview method task. Word Vercel's login and Cloudflare Access as a login, and Netlify's lock as a password.
@@ -2,9 +2,11 @@
2
2
 
3
3
  Vercel deploys previews of the pull requests: impeccable_hosted_previews lists a successful Preview deployment that Vercel made (`vercel: true`) of a pull request, with its address. The checkout often shows it too (`vercel.json`, `.vercel/project.json`, a `vercel` dependency), but GitHub's record decides. Vercel builds and serves every preview, so these steps replace the playbook's step 3, and the repository needs no workflow and no setup pull request.
4
4
 
5
+ It applies as well **before the first pull request**: Vercel deploys the repository already (impeccable_hosted_previews lists deployments Vercel made, production ones or of pushes, and none of a pull request; on a repository with several apps, those of the project that builds the app, named in `project`), so its previews start with the test drive's push. Then there is nothing to prove in step 1 yet: impeccable_ask with `vercel-setup` is refused, since there is no sign-in, project or connection to make; go on to pull-requests' step 4, which pushes the test drive, waits for Vercel's deployment of the pushed branch (impeccable_hosted_previews with `branch`, and the app's `appKey`), proves the test drive's page there, past Vercel's login as preview-protection says, and then step 2 saves the method.
6
+
5
7
  ### Steps
6
8
 
7
- 1. **Prove the newest preview** with impeccable_prove_preview: the address of the newest successful Preview deployment of a pull request (or a page on it a visitor opens without signing in, when the app's own sign-in answers there), and words the page shows that the checkout holds, such as the app's name in its title. Done when it answers `proven: true`; when an address no longer answers, try the next newest, three at most.
9
+ 1. **Prove the newest preview** with impeccable_prove_preview: the address of the newest successful Preview deployment of a pull request (on a repository with several apps, the newest of those impeccable_hosted_previews listed for the app's `appKey`, the deployments of the Vercel project that builds its folder) (or a page on it a visitor opens without signing in, when the app's own sign-in answers there), and words the page shows that the checkout holds, such as the app's name in its title. Done when it answers `proven: true`; when an address no longer answers, try the next newest, three at most.
8
10
  2. **Save the method** with impeccable_write to `preview`: `{"intendedMethod": "preview-deploy"}`. The proof stands in for the person's answer, so ask nothing. Done when the answer's `keys` show `intendedMethod` stored or unchanged.
9
11
 
10
12
  ### Reference
@@ -1,6 +1,6 @@
1
1
  ### When this applies
2
2
 
3
- The checkout is set up for Vercel (`vercel.json`, `.vercel/project.json`, a `vercel` dependency or script), or the person picked Vercel in server-previews, and Vercel has never deployed it: impeccable_hosted_previews lists no deployment that Vercel made (`vercel: true`), and no `error` (an error says nothing either way). These steps replace the playbook's step 3. As in vercel-previews, the repository needs no workflow and no setup pull request.
3
+ The checkout is set up for Vercel (`vercel.json`, `.vercel/project.json`, a `vercel` dependency or script), or the person picked Vercel in server-previews, and Vercel has never deployed it: impeccable_hosted_previews lists no deployment that Vercel made (`vercel: true`) of any kind, production ones included, and no `error` (an error says nothing either way); on a repository with several apps, it names no project of the app's (`unmatched`). A repository Vercel deploys already, with no pull request yet, is vercel-previews' ("before the first pull request"), and step 1's screen is refused for it. These steps replace the playbook's step 3. As in vercel-previews, the repository needs no workflow and no setup pull request.
4
4
 
5
5
  ### Steps
6
6
 
@@ -22,6 +22,7 @@ The checkout is set up for Vercel (`vercel.json`, `.vercel/project.json`, a `ver
22
22
  - **A step fails before the test drive is pushed** (the sign-in skipped or not completed, the team skipped, the project not created, the repository not connected, or step 3 answering that the linked project is not this run's: `vercel link` linked one of the person's projects, named like the folder, which is left as it is): the Vercel setup ends there. Set the preview up as server-previews or static-site says, say under "What was skipped, and why" what stopped it, and put the person's step in `left` when there is one, such as "Give Vercel's GitHub app access to the repository", with the `access` page in the report.
23
23
  - **`pullRequestPreviews` in impeccable_hosted_previews' answer**: what vercel.json and the linked project say of the test drive's branch, read with the evidence and never changed by the run, since the setting is the repository's own or has no command (impeccable_enable_previews takes no Vercel). `enabled: false` (`git.deploymentEnabled` in vercel.json turns the branch off, or Vercel records no deployments on GitHub, where Impeccable finds its previews): the Vercel setup ends before step 4, as "A step fails before the test drive is pushed" says, and say in one line under "What was skipped, and why" that Vercel builds no preview of the pull requests, with the `reason`, and the exact change for the person: the vercel.json rule to lift, or Vercel GitHub deployments under the project's Settings → Git. `enabled: 'unknown'` (an Ignored Build Step decides commit by commit, or the CLI could not read the project): go on, and say so in one line under "What is not verified".
24
24
  - **A push before step 3** is refused, since a connected project builds every push: run impeccable_vercel_connect first.
25
+ - **On a repository with several apps**, the project step 2 creates builds the repository's root: impeccable_vercel_connect connects and locks it only where an app this run sets up is the root's, and otherwise answers so, and the Vercel setup ends there for that app, as "A step fails before the test drive is pushed" says.
25
26
  - **`locked: false`** from step 3, with `production` not "exists" (the person skipped the tool's screen, or the lock was still off after its retries): push nothing, and set up no other preview either, since the connected project would build that push as its first deployment, public at its own address. Mark the Test drive task failed, and say under "What was skipped, and why" what stopped the lock (the answer's `reason`).
26
27
  - **No preview for the pushed test drive**: save no method, set up no other preview and open no pull request, as pull-requests' "No preview, no test drive" says; the project and the connection stay, and the report names them.
27
28
  - **The build failed** (`state` is `failure` or `error`): `vercel deploy --yes` builds this checkout on Vercel and prints why it failed. When the cause is in the test drive's own change, correct it, commit, push once more and wait for that build; else the report names the cause.
package/dist/playbook.js CHANGED
@@ -61,7 +61,7 @@ Keep secrets out of files, settings and messages: the workflow names a repositor
61
61
  ## Voice
62
62
  The person reads your [STATUS] lines, task titles and questions, here and on the web page: calm, specific, one line each.
63
63
  - **[STATUS] lines**: start each step's message with one, a present-tense verb phrase of at most 70 characters saying what is happening (*"Running the build"*, *"Opening the test drive"*), or a short finding (*"Next.js 16, pnpm, Prisma on SQLite. No hosting found."*). impeccable_progress refuses a line that narrates like an assistant: write *"Reading the deploy workflows"*, not *"I'm trying to work out how the app deploys."* Detail belongs in the report.
64
- - **Tasks**: after each task, call impeccable_progress with the whole list. Titles are short noun phrases the person recognizes, the same every run where they fit: Read the repository, Install and build, Preview method, Preview workflow, Sign-in, Test drive, Setup pull request. The list carries only what this run does: add a task when its case starts, never before, and leave off a task no case of this run takes, such as Preview workflow and Setup pull request where a host builds the previews (impeccable_progress refuses them once its previews are the method). Mark a task skipped only when the person skipped it, and failed only when it was tried and failed. One task is in progress at a time, and every status line is the detail of that task (*"Installing with pnpm"* while Install and build is in progress); impeccable_progress refuses a status line about another task.
64
+ - **Tasks**: after each task, call impeccable_progress with the whole list. Titles are short noun phrases the person recognizes, the same every run where they fit: Read the repository, Apps (on a repository with several apps), Install and build, Preview method, Preview workflow, Sign-in, Test drive, Setup pull request. The list carries only what this run does: add a task when its case starts, never before, and leave off a task no case of this run takes, such as Preview workflow and Setup pull request where a host builds the previews (impeccable_progress refuses them once its previews are the method). Mark a task skipped only when the person skipped it, and failed only when it was tried and failed. One task is in progress at a time, and every status line is the detail of that task (*"Installing with pnpm"* while Install and build is in progress); impeccable_progress refuses a status line about another task.
65
65
  - **Questions**: one sentence, at most one hint line, and a title that names the subject.
66
66
 
67
67
  ## The report
package/dist/preflight.js CHANGED
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync, statSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
+ import { folderOf } from './host-previews.js';
5
6
  import { UserError } from './log.js';
6
7
  export const EFFORTS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
7
8
  /** The environment variable that names the folder to set up when --install-dir does not. */
@@ -17,6 +18,8 @@ Options:
17
18
  --install-dir <path>
18
19
  the folder to set up, in place of the one the command is
19
20
  run in (IMPECCABLE_REVIEWS_INSTALL_DIR does the same)
21
+ --app <key> on a repository with several apps, set up this one app,
22
+ by its key (such as storefront), without asking which
20
23
  --host <origin> Impeccable's address (default https://impeccable.reviews)
21
24
  --model <id> the model the setup agent runs on (defaults to the model the
22
25
  service hands out)
@@ -47,6 +50,7 @@ export function parseFlags(argv, env = process.env, cwd = process.cwd()) {
47
50
  allowPositionals: false,
48
51
  options: {
49
52
  'install-dir': { type: 'string' },
53
+ app: { type: 'string' },
50
54
  host: { type: 'string', default: 'https://impeccable.reviews' },
51
55
  debug: { type: 'boolean', default: false },
52
56
  transcript: { type: 'string' },
@@ -86,13 +90,15 @@ export function parseFlags(argv, env = process.env, cwd = process.cwd()) {
86
90
  throw new UserError('--frames needs a folder.');
87
91
  if (values['install-dir'] !== undefined && !values['install-dir'].trim())
88
92
  throw new UserError('--install-dir needs a folder.');
93
+ if (values.app !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(values.app.trim()))
94
+ throw new UserError('--app needs an app\'s key, such as storefront.');
89
95
  // The option wins over the environment variable, and a relative path is taken from the folder the command is run in.
90
96
  // The path stays as it was typed: a folder's name may begin or end with a space.
91
97
  const named = values['install-dir'] ?? env[INSTALL_DIR_ENV];
92
98
  const installDir = named?.trim() ? { path: resolve(cwd, named), from: values['install-dir'] !== undefined ? '--install-dir' : INSTALL_DIR_ENV } : undefined;
93
99
  // Paths from the folder the command is run in too: it goes on from the checkout's top.
94
100
  const path = (value) => (value === undefined ? undefined : resolve(cwd, value.trim()));
95
- return { host: host.origin, installDir, debug: values.debug, transcript: path(values.transcript), scanReport: values['scan-report'], frames: path(values.frames), model: values.model?.trim(), effort: values.effort, yes: values.yes, noSandbox: values['no-sandbox'], noTelemetry: values['no-telemetry'], help: values.help };
101
+ return { host: host.origin, installDir, ...(values.app !== undefined ? { app: values.app.trim() } : {}), debug: values.debug, transcript: path(values.transcript), scanReport: values['scan-report'], frames: path(values.frames), model: values.model?.trim(), effort: values.effort, yes: values.yes, noSandbox: values['no-sandbox'], noTelemetry: values['no-telemetry'], help: values.help };
96
102
  }
97
103
  const NOT_A_CHECKOUT = { event: 'setup_command_check_failed', outcome: 'not_a_checkout' };
98
104
  /** owner/repo from a GitHub remote address, in any of the forms git accepts. */
@@ -174,7 +180,7 @@ export function readCheckout(cwd, from) {
174
180
  const parsed = parseGitHubRemote(remote);
175
181
  if (!parsed)
176
182
  throw new UserError(from ? `The "origin" remote of the checkout in ${root} is not on GitHub. Name the folder of a checkout of a GitHub repository.` : 'The "origin" remote of this checkout is not on GitHub. Run this in a checkout of a GitHub repository.', { event: 'setup_command_check_failed', outcome: 'not_github' });
177
- return { root, ...parsed, protectedBranches: [...new Set([defaultBranch(cwd, git), 'main', 'master'].filter(Boolean))] };
183
+ return { root, start: folderOf(git('rev-parse', '--show-prefix')) ?? '', ...parsed, protectedBranches: [...new Set([defaultBranch(cwd, git), 'main', 'master'].filter(Boolean))] };
178
184
  }
179
185
  /** Dependencies that make a package a web front end, whatever else it depends on. */
180
186
  const WEB_PACKAGES = /^(next|nuxt|vite|astro|svelte|vue|gatsby|react-scripts|solid-js|preact|lit|ember-source|vitepress|htmx\.org|alpinejs|parcel|webpack|react-router-dom|@(angular|remix-run|sveltejs|nuxt|docusaurus|11ty)\/.+|@builder\.io\/qwik)$/;