@impeccable/reviews 0.1.12 → 0.1.14

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.
@@ -1,5 +1,6 @@
1
+ import { realpathSync } from 'node:fs';
1
2
  import { readFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
+ import { join, resolve, sep } from 'node:path';
3
4
  import { cloudflarePreviewsFact, cloudflarePreviewsPage, folderOf } from './host-previews.js';
4
5
  import { commandRunner } from './netlify.js';
5
6
  import { ownFolder, ownNpmCache } from './sandbox.js';
@@ -238,13 +239,50 @@ export const CF_PACKAGE = 'cf@1.0.0-beta.13';
238
239
  /**
239
240
  * cf here: its JSON uncoloured, and an account's command given its account, which cf otherwise asks the person to pick
240
241
  * when they have several. `browser` lets its sign-in open one. npx runs it from a folder of the command's own, never the
241
- * checkout, whose node_modules and .npmrc would choose which cf runs as the person, outside the sandbox: every cf
242
- * command names its project or its account, so none needs the checkout. It fetches cf through a cache of the command's
243
- * own, never ~/.npm, where a sandboxed install could have planted a cf under the registry's live ETag; the registry is
244
- * asked afresh, and no install script of cf's runs, which it does without.
242
+ * checkout, whose node_modules and .npmrc would choose which cf runs as the person, outside the sandbox, and from where cf
243
+ * itself would read the checkout's .env into its token and account and evaluate its cloudflare.config.ts: every cf
244
+ * command names its project or its account, so none needs the checkout. Only the sign-in cf picks by folder does: with
245
+ * `checkout`, each command runs as the profile cf binds to it (`cf auth activate`), which cf names from its own folder
246
+ * too, so the account is the one the person's cf uses there; where reading the bindings failed, the command fails as well,
247
+ * never falling back to another sign-in. It fetches cf through a cache of the command's own, never ~/.npm, where a
248
+ * sandboxed install could have planted a cf under the registry's live ETag; the registry is asked afresh, and no install
249
+ * script of cf's runs, which it does without.
245
250
  */
246
251
  export function cfRunner(opts = {}, runner = commandRunner) {
247
- return (args, { account, signal } = {}) => runner('npx', ownFolder(), { timeoutMs: 120_000, ...opts, signal, env: { NO_COLOR: '1', ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}) } })(['--yes', '--cache', ownNpmCache(), '--prefer-online', '--ignore-scripts', CF_PACKAGE, ...args]);
252
+ const { checkout, ...run } = opts;
253
+ const npx = (args, signal, env = {}) => runner('npx', ownFolder(), { timeoutMs: 120_000, ...run, signal, env: { NO_COLOR: '1', ...env } })(['--yes', '--cache', ownNpmCache(), '--prefer-online', '--ignore-scripts', CF_PACKAGE, ...args]);
254
+ let profile;
255
+ return async (args, { account, signal } = {}) => {
256
+ // Read once a runner; a failed read is read again by the next command.
257
+ if (checkout && !profile)
258
+ profile = npx(['auth', 'list'], signal).then((listed) => boundProfile(listed, checkout)).catch((err) => { profile = undefined; throw err; });
259
+ const bound = await profile;
260
+ return npx([...(bound ? ['--profile', bound] : []), ...args], signal, account ? { CLOUDFLARE_ACCOUNT_ID: account } : {});
261
+ };
262
+ }
263
+ /** The profile `cf auth list` binds to `checkout`, found as cf finds it from there: the longest bound folder that is the checkout's real path or holds it; none where no folder does. A list that is not cf's throws. */
264
+ export function boundProfile(listed, checkout) {
265
+ const profiles = JSON.parse(listed);
266
+ if (!Array.isArray(profiles))
267
+ throw new Error('cf auth list printed no list of profiles');
268
+ let here = resolve(checkout);
269
+ try {
270
+ here = realpathSync(here);
271
+ }
272
+ catch { /* cf's own folder, as cf resolves it */ }
273
+ let found;
274
+ for (const p of profiles) {
275
+ const { name, boundDirectories } = record(p) ?? {};
276
+ if (typeof name !== 'string' || !/^[A-Za-z0-9_-]+$/.test(name) || !Array.isArray(boundDirectories))
277
+ continue;
278
+ for (const dir of boundDirectories) {
279
+ if (typeof dir !== 'string' || !(here === dir || (here.startsWith(dir) && here[dir.length] === sep)))
280
+ continue;
281
+ if (!found || dir.length > found.dir.length)
282
+ found = { name, dir };
283
+ }
284
+ }
285
+ return found?.name;
248
286
  }
249
287
  /** The status of the API call a cf command failed on, which its error box names on a line of its own ("│ 403 Forbidden", "│ 404 Not Found · HTTP /accounts/…"); null when it names none. */
250
288
  export function cfFailure(err) {
@@ -406,7 +444,7 @@ export async function readCloudflarePreviews(owner, repo, cwd, deps = {}) {
406
444
  // `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
445
  const named = app ? undefined : deps.project ?? github.previews.map((p) => pagesProject(p.url)).find(Boolean) ?? (config?.kind === 'pages' ? config.name : undefined);
408
446
  // 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.
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);
447
+ const cf = await fromCf(named, deps.cf ?? cfRunner({ checkout: cwd }, deps.run), { owner, repo, heads: github.heads.map((h) => h.sha), readme: readme.pages, ...(app ? { directory: deps.directory } : {}) }, plain, deps.signal);
410
448
  const where = deps.directory ? `the folder ${deps.directory}` : "the repository's root";
411
449
  const unmatched = !app ? undefined
412
450
  : 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).`
package/dist/main.js CHANGED
@@ -256,8 +256,8 @@ export async function main(argv) {
256
256
  todo: [...apps.todo, ...report.left],
257
257
  warnings: [...removed, ...(saved && !saved.ok ? [`The web page missed the last update (${saved.error}), so it may lag behind this.`] : [])],
258
258
  }, hand);
259
- // The web page has the report; when it missed it, the report stays here instead.
260
- if (!saved?.ok)
259
+ // The web page has the report; when it missed it, or there is no test drive, whose why it holds, the report stays here as well.
260
+ if (!saved?.ok || !testPr)
261
261
  say(`\n${plainReport(report.markdown)}`);
262
262
  if (!saved?.ok)
263
263
  reporter.step({ event: 'setup_command_stopped', outcome: 'update_lost' });
package/dist/netlify.js CHANGED
@@ -123,6 +123,24 @@ async function fromGitHub(owner, repo, gh, only) {
123
123
  }
124
124
  return { previews: found, heads: new Map(heads.map((head) => [head.number, head.sha])), failed };
125
125
  }
126
+ /** A page of the person's site list, and how many pages are read at most: what `netlify sites:list` reads. */
127
+ const SITES_PER_PAGE = 100;
128
+ const SITE_PAGES = 10;
129
+ /**
130
+ * The person's Netlify sites, as `netlify sites:list --json` lists them, read through `netlify api listSites`: in a
131
+ * checkout with several workspace packages the CLI asks which one to work in before sites:list, and without a terminal
132
+ * exits on that question, while its api command never asks (#319).
133
+ */
134
+ async function netlifySites(netlify) {
135
+ const sites = [];
136
+ for (let page = 1; page <= SITE_PAGES; page++) {
137
+ const answer = JSON.parse(await netlify(['api', 'listSites', '--data', JSON.stringify({ filter: 'all', page, per_page: SITES_PER_PAGE })]));
138
+ sites.push(...list(answer));
139
+ if (!Array.isArray(answer) || answer.length < SITES_PER_PAGE)
140
+ break;
141
+ }
142
+ return sites;
143
+ }
126
144
  /** What the Netlify CLI on this machine knows, when it is signed in: the sites built from this repository, and their deploy previews. */
127
145
  async function fromCli(owner, repo, netlify, only, directory) {
128
146
  const { cli } = await netlifyStatus(netlify);
@@ -135,7 +153,7 @@ async function fromCli(owner, repo, netlify, only, directory) {
135
153
  const facts = [];
136
154
  let failed = false;
137
155
  try {
138
- for (const site of list(JSON.parse(await netlify(['sites:list', '--json'])))) {
156
+ for (const site of await netlifySites(netlify)) {
139
157
  const path = record(site.build_settings)?.repo_path;
140
158
  // 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
159
  if (typeof site.name !== 'string' || typeof site.id !== 'string' || !new RegExp(`^${SITE}$`).test(site.name) || typeof path !== 'string' || path.toLowerCase() !== slug)
@@ -286,25 +304,54 @@ export async function waitForNetlifyAccess(siteId, deps) {
286
304
  * repository's GitHub account, which Netlify names on every site of the person's that the app already builds from that
287
305
  * account. GitHub sends the app each push and pull request, so the pull requests build deploy previews. Then the first
288
306
  * build says whether the app reads the repository. Only a site this run's own sites:create made takes the connection,
289
- * and a site built from another repository keeps it: the person's sites are never pointed elsewhere.
307
+ * and a site built from another repository keeps it: the person's sites are never pointed elsewhere. A folder linked to
308
+ * no site stands for the one site built from this repository (from `directory`, for one app), which is connected already.
290
309
  */
291
310
  export async function connectNetlifySite(owner, repo, cwd, deps) {
292
311
  const not = (reason, more = {}) => ({ connected: false, reason, ...more });
293
- const siteId = linkedNetlifySite(cwd);
294
- if (!siteId)
295
- return not('This folder is linked to no Netlify site: link or create one first.');
312
+ const slug = `${owner}/${repo}`.toLowerCase();
313
+ const ours = (site) => { const path = record(site?.build_settings)?.repo_path; return typeof path === 'string' && path.toLowerCase() === slug; };
314
+ let siteId = linkedNetlifySite(cwd);
296
315
  let site;
297
- try {
298
- site = await api(deps.netlify, 'getSite', { site_id: siteId });
316
+ if (siteId) {
317
+ try {
318
+ site = await api(deps.netlify, 'getSite', { site_id: siteId });
319
+ }
320
+ catch {
321
+ return not('Netlify did not answer for the linked site.');
322
+ }
299
323
  }
300
- catch {
301
- return not('Netlify did not answer for the linked site.');
324
+ // No link (netlify link asks in a terminal when several sites are built from the repository), or a link to the site of another app's folder:
325
+ // the one site built from this repository with the app's folder as its base (#319). The checks below read it again as built from this
326
+ // repository, so a site found here is never written to.
327
+ const otherApp = site !== undefined && deps.directory !== undefined && ours(site) && folderOf(record(site.build_settings)?.base) !== deps.directory;
328
+ if (!siteId || otherApp) {
329
+ const linkedTo = otherApp ? "This folder is linked to the Netlify site of another app's folder" : 'This folder is linked to no Netlify site';
330
+ const where = deps.directory !== undefined ? ` from ${deps.directory || "the repository's root"}` : '';
331
+ let built;
332
+ try {
333
+ built = (await netlifySites(deps.netlify)).filter((one) => typeof one.id === 'string' && /^[A-Za-z0-9-]{1,64}$/.test(one.id) && typeof one.name === 'string' && new RegExp(`^${SITE}$`).test(one.name) && ours(one)
334
+ && (deps.directory === undefined || folderOf(record(one.build_settings)?.base) === deps.directory));
335
+ }
336
+ catch {
337
+ return not(`${linkedTo}, and the person's Netlify sites could not be read.`);
338
+ }
339
+ if (built.length > 1)
340
+ return not(`${linkedTo}, and several of the person's sites are built from this repository${where} (${built.map((one) => one.name).join(', ')}), so which one to connect cannot be told.`);
341
+ if (!built.length)
342
+ return not(`${linkedTo}, and none of the person's sites is built from this repository${where}: create one first.`);
343
+ siteId = built[0].id;
344
+ try {
345
+ site = await api(deps.netlify, 'getSite', { site_id: siteId });
346
+ }
347
+ catch {
348
+ return not('Netlify did not answer for the site built from this repository.');
349
+ }
302
350
  }
303
351
  const name = typeof site?.name === 'string' && new RegExp(`^${SITE}$`).test(site.name) ? site.name : undefined;
304
352
  if (!site || !name)
305
353
  return not('The linked site could not be read.');
306
354
  const linked = { id: siteId, name };
307
- const slug = `${owner}/${repo}`.toLowerCase();
308
355
  const settings = record(site.build_settings);
309
356
  let github;
310
357
  try {
@@ -335,7 +382,7 @@ export async function connectNetlifySite(owner, repo, cwd, deps) {
335
382
  // The app's installation on this GitHub account, as another site of the person's built from it names it.
336
383
  let installation;
337
384
  try {
338
- for (const other of list(JSON.parse(await deps.netlify(['sites:list', '--json'])))) {
385
+ for (const other of await netlifySites(deps.netlify)) {
339
386
  const build = record(other.build_settings);
340
387
  const path = typeof build?.repo_path === 'string' ? build.repo_path : '';
341
388
  if (build?.provider === 'github' && path.toLowerCase().startsWith(`${owner.toLowerCase()}/`) && positive(build.installation_id)) {
@@ -8,7 +8,7 @@ The state's repository has an `apps` item: the scan proposed several apps, folde
8
8
  2. **Ask which apps this run sets up**, on every run, with impeccable_ask: `id: "apps"`, `kind: "multi"`, and the `prompt` *"Which apps should get Impeccable previews?"*. The tool words the question and lists the state's apps (the repository's own, `main`, only where the Worker kept it as a product of its own), each picked as it is on, or every one after this run's confirm. Mark the Apps task in progress first. Every write and host tool for an app is refused until it is answered. Done when it answered `values`, `named` (the command was started for that one app with `--app`, from its card on the dashboard: the tool showed one confirm screen naming that app instead of the list, and the list all the same when the person asked for other apps there), or a skip, whose `on` lists the apps that are on: the ones this run sets up. After `named`, that app is the one this run sets up: every host read, key, proof, method and check is its own, and no other app is mentioned past that screen.
9
9
  3. **Write the picks**, one action a call as the item's instructions give them: `enable` each chosen app that is off, then `disable` each app that is on and was not chosen. The last app that is on cannot be switched off (409), which is why the enables come first. **After `named`**, `enable` that app when it is off, and switch no other off. **After Skip**, write nothing more: the apps that are on stay on. Done when the last answer's `apps` show `enabled` true for every chosen app and false for the rest (after `named`, true for that app), or, after Skip, nothing was written.
10
10
  4. **Read the state again** with impeccable_state. Done when the preview item's `apps` names each chosen app's `directory`, `build` and `method` (none yet for an app not set up), and `perApp` its status.
11
- 5. **Set each chosen app up**, one after the other, as the playbook's steps 3 and 4 say for that app alone, `appKey` on every write and check (`preview`, `test-accounts`, `latest-pull-check`, `connection-check`): its method by the case that applies to it (server-previews when its pages need a server, static-site when they do not, a host's case when the host deploys that app as a project of its own, proven on that app's own address: impeccable_hosted_previews, impeccable_netlify_previews and impeccable_cloudflare_previews take its `appKey` and list only the previews of the project or site that builds its folder, so another app's address, project or key never stands in for it; their `unmatched` says why none can be told, and that app goes as server-previews or static-site says, with the reason under "What is not verified"), and its own sign-in when the scan item's `accessByApp` says it has one, each account question's title naming the app (*"Order desk admin account"*). **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.
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 (impeccable_netlify_connect takes it too, and connects that site), 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
12
  6. **The pull requests** as pull-requests says, once for the repository: the setup commit takes the state's one workflow `file` (a job for each chosen static site whose preview Impeccable builds) once every chosen app's method and build settings are saved, or its preview skipped as server-previews says; the test drive adds its page to every chosen app with a preview (an app whose preview was skipped gets none, and goes under "What was skipped, and why"), in that app's own framework and linked from that app's own public page, so one review renders each of them on its own preview; that case's step 3 proves each chosen app with the input that proved it; its step 4 proves the test drive's page on each chosen app's own preview, with that app's key where its lock stands in front; its step 6 checks each chosen app with a preview, with its `appKey`, one call each. Done when pull-requests' steps are done.
13
13
  - **A chosen app whose preview was skipped** (server-previews): it stays on, with no method, no job in the workflow and no test drive page. It holds nothing back: the other apps' setup commit, test drive and checks go on as above, and its checkup entry left in `next` counts as done for the playbook's step 5. The report says, on its line and under "What was skipped, and why", that it gets no preview until its host deploys them.
14
14
  - **A host previews some chosen apps and Impeccable builds the others**: a host's case speaks for its own app. The setup commit carries the workflow for the others and the test drive branches from it, and pull-requests' step 4 goes each app's way: the host's app proven on the host's build, the others by the workflow. The repository goes without a workflow and a setup pull request only where a host previews every chosen app.
@@ -9,7 +9,8 @@ The checkout is set up for Netlify: a `netlify.toml`, a `.netlify/` folder, or a
9
9
  - **`teams` in the answer** (the person has several Netlify teams): ask once which owns the site, `kind: "single"`, `id: "netlify-team"`, `options` the teams as given (the tool words the rest), and pass the pick to `sites:create` as `--account-slug`.
10
10
  - **Skip**: set the preview up as server-previews or static-site says, and say under "What was skipped, and why" that Netlify was found and the sign-in was skipped. The steps below do not apply.
11
11
  2. **Link or create the site** (run each Netlify command plain, on its own: a command chained after `sites:create` leaves the site unrecorded): `netlify link --git-remote-url <the GitHub origin>` links a site Netlify already builds from this repository; when it finds none, `netlify sites:create --name <name>` creates one in the person's team and links this folder. Name it after the repository: lowercase letters, digits and hyphens, at most 40 characters; a name Netlify says is taken gets `-2`. Done when `netlify status` names the linked project.
12
- 3. **Connect the repository** with impeccable_netlify_connect: it connects the linked site to this repository through Netlify's GitHub app, so pull requests build deploy previews, when that site is the one this run created or one built from this repository already (a site built from another repository, or one the run did not create, is left as it is, with the reason), and asks the person for the app's access when Netlify cannot read the repository. Give `command` and `publish` only where no `netlify.toml` sets the build. Done when it answered `connected: true`; a site `netlify link` found is connected already, and it says so. **Deploy Previews off** on that site (`pullRequestPreviews` in its answer, or in impeccable_netlify_previews', `enabled: false`): the aim holds, so ask once with impeccable_enable_previews (`host: "netlify"`) before step 4, and never a second time. `enabled`: step 4. `skipped` or `refused`: the setup ends here, as "A step fails before the test drive is pushed" says, with the `reason` and the `page` in the report, and `left` empty, since the person was asked in this run.
12
+ - **A site already built from this repository** (impeccable_netlify_previews named it in `sites`; on a repository with several apps, read with the app's `appKey`): link nothing, since `netlify link` asks in a terminal this run does not have when several sites are built from the repository. Step 3 finds that site by itself.
13
+ 3. **Connect the repository** with impeccable_netlify_connect (with the app's `appKey` on a repository with several apps): it connects the linked site to this repository through Netlify's GitHub app, so pull requests build deploy previews, when that site is the one this run created or one built from this repository already (a site built from another repository, or one the run did not create, is left as it is, with the reason), and asks the person for the app's access when Netlify cannot read the repository. Give `command` and `publish` only where no `netlify.toml` sets the build. Done when it answered `connected: true`; a site `netlify link` found is connected already, and it says so. **Deploy Previews off** on that site (`pullRequestPreviews` in its answer, or in impeccable_netlify_previews', `enabled: false`): the aim holds, so ask once with impeccable_enable_previews (`host: "netlify"`) before step 4, and never a second time. `enabled`: step 4. `skipped` or `refused`: the setup ends here, as "A step fails before the test drive is pushed" says, with the `reason` and the `page` in the report, and `left` empty, since the person was asked in this run.
13
14
  4. **Build and push the test drive** (the Test drive task) as pull-requests' steps 2 to 4 say, with the changes netlify-previews' reference gives: from the default branch, no setup pull request, built once with the build Netlify runs. Stage the new page and the page that links to it by name: `netlify sites:create` writes `.netlify/` and may add it to `.gitignore`, and that change stays out of the pull request. Done when `git push -u origin impeccable/test-drive` succeeded.
14
15
  5. **Its draft, its preview proven and saved, then marked ready** as pull-requests' step 4 (Netlify) and step 5 say: the draft, whose deploy preview is the site's first, the wait for it by `pull`, the test drive's page proven on it, impeccable_write to `preview` as netlify-previews' step 2 says, `gh pr ready`, then the recording. The playbook's step 6 then has no pull request left to open: go on with the playbook's step 4. Done when impeccable_test_pr answered "Recorded".
15
16
 
package/dist/tools.js CHANGED
@@ -137,7 +137,7 @@ function stageError(status, tasks) {
137
137
  }
138
138
  /** 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. */
139
139
  export function resultLine(report) {
140
- 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;
140
+ const lead = !report.testPr ? 'No test drive: the report says why, printed here when this screen closes.' : 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;
141
141
  return report.setupPr || !report.hostedBy ? lead : `${lead} ${nothingToMerge(report.hostedBy)}`;
142
142
  }
143
143
  /** Said once, here and in the report, when the host builds the previews and the repository needed no change. */
@@ -293,6 +293,7 @@ const NETLIFY_CONNECT = {
293
293
  type: 'object',
294
294
  additionalProperties: false,
295
295
  properties: {
296
+ appKey: APP_KEY,
296
297
  command: { type: 'string', minLength: 1, description: "The build command Netlify runs, only where no netlify.toml sets one: the package's build script." },
297
298
  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.' },
298
299
  },
@@ -1833,14 +1834,14 @@ export function createTools(ctx) {
1833
1834
  }, signal);
1834
1835
  }
1835
1836
  /** 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. */
1836
- async function netlifyConnect(pi, settings, signal) {
1837
+ async function netlifyConnect(pi, settings, signal, directory) {
1837
1838
  // The site's settings change on the host: only once the person opened the Netlify setup, as the fence holds for the CLI.
1838
1839
  if (!hostSetupAgreed(ctx.log, 'netlify'))
1839
1840
  return { connected: false, reason: 'The Netlify sign-in comes first: impeccable_ask with the id netlify-setup.' };
1840
1841
  const netlify = (ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal });
1841
1842
  const gh = (ctx.hostRunner ?? commandRunner)('gh', pi.cwd, { signal });
1842
1843
  const clone = ctx.hostWait?.clone;
1843
- const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, linked: linkedByRun(ctx.log), settings, signal, ...(clone ? { wait: clone } : {}) });
1844
+ const result = await connectNetlifySite(ctx.owner, ctx.repo, pi.cwd, { netlify, gh, linked: linkedByRun(ctx.log), settings, signal, ...(clone ? { wait: clone } : {}), ...(directory !== undefined ? { directory } : {}) });
1844
1845
  netlifySite = result.site?.name ?? netlifySite;
1845
1846
  if (result.pullRequestPreviews)
1846
1847
  previewFacts.set('netlify', result.pullRequestPreviews);
@@ -2765,14 +2766,25 @@ export function createTools(ctx) {
2765
2766
  return JSON.stringify({ ...proof, appKey: app.appKey });
2766
2767
  return JSON.stringify(proof);
2767
2768
  }),
2768
- 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: a site this run created with netlify sites:create, or one built from this repository already. A site built from another repository, or not created by this run, is left as it is, with the reason. 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) => {
2769
+ 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: a site this run created with netlify sites:create, or one built from this repository already. With the folder linked to no site, the one site built from this repository already stands for it, with appKey the one whose base directory is that app's folder: no netlify link needed. A site built from another repository, or not created by this run, is left as it is, with the reason. 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) => {
2769
2770
  const command = p.command;
2770
2771
  const publish = p.publish;
2771
2772
  if (command !== undefined && (command.length > 200 || /[\r\n\0]/.test(command)))
2772
2773
  return 'Refused: the build command is one line of at most 200 characters.';
2773
2774
  if (publish !== undefined && publish !== '.' && !staticRelativePath(publish))
2774
2775
  return 'Refused: publish is a folder relative to the checkout, such as "dist".';
2775
- return JSON.stringify(await netlifyConnect(pi, { command, publish }, signal));
2776
+ const refused = appRefusal(p.appKey, 'connected') ?? lockRefusal(p.appKey, 'connected');
2777
+ if (refused)
2778
+ return refused;
2779
+ // On a repository with several apps, only the site that builds the app's folder (#319).
2780
+ const appKey = appsRepo ? p.appKey : undefined;
2781
+ const directory = appKey !== undefined ? await appFolder(pi, appKey) : undefined;
2782
+ if (appKey !== undefined && directory === undefined)
2783
+ return JSON.stringify({ appKey, connected: false, reason: `The setup state names no folder for the app ${appKey}.` });
2784
+ const result = await netlifyConnect(pi, { command, publish }, signal, directory);
2785
+ if (result.how && result.site)
2786
+ noteApp(appKey, { host_project: result.site.name });
2787
+ return JSON.stringify(appKey !== undefined ? { appKey, ...result } : result);
2776
2788
  }),
2777
2789
  tool('impeccable_enable_previews', 'Pull-request previews', "Turn on previews of pull requests on the host that builds this repository but not its pull requests, as pullRequestPreviews in the answer of impeccable_netlify_previews, impeccable_netlify_connect or impeccable_cloudflare_previews said (enabled false). It does the rest as the person: one screen asking to turn them on, then the host's CLI changes that one setting (Netlify: Deploy Previews on; Cloudflare Pages: preview branch control set to every non-production branch, or the test drive's branch added to the custom includes) and reads it back; where the CLI cannot (the role, a cf sign-in without pages:write, builds stopped altogether), it opens the host's settings page and asks the person to try again once they changed it there. Answers {\"status\": …}: enabled (pull-request previews are on now; by says whether the run or the person turned them on, and project which project or site), skipped (the person skipped a screen; reason says what came before) or refused (still off after the retries; reason says why, and page where it is changed). Vercel builds every branch once Git is connected, so its fact is reported and never changed here.", ENABLE_PREVIEWS, async (p, pi, signal) => {
2778
2790
  const host = p.host;
@@ -2797,7 +2809,7 @@ export function createTools(ctx) {
2797
2809
  const turnOn = async () => {
2798
2810
  if (host === 'netlify')
2799
2811
  return enableNetlifyPreviews((ctx.hostRunner ?? commandRunner)('netlify', pi.cwd, { signal }), fact.project);
2800
- const cf = cfRunner({}, ctx.hostRunner);
2812
+ const cf = cfRunner({ checkout: pi.cwd }, ctx.hostRunner);
2801
2813
  return enableCloudflarePreviews(cf, fact.project, await cfStatus(cf, signal), signal);
2802
2814
  };
2803
2815
  // The run's change, read back; where the host kept the setting, the person changes it on the host's page and the run reads again, twice at most.
@@ -2842,8 +2854,8 @@ export function createTools(ctx) {
2842
2854
  if ((await ask({ ...screen, prompt: create ? `Create the Pages project ${project} for ${ctx.owner}/${ctx.repo}?` : `Connect ${ctx.owner}/${ctx.repo} to the Pages project ${project}?` })).skipped)
2843
2855
  return answer('skipped', { reason: `The person skipped ${create ? 'creating the Pages project' : 'connecting the repository to the Pages project'}.` });
2844
2856
  // cf signed in with pages:write, as it is or after Cloudflare's sign-in in the browser, keeping the scopes it held; a sign-in that did not complete offers a retry, twice.
2845
- const cf = cfRunner({}, ctx.hostRunner);
2846
- const login = cfRunner({ browser: true, timeoutMs: 300_000 }, ctx.hostRunner);
2857
+ const cf = cfRunner({ checkout: pi.cwd }, ctx.hostRunner);
2858
+ const login = cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, ctx.hostRunner);
2847
2859
  const writes = (status) => status.cli === 'signed-in' && (!status.scopes || status.scopes.includes('pages:write'));
2848
2860
  let status = await cfStatus(cf, signal);
2849
2861
  if (!writes(status)) {
@@ -3121,8 +3133,8 @@ export function createTools(ctx) {
3121
3133
  answer = await passCloudflareAccess({
3122
3134
  url: at.url,
3123
3135
  consent: async () => !(await ask(sentTo(HOST_SCREENS['cloudflare-access'], at.url))).skipped,
3124
- cf: cfRunner({}, ctx.hostRunner),
3125
- login: cfRunner({ browser: true, timeoutMs: 300_000 }, ctx.hostRunner),
3136
+ cf: cfRunner({ checkout: pi.cwd }, ctx.hostRunner),
3137
+ login: cfRunner({ browser: true, timeoutMs: 300_000, checkout: pi.cwd }, ctx.hostRunner),
3126
3138
  retry: async () => !(await ask(HOST_SCREENS['cloudflare-signin-retry'])).skipped,
3127
3139
  status: said,
3128
3140
  apiToken: async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impeccable/reviews",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Set up a repository for Impeccable for PRs from your terminal.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "private": false,