@goodfellow-cms/github 0.2.0 → 0.4.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.
package/dist/index.js CHANGED
@@ -1,18 +1,20 @@
1
- import { ConflictError, DomainError, EditorsError, GitApiError, SetupError, SignInError, UpdatesError, buildStepFor, compareReleases, credentialStorage, encodeBase64, encodeBase64Bytes, siteVersion } from "@goodfellow-cms/core";
1
+ import { ConflictError, DomainError, DraftError, EditorsError, GitApiError, REBUILD_JOB, SetupError, SignInError, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, compareReleases, credentialStorage, decodeBase64, encodeBase64, encodeBase64Bytes, siteVersion } from "@goodfellow-cms/core";
2
2
  //#region src/api.ts
3
3
  /** Calls the GitHub REST or GraphQL API. Treats 401 as an expired sign-in; other errors become `GitApiError`. */
4
4
  async function githubRequest({ apiUrl, token, fetch }, path, init = {}) {
5
- const response = await fetch(`${apiUrl}${path}`, {
5
+ const send = async (refresh) => fetch(`${apiUrl}${path}`, {
6
6
  method: init.method ?? "GET",
7
7
  headers: {
8
8
  accept: init.accept ?? "application/vnd.github+json",
9
- authorization: `Bearer ${token}`,
9
+ authorization: `Bearer ${typeof token === "string" ? token : await token({ refresh })}`,
10
10
  "x-github-api-version": "2022-11-28",
11
11
  ...init.body !== void 0 && { "content-type": "application/json" }
12
12
  },
13
13
  body: init.body === void 0 ? void 0 : JSON.stringify(init.body),
14
14
  cache: "no-store"
15
15
  });
16
+ let response = await send(false);
17
+ if (response.status === 401 && typeof token !== "string") response = await send(true);
16
18
  if (response.status === 401) throw new SignInError("invalid", "GitHub didn't accept the sign-in. It may have expired.");
17
19
  if (!response.ok && !init.allow?.includes(response.status)) {
18
20
  const body = await response.json().catch(() => ({}));
@@ -139,15 +141,30 @@ function githubEditors(read, manage, repo, login) {
139
141
  }
140
142
  };
141
143
  }
142
- /** The owner token: GitHub's Administration and Pages permissions, for this tab only. */
143
- function githubOwnerAccess(base, repo, tokenLink) {
144
+ /**
145
+ * The owner token: GitHub's Administration and Pages permissions, for this tab
146
+ * only. With the sign-in worker, owners can sign in with its owners' app
147
+ * instead (`redirect`), and `useSignIn()` gives the token that comes back.
148
+ */
149
+ function githubOwnerAccess(base, repo, tokenLink, redirect) {
144
150
  let owner;
151
+ /** The owner's access to the site's code repository, where its content is kept in a repository of its own. */
152
+ let ownerCode;
153
+ let endSignIn;
154
+ let problem;
145
155
  return {
146
156
  access: {
147
157
  tokenLink,
148
158
  get active() {
149
159
  return owner !== void 0;
150
160
  },
161
+ get problem() {
162
+ return problem;
163
+ },
164
+ ...redirect && { async startRedirect(options) {
165
+ problem = void 0;
166
+ await redirect(options);
167
+ } },
151
168
  async applyToken(token) {
152
169
  const api = {
153
170
  ...base,
@@ -162,12 +179,35 @@ function githubOwnerAccess(base, repo, tokenLink) {
162
179
  throw error;
163
180
  }
164
181
  owner = api;
182
+ ownerCode = api;
183
+ problem = void 0;
165
184
  },
166
185
  forget() {
167
186
  owner = void 0;
187
+ ownerCode = void 0;
188
+ endSignIn?.();
189
+ endSignIn = void 0;
168
190
  }
169
191
  },
170
- api: () => owner
192
+ api: () => owner,
193
+ codeApi: () => ownerCode,
194
+ /** Uses an owner's sign-in through the worker, with its token for the code repository if it gave one. `end` runs when it's forgotten. */
195
+ useSignIn(token, end, codeToken) {
196
+ owner = {
197
+ ...base,
198
+ token
199
+ };
200
+ ownerCode = codeToken ? {
201
+ ...base,
202
+ token: codeToken
203
+ } : void 0;
204
+ endSignIn = end;
205
+ problem = void 0;
206
+ },
207
+ /** Remembers why an owner's sign-in didn't work, for the screen that started it to show. */
208
+ failed(error) {
209
+ problem = error;
210
+ }
171
211
  };
172
212
  }
173
213
  //#endregion
@@ -291,17 +331,19 @@ function githubPages(api, repo) {
291
331
  //#region src/updates.ts
292
332
  /** The starters' GitHub Pages workflow, which runs `goodfellow update` each night and when it's dispatched. */
293
333
  const WORKFLOW = "deploy.yml";
294
- const WORKFLOW_FILE = `.github/workflows/${WORKFLOW}`;
334
+ /** The code repository's workflow, where a site keeps its content in a repository of its own. */
335
+ const CODE_WORKFLOW = "update.yml";
295
336
  /**
296
337
  * The site's nightly update, which runs in its GitHub Pages workflow. Running
297
338
  * it now dispatches the workflow, which needs GitHub's Actions permission
298
339
  * with write access: `manage()` gives the owner token when one is in use.
299
340
  */
300
- function githubUpdates(read, manage, repo, branch, files) {
301
- const path = `/repos/${repo}/actions/workflows/${WORKFLOW}`;
341
+ function githubUpdates(read, manage, repo, branch, files, workflow = WORKFLOW) {
342
+ const path = `/repos/${repo}/actions/workflows/${workflow}`;
343
+ const workflowFile = `.github/workflows/${workflow}`;
302
344
  return {
303
345
  async setup() {
304
- return (await files.read(WORKFLOW_FILE))?.includes("update --publish") ? "ready" : "missing";
346
+ return (await files.read(workflowFile))?.includes("update --publish") ? "ready" : "missing";
305
347
  },
306
348
  async lastRun() {
307
349
  const response = await githubRequest(read, `${path}/runs?branch=${encodeURIComponent(branch)}&per_page=20`, { allow: [403, 404] });
@@ -359,8 +401,77 @@ function githubUpdates(read, manage, repo, branch, files) {
359
401
  }
360
402
  };
361
403
  }
404
+ /** Whether an error means the sign-in can't see the code repository, or has no token for it. */
405
+ function noAccess(error) {
406
+ return error instanceof SignInError && error.problem === "no-access" || error instanceof GitApiError && (error.status === 403 || error.status === 404);
407
+ }
408
+ /**
409
+ * The nightly update of a site whose content is kept in a repository of its
410
+ * own, which runs in the code repository's `update.yml`. The sign-in needs to
411
+ * see the code repository too; where it can't, `setup()` says `no-access`.
412
+ */
413
+ function githubCodeUpdates(options) {
414
+ const { read } = options;
415
+ const repo = repoPath(options.repo);
416
+ let branch;
417
+ const liveBranch = (api) => {
418
+ branch ??= githubJson(api, `/repos/${repo}`).then((found) => found.default_branch);
419
+ branch.catch(() => {
420
+ branch = void 0;
421
+ });
422
+ return branch;
423
+ };
424
+ /** The code repository at its newest commit, read again each time, since updates change it. */
425
+ async function files() {
426
+ if (!read) throw new SignInError("no-access", `This sign-in has no access to ${options.repo}.`);
427
+ return options.open(read, await liveBranch(read));
428
+ }
429
+ /** A file as it is now: setup files are outside the folders backends list, so they're read at the newest commit. */
430
+ async function readNow(path) {
431
+ const backend = await files();
432
+ return backend.readAt(path, await backend.revision());
433
+ }
434
+ async function updates() {
435
+ if (!read) throw new SignInError("no-access", `This sign-in has no access to ${options.repo}.`);
436
+ const target = await liveBranch(read);
437
+ return githubUpdates(read, options.manage, repo, target, {
438
+ read: readNow,
439
+ readAt: async (path, revision) => (await files()).readAt(path, revision)
440
+ }, CODE_WORKFLOW);
441
+ }
442
+ return {
443
+ code: {
444
+ repo: options.repo,
445
+ webUrl: `${options.webUrl}/${options.repo}`,
446
+ read: readNow,
447
+ history: async (path, history) => (await files()).history(path, history),
448
+ readAt: async (path, revision) => (await files()).readAt(path, revision)
449
+ },
450
+ async setup() {
451
+ try {
452
+ return await (await updates()).setup();
453
+ } catch (error) {
454
+ if (noAccess(error)) return "no-access";
455
+ throw error;
456
+ }
457
+ },
458
+ async lastRun() {
459
+ try {
460
+ return await (await updates()).lastRun();
461
+ } catch (error) {
462
+ if (noAccess(error)) return void 0;
463
+ throw error;
464
+ }
465
+ },
466
+ async start(target) {
467
+ await (await updates()).start(target);
468
+ }
469
+ };
470
+ }
362
471
  //#endregion
363
472
  //#region src/backend.ts
473
+ /** How many files one GraphQL request reads. */
474
+ const GRAPHQL_BATCH = 100;
364
475
  const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
365
476
  createCommitOnBranch(input: $input) { commit { oid } }
366
477
  }`;
@@ -369,30 +480,117 @@ const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
369
480
  * returned; saves are single commits made with GraphQL's `createCommitOnBranch`,
370
481
  * which fails if the branch has moved, so nothing is ever overwritten.
371
482
  */
372
- var GitHubBackend = class {
483
+ var GitHubBackend = class GitHubBackend {
373
484
  user;
374
485
  pages;
375
486
  editors;
376
487
  ownerAccess;
488
+ /** Absent where the repository holds only content: the update job runs where the code is. */
377
489
  updates;
490
+ drafts;
491
+ contentOnly;
492
+ options;
378
493
  api;
379
494
  repo;
380
495
  branch;
381
496
  onSignOut;
497
+ author;
498
+ owner;
382
499
  pinned;
383
500
  trees = /* @__PURE__ */ new Map();
501
+ /** Each file's size in bytes at a commit, which GitHub lists with its tree. */
502
+ treeSizes = /* @__PURE__ */ new Map();
384
503
  blobs = /* @__PURE__ */ new Map();
385
504
  constructor(options) {
505
+ this.options = options;
386
506
  this.api = options.api;
387
507
  this.repo = repoPath(options.repo);
388
508
  this.branch = options.branch;
389
509
  this.user = options.user;
390
510
  this.onSignOut = options.onSignOut;
391
- const owner = githubOwnerAccess(this.api, this.repo, options.ownerTokenLink);
511
+ this.author = options.author;
512
+ this.pinned = options.revision;
513
+ const owner = githubOwnerAccess(this.api, this.repo, options.ownerTokenLink, options.ownerRedirect);
514
+ this.owner = owner;
392
515
  this.ownerAccess = owner.access;
393
516
  this.pages = githubPages(() => owner.api() ?? this.api, this.repo);
394
517
  this.editors = githubEditors(this.api, owner.api, this.repo, this.user.login);
395
- this.updates = githubUpdates(this.api, owner.api, this.repo, this.branch, this);
518
+ this.contentOnly = options.contentOnly === true;
519
+ if (!this.contentOnly) this.updates = githubUpdates(this.api, owner.api, this.repo, this.branch, this);
520
+ else if (options.code) this.updates = githubCodeUpdates({
521
+ repo: options.code.repo,
522
+ webUrl: options.code.webUrl,
523
+ read: options.code.api,
524
+ manage: owner.codeApi,
525
+ open: (api, branch) => new GitHubBackend({
526
+ api,
527
+ repo: options.code?.repo ?? "",
528
+ branch,
529
+ user: this.user,
530
+ onSignOut: () => {},
531
+ ownerTokenLink: options.ownerTokenLink
532
+ })
533
+ });
534
+ this.drafts = branchDrafts(this, this.draftHost());
535
+ }
536
+ /** Drafts on branches of the repository, with pull requests for review. */
537
+ draftHost() {
538
+ const base = `/repos/${this.repo}`;
539
+ const ref = (branch) => `${base}/git/refs/heads/${encodeURIComponent(branch)}`;
540
+ return {
541
+ liveBranch: this.branch,
542
+ branchStore: (branch) => new GitHubBackend({
543
+ ...this.options,
544
+ branch
545
+ }),
546
+ createBranch: async (branch, revision) => {
547
+ if ((await githubRequest(this.api, `${base}/git/refs`, {
548
+ method: "POST",
549
+ body: {
550
+ ref: `refs/heads/${branch}`,
551
+ sha: revision
552
+ },
553
+ allow: [422]
554
+ })).status === 422) throw new DraftError("exists", `There's already a draft called ${branch}.`);
555
+ },
556
+ deleteBranch: async (branch) => {
557
+ await githubRequest(this.api, ref(branch), {
558
+ method: "DELETE",
559
+ allow: [404, 422]
560
+ });
561
+ },
562
+ mergeBase: async (branch) => {
563
+ const response = await githubRequest(this.api, `${base}/compare/${encodeURIComponent(this.branch)}...${encodeURIComponent(branch)}`, { allow: [404] });
564
+ if (response.status === 404) throw new DraftError("not-found", `There's no draft ${branch}.`);
565
+ return (await response.json()).merge_base_commit.sha;
566
+ },
567
+ openReview: async ({ branch, title, description }) => {
568
+ const response = await githubRequest(this.api, `${base}/pulls`, {
569
+ method: "POST",
570
+ body: {
571
+ title,
572
+ head: branch,
573
+ base: this.branch,
574
+ body: description
575
+ },
576
+ allow: [
577
+ 403,
578
+ 404,
579
+ 422
580
+ ]
581
+ });
582
+ if (response.ok) return { url: (await response.json()).html_url };
583
+ if (response.status !== 422) throw new DraftError("no-reviews", "This sign-in can't open pull requests on the site's repository.");
584
+ const owner = decodeURIComponent(this.repo).split("/")[0];
585
+ const query = new URLSearchParams({
586
+ head: `${owner}:${branch}`,
587
+ state: "open"
588
+ });
589
+ const [open] = await githubJson(this.api, `${base}/pulls?${query}`);
590
+ if (!open) throw new GitApiError(422, "GitHub couldn't open the pull request.");
591
+ return { url: open.html_url };
592
+ }
593
+ };
396
594
  }
397
595
  async revision() {
398
596
  const ref = await githubJson(this.api, `/repos/${this.repo}/git/ref/heads/${encodeURIComponent(this.branch)}`);
@@ -405,7 +603,9 @@ var GitHubBackend = class {
405
603
  if (!tree) {
406
604
  tree = githubJson(this.api, `/repos/${this.repo}/git/trees/${sha}?recursive=1`).then((response) => {
407
605
  if (response.truncated) throw new GitApiError(413, "The repository has too many files for GitHub to list at once.");
408
- return new Map(response.tree.filter((entry) => entry.type === "blob").map((entry) => [entry.path, entry.sha]));
606
+ const blobs = response.tree.filter((entry) => entry.type === "blob");
607
+ this.treeSizes.set(sha, new Map(blobs.flatMap((entry) => entry.size === void 0 ? [] : [[entry.path, entry.size]])));
608
+ return new Map(blobs.map((entry) => [entry.path, entry.sha]));
409
609
  });
410
610
  tree.catch(() => this.trees.delete(sha));
411
611
  this.trees.set(sha, tree);
@@ -429,6 +629,40 @@ var GitHubBackend = class {
429
629
  }
430
630
  return blob;
431
631
  }
632
+ /** Reads many text files in a few GraphQL requests rather than one request each. */
633
+ async readFiles(paths) {
634
+ const tree = await this.currentTree();
635
+ const wanted = paths.flatMap((path) => {
636
+ const sha = tree.get(path);
637
+ return sha ? [[path, sha]] : [];
638
+ });
639
+ const fetch = [...new Set(wanted.map(([, sha]) => sha).filter((sha) => !this.blobs.has(sha)))];
640
+ const [owner, name] = decodeURIComponent(this.repo).split("/");
641
+ for (let start = 0; start < fetch.length; start += GRAPHQL_BATCH) {
642
+ const batch = fetch.slice(start, start + GRAPHQL_BATCH);
643
+ const fields = batch.map((sha, index) => `f${index}: object(oid: "${sha}") { ... on Blob { text isBinary } }`);
644
+ const response = await githubJson(this.api, "/graphql", {
645
+ method: "POST",
646
+ body: {
647
+ query: `query ($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${fields.join(" ")} } }`,
648
+ variables: {
649
+ owner,
650
+ name
651
+ }
652
+ }
653
+ });
654
+ batch.forEach((sha, index) => {
655
+ const blob = response.data?.repository?.[`f${index}`];
656
+ if (blob && !blob.isBinary && typeof blob.text === "string") this.blobs.set(sha, Promise.resolve(blob.text));
657
+ });
658
+ }
659
+ const result = /* @__PURE__ */ new Map();
660
+ for (const [path] of wanted) {
661
+ const text = await this.readFrom(tree, path);
662
+ if (text !== void 0) result.set(path, text);
663
+ }
664
+ return result;
665
+ }
432
666
  async readBytes(path) {
433
667
  const sha = (await this.currentTree()).get(path);
434
668
  if (!sha) return void 0;
@@ -439,7 +673,101 @@ var GitHubBackend = class {
439
673
  const prefix = `${dir}/`;
440
674
  return [...(await this.currentTree()).keys()].filter((path) => path.startsWith(prefix)).sort();
441
675
  }
442
- async write(changes, { message, expectedRevision }) {
676
+ async fileSizes(dir) {
677
+ const prefix = `${dir}/`;
678
+ await this.currentTree();
679
+ const sizes = this.treeSizes.get(this.pinned ?? "") ?? /* @__PURE__ */ new Map();
680
+ return new Map([...sizes].filter(([path]) => path.startsWith(prefix)));
681
+ }
682
+ /** Uses an owner's sign-in through the worker for editors and GitHub Pages settings, until it's forgotten. */
683
+ useOwnerSignIn(token, end, codeToken) {
684
+ this.owner.useSignIn(token, end, codeToken);
685
+ }
686
+ /** Remembers why an owner's sign-in through the worker didn't work, which the Editors and Domain screens show. */
687
+ ownerSignInFailed(error) {
688
+ this.owner.failed(error);
689
+ }
690
+ async write(changes, options) {
691
+ if (this.contentOnly) assertContentChanges(changes);
692
+ return this.author ? this.writeCommit(changes, options, this.author) : this.writeGraphQL(changes, options);
693
+ }
694
+ /**
695
+ * Saves with Git's data API, naming the editor as the commit's author:
696
+ * GitHub refuses to move the branch unless the commit follows on from it, so
697
+ * nothing is overwritten.
698
+ */
699
+ async writeCommit(changes, { message, expectedRevision }, author) {
700
+ const existing = await this.tree(expectedRevision);
701
+ const base = `/repos/${this.repo}`;
702
+ const entries = [];
703
+ try {
704
+ for (const change of changes) if ("delete" in change) {
705
+ if (existing.has(change.path)) entries.push({
706
+ path: change.path,
707
+ mode: "100644",
708
+ type: "blob",
709
+ sha: null
710
+ });
711
+ } else if ("bytes" in change) {
712
+ const blob = await githubJson(this.api, `${base}/git/blobs`, {
713
+ method: "POST",
714
+ body: {
715
+ content: encodeBase64Bytes(change.bytes),
716
+ encoding: "base64"
717
+ }
718
+ });
719
+ entries.push({
720
+ path: change.path,
721
+ mode: "100644",
722
+ type: "blob",
723
+ sha: blob.sha
724
+ });
725
+ } else entries.push({
726
+ path: change.path,
727
+ mode: "100644",
728
+ type: "blob",
729
+ content: change.content
730
+ });
731
+ if (entries.length === 0) return { revision: expectedRevision };
732
+ const parent = await githubJson(this.api, `${base}/git/commits/${expectedRevision}`);
733
+ const tree = await githubJson(this.api, `${base}/git/trees`, {
734
+ method: "POST",
735
+ body: {
736
+ base_tree: parent.tree.sha,
737
+ tree: entries
738
+ }
739
+ });
740
+ const commit = await githubJson(this.api, `${base}/git/commits`, {
741
+ method: "POST",
742
+ body: {
743
+ message,
744
+ tree: tree.sha,
745
+ parents: [expectedRevision],
746
+ author: {
747
+ ...author,
748
+ date: (/* @__PURE__ */ new Date()).toISOString()
749
+ }
750
+ }
751
+ });
752
+ if ((await githubRequest(this.api, `${base}/git/refs/heads/${encodeURIComponent(this.branch)}`, {
753
+ method: "PATCH",
754
+ body: {
755
+ sha: commit.sha,
756
+ force: false
757
+ },
758
+ allow: [422]
759
+ })).status === 422) {
760
+ if (await this.revision() !== expectedRevision) throw new ConflictError();
761
+ throw new GitApiError(422, "GitHub couldn't save the changes.");
762
+ }
763
+ this.pinned = commit.sha;
764
+ return { revision: commit.sha };
765
+ } catch (error) {
766
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SignInError("cant-publish", "This sign-in can't publish to the site.");
767
+ throw error;
768
+ }
769
+ }
770
+ async writeGraphQL(changes, { message, expectedRevision }) {
443
771
  const existing = await this.tree(expectedRevision);
444
772
  const additions = changes.flatMap((change) => "delete" in change ? [] : [{
445
773
  path: change.path,
@@ -550,7 +878,8 @@ var GitHubBackend = class {
550
878
  const response = await githubRequest(this.api, `/repos/${this.repo}/actions/runs?head_sha=${revision}&per_page=20`, { allow: [403, 404] });
551
879
  if (!response.ok) return void 0;
552
880
  const { workflow_runs: runs = [] } = await response.json();
553
- return runs.find((run) => /deploy|pages/i.test(`${run.path ?? ""} ${run.name ?? ""}`)) ?? runs[0];
881
+ const builds = runs.filter((run) => !run.path?.endsWith(`/${REBUILD_JOB}.yml`));
882
+ return builds.find((run) => /deploy|pages/i.test(`${run.path ?? ""} ${run.name ?? ""}`)) ?? builds[0];
554
883
  }
555
884
  /** Which step of a failed run failed. */
556
885
  async runProblem(run) {
@@ -576,13 +905,184 @@ var GitHubBackend = class {
576
905
  }
577
906
  };
578
907
  //#endregion
908
+ //#region src/sign-in.ts
909
+ /**
910
+ * Talking to the sign-in worker (`@goodfellow-cms/worker`), which signs
911
+ * editors in with GitHub and gives the admin panel a token for the site's
912
+ * repository, lasting an hour. Requests are sent as plain text, so browsers
913
+ * send them straight away without asking the worker first.
914
+ */
915
+ /** The worker's problems, as the admin panel's sign-in problems. */
916
+ const PROBLEMS = {
917
+ invalid: "invalid",
918
+ "no-access": "no-access",
919
+ "cant-publish": "cant-publish",
920
+ cancelled: "redirect-failed",
921
+ "not-installed": "not-installed",
922
+ "no-address": "no-address",
923
+ "wrong-address": "wrong-address",
924
+ "local-address": "local-address",
925
+ "not-owner": "not-owner"
926
+ };
927
+ /** The worker's answer as a `SignInError`. */
928
+ function signInProblem(answer) {
929
+ const problem = PROBLEMS[String(answer.problem)] ?? "unavailable";
930
+ const link = typeof answer.installUrl === "string" && /^https:\/\//.test(answer.installUrl) ? answer.installUrl : void 0;
931
+ return new SignInError(problem, `The sign-in service said: ${String(answer.problem ?? "no answer")}.`, link);
932
+ }
933
+ /** Addresses on the computer itself, which the worker never gives tokens to. */
934
+ function isLocalAddress(href) {
935
+ let host;
936
+ try {
937
+ host = new URL(href).hostname.toLowerCase();
938
+ } catch {
939
+ return true;
940
+ }
941
+ return host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || host === "[::1]" || /^127(\.\d{1,3}){3}$/.test(host);
942
+ }
943
+ function base64Url(bytes) {
944
+ let binary = "";
945
+ for (const byte of bytes) binary += String.fromCharCode(byte);
946
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
947
+ }
948
+ /** A secret that never leaves this tab, and its hash, which the worker checks it against (PKCE's S256). */
949
+ async function newVerifier() {
950
+ const verifier = base64Url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)));
951
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
952
+ return {
953
+ verifier,
954
+ challenge: base64Url(new Uint8Array(digest))
955
+ };
956
+ }
957
+ function browserLocation() {
958
+ if (typeof window === "undefined") return void 0;
959
+ return {
960
+ href: () => window.location.href,
961
+ assign: (url) => window.location.assign(url),
962
+ replace: (url) => window.history.replaceState(window.history.state, "", url)
963
+ };
964
+ }
965
+ /** Query parameters the worker comes back with. */
966
+ const GRANT_PARAM = "goodfellow_sign_in";
967
+ const ERROR_PARAM = "goodfellow_sign_in_error";
968
+ var SignInService = class {
969
+ url;
970
+ fetcher;
971
+ constructor(url, fetcher) {
972
+ this.url = url.replace(/\/$/, "");
973
+ this.fetcher = fetcher;
974
+ }
975
+ async answer(path, init) {
976
+ let response;
977
+ try {
978
+ response = await this.fetcher(`${this.url}${path}`, init);
979
+ } catch (error) {
980
+ throw new SignInError("unavailable", `Couldn't reach the sign-in service: ${error.message}`);
981
+ }
982
+ const body = await response.json().catch(() => ({}));
983
+ if (!response.ok) throw signInProblem(body);
984
+ return body;
985
+ }
986
+ /** Posts JSON as plain text. */
987
+ post(path, body) {
988
+ return this.answer(path, {
989
+ method: "POST",
990
+ headers: { "content-type": "text/plain" },
991
+ body: JSON.stringify(body)
992
+ });
993
+ }
994
+ /** Posts JSON as plain text, and gives the answer whatever it is, for callers that explain problems of their own. */
995
+ async postAnswer(path, body) {
996
+ let response;
997
+ try {
998
+ response = await this.fetcher(`${this.url}${path}`, {
999
+ method: "POST",
1000
+ headers: { "content-type": "text/plain" },
1001
+ body: JSON.stringify(body)
1002
+ });
1003
+ } catch (error) {
1004
+ throw new SignInError("unavailable", `Couldn't reach the sign-in service: ${error.message}`);
1005
+ }
1006
+ return {
1007
+ ok: response.ok,
1008
+ body: await response.json().catch(() => ({}))
1009
+ };
1010
+ }
1011
+ /** Whether the worker is set up and answering. Throws `SignInError` if it isn't. */
1012
+ async health() {
1013
+ if ((await this.answer("/health")).ok !== true) throw new SignInError("unavailable", "The sign-in service isn't set up.");
1014
+ }
1015
+ /** Whether this page can sign in to the repository through the worker. Throws `SignInError` if it can't. */
1016
+ async check(repo, purpose) {
1017
+ await this.answer(`/github/check?${new URLSearchParams({
1018
+ repo,
1019
+ purpose
1020
+ })}`);
1021
+ }
1022
+ /** Where to send the browser to sign in, or with `install`, to install Goodfellow and sign in. */
1023
+ startUrl(options) {
1024
+ const query = new URLSearchParams({
1025
+ purpose: options.purpose,
1026
+ ...options.repo && { repo: options.repo },
1027
+ return: options.back,
1028
+ challenge: options.challenge,
1029
+ remember: options.remember ? "1" : "0",
1030
+ ...options.install && { install: "1" }
1031
+ });
1032
+ return `${this.url}/github/start?${query}`;
1033
+ }
1034
+ /**
1035
+ * Exchanges the grant the worker came back with, and this tab's verifier,
1036
+ * for a session (and for a site, its first token, and with `code`, one for
1037
+ * its code repository).
1038
+ */
1039
+ session(grant, verifier, code) {
1040
+ return this.post("/github/session", {
1041
+ grant,
1042
+ verifier,
1043
+ ...code && { code }
1044
+ });
1045
+ }
1046
+ token(session, code) {
1047
+ return this.post("/github/token", {
1048
+ session,
1049
+ ...code && { code }
1050
+ });
1051
+ }
1052
+ };
1053
+ /** A token that renews itself through the worker before it expires, or when GitHub stops accepting it. */
1054
+ function renewingToken(service, first, onRenew, code) {
1055
+ let current = first;
1056
+ let renewing;
1057
+ async function token({ refresh = false } = {}) {
1058
+ if (refresh || Date.parse(current.expiresAt) - 3e5 < Date.now()) {
1059
+ renewing ??= service.token(current.session, code).finally(() => {
1060
+ renewing = void 0;
1061
+ });
1062
+ current = await renewing;
1063
+ onRenew(current);
1064
+ }
1065
+ return current.token;
1066
+ }
1067
+ return {
1068
+ current: () => current,
1069
+ token,
1070
+ async codeToken(options) {
1071
+ await token(options);
1072
+ if (!current.code) throw new SignInError("no-access", "The sign-in service gave no token for the code repository.");
1073
+ return current.code.token;
1074
+ }
1075
+ };
1076
+ }
1077
+ //#endregion
579
1078
  //#region src/setup.ts
580
1079
  /** The branch the deploy setups build from. */
581
1080
  const MAIN = "main";
582
1081
  /**
583
1082
  * Links to GitHub's token pages with what creating a site needs: creating the
584
- * repository (administration), writing its files and its build setup
585
- * (contents and workflows), and turning on GitHub Pages (pages).
1083
+ * repository and adding deploy keys (administration), writing its files and
1084
+ * its build setup (contents and workflows), turning on GitHub Pages (pages),
1085
+ * and giving a workflow the key to another of the site's repositories (secrets).
586
1086
  */
587
1087
  function githubSetupTokenLinks(webUrl = "https://github.com") {
588
1088
  const fineGrained = new URLSearchParams({
@@ -592,7 +1092,8 @@ function githubSetupTokenLinks(webUrl = "https://github.com") {
592
1092
  administration: "write",
593
1093
  contents: "write",
594
1094
  workflows: "write",
595
- pages: "write"
1095
+ pages: "write",
1096
+ secrets: "write"
596
1097
  });
597
1098
  const classic = new URLSearchParams({
598
1099
  scopes: "repo,workflow",
@@ -603,198 +1104,340 @@ function githubSetupTokenLinks(webUrl = "https://github.com") {
603
1104
  classic: `${webUrl}/settings/tokens/new?${classic}`
604
1105
  };
605
1106
  }
606
- /** Signing in to GitHub to create a site's repository, with GitHub Pages if wanted. */
607
- function githubSetup(options = {}) {
608
- const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
609
- const links = githubSetupTokenLinks(options.webUrl);
610
- const fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
611
- async function connect(token) {
612
- const api = {
613
- apiUrl,
614
- token,
615
- fetch: fetcher
616
- };
617
- const user = await githubJson(api, "/user");
618
- async function owners() {
619
- return [{
620
- path: user.login,
621
- name: user.name || user.login,
622
- kind: "personal"
623
- }, ...(await githubJson(api, "/user/orgs?per_page=100").catch(() => [])).map((org) => ({
624
- path: org.login,
625
- name: org.login,
626
- kind: "organization"
627
- }))];
628
- }
629
- async function isAvailable(owner, name) {
630
- return (await githubRequest(api, `/repos/${repoPath(`${owner.path}/${name}`)}`, { allow: [404] })).status === 404;
1107
+ /** The steps after the repository exists, which are the same however it was created. */
1108
+ function setupAccount(creator) {
1109
+ /** Each repository created, and the token for setting it up, for connecting it to another one later. */
1110
+ const createdApis = /* @__PURE__ */ new Map();
1111
+ /** Makes `main` the default branch, which the deploy setups build from. */
1112
+ async function useMainBranch(api, repo) {
1113
+ if (repo.default_branch === MAIN) return;
1114
+ const base = `/repos/${repoPath(repo.full_name)}`;
1115
+ const ref = await githubJson(api, `${base}/git/ref/heads/${repo.default_branch}`);
1116
+ await githubRequest(api, `${base}/git/refs`, {
1117
+ method: "POST",
1118
+ body: {
1119
+ ref: `refs/heads/${MAIN}`,
1120
+ sha: ref.object.sha
1121
+ }
1122
+ });
1123
+ await githubRequest(api, base, {
1124
+ method: "PATCH",
1125
+ body: { default_branch: MAIN }
1126
+ });
1127
+ await githubRequest(api, `${base}/git/refs/heads/${repo.default_branch}`, { method: "DELETE" });
1128
+ }
1129
+ /** Turns on GitHub Pages, published by the site's own workflow. Returns the site's address. */
1130
+ async function turnOnPages(api, repo) {
1131
+ try {
1132
+ return (await githubJson(api, `/repos/${repoPath(repo.full_name)}/pages`, {
1133
+ method: "POST",
1134
+ body: { build_type: "workflow" }
1135
+ })).html_url;
1136
+ } catch (error) {
1137
+ if (error instanceof GitApiError && [
1138
+ 403,
1139
+ 404,
1140
+ 409,
1141
+ 422
1142
+ ].includes(error.status)) return void 0;
1143
+ throw error;
631
1144
  }
632
- async function createRepository(site) {
633
- const path = site.owner.kind === "personal" ? "/user/repos" : `/orgs/${encodeURIComponent(site.owner.path)}/repos`;
634
- if (!await isAvailable(site.owner, site.name)) throw new SetupError("name-taken", `There's already a repository called ${site.owner.path}/${site.name}.`);
635
- try {
636
- return await githubJson(api, path, {
637
- method: "POST",
638
- body: {
639
- name: site.name,
640
- private: site.private,
641
- description: site.description,
642
- auto_init: true
643
- }
1145
+ }
1146
+ /** Writes every file in one commit, which replaces the starting README rather than following it. */
1147
+ async function writeFiles(api, repo, site) {
1148
+ const base = `/repos/${repoPath(repo.full_name)}`;
1149
+ const tree = [];
1150
+ for (const file of site.files) {
1151
+ if ("delete" in file) continue;
1152
+ if ("content" in file) {
1153
+ tree.push({
1154
+ path: file.path,
1155
+ mode: "100644",
1156
+ type: "blob",
1157
+ content: file.content
644
1158
  });
645
- } catch (error) {
646
- if (error instanceof GitApiError && error.status === 422) throw new SetupError("name-invalid", `GitHub can't use "${site.name}" as a repository name.`);
647
- if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", `This sign-in can't create repositories in ${site.owner.path}.`);
648
- throw error;
1159
+ continue;
649
1160
  }
1161
+ const blob = await githubJson(api, `${base}/git/blobs`, {
1162
+ method: "POST",
1163
+ body: {
1164
+ content: encodeBase64Bytes(file.bytes),
1165
+ encoding: "base64"
1166
+ }
1167
+ });
1168
+ tree.push({
1169
+ path: file.path,
1170
+ mode: "100644",
1171
+ type: "blob",
1172
+ sha: blob.sha
1173
+ });
650
1174
  }
651
- /** Makes `main` the default branch, which the deploy setups build from. */
652
- async function useMainBranch(repo) {
653
- if (repo.default_branch === MAIN) return;
654
- const base = `/repos/${repoPath(repo.full_name)}`;
655
- const ref = await githubJson(api, `${base}/git/ref/heads/${repo.default_branch}`);
656
- await githubRequest(api, `${base}/git/refs`, {
1175
+ try {
1176
+ const created = await githubJson(api, `${base}/git/trees`, {
1177
+ method: "POST",
1178
+ body: { tree }
1179
+ });
1180
+ const commit = await githubJson(api, `${base}/git/commits`, {
657
1181
  method: "POST",
658
1182
  body: {
659
- ref: `refs/heads/${MAIN}`,
660
- sha: ref.object.sha
1183
+ message: site.message,
1184
+ tree: created.sha,
1185
+ parents: [],
1186
+ ...creator.author && { author: {
1187
+ ...creator.author,
1188
+ date: (/* @__PURE__ */ new Date()).toISOString()
1189
+ } }
661
1190
  }
662
1191
  });
663
- await githubRequest(api, base, {
1192
+ await githubRequest(api, `${base}/git/refs/heads/${MAIN}`, {
664
1193
  method: "PATCH",
665
- body: { default_branch: MAIN }
1194
+ body: {
1195
+ sha: commit.sha,
1196
+ force: true
1197
+ }
666
1198
  });
667
- await githubRequest(api, `${base}/git/refs/heads/${repo.default_branch}`, { method: "DELETE" });
668
- }
669
- /** Turns on GitHub Pages, published by the site's own workflow. Returns the site's address. */
670
- async function turnOnPages(repo) {
671
- try {
672
- return (await githubJson(api, `/repos/${repoPath(repo.full_name)}/pages`, {
673
- method: "POST",
674
- body: { build_type: "workflow" }
675
- })).html_url;
676
- } catch (error) {
677
- if (error instanceof GitApiError && [
678
- 403,
679
- 404,
680
- 409,
681
- 422
682
- ].includes(error.status)) return void 0;
683
- throw error;
684
- }
1199
+ } catch (error) {
1200
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", "This sign-in can't write the site's files, or its build setup.");
1201
+ throw error;
685
1202
  }
686
- /** Writes every file in one commit, which replaces the starting README rather than following it. */
687
- async function writeFiles(repo, site) {
688
- const base = `/repos/${repoPath(repo.full_name)}`;
689
- const tree = [];
690
- for (const file of site.files) {
691
- if ("delete" in file) continue;
692
- if ("content" in file) {
693
- tree.push({
694
- path: file.path,
695
- mode: "100644",
696
- type: "blob",
697
- content: file.content
698
- });
699
- continue;
700
- }
701
- const blob = await githubJson(api, `${base}/git/blobs`, {
702
- method: "POST",
703
- body: {
704
- content: encodeBase64Bytes(file.bytes),
705
- encoding: "base64"
706
- }
707
- });
708
- tree.push({
709
- path: file.path,
710
- mode: "100644",
711
- type: "blob",
712
- sha: blob.sha
713
- });
714
- }
715
- try {
716
- const created = await githubJson(api, `${base}/git/trees`, {
717
- method: "POST",
718
- body: { tree }
719
- });
720
- const commit = await githubJson(api, `${base}/git/commits`, {
1203
+ }
1204
+ /** Stops anyone rewriting or deleting the main branch's history. Not every plan allows it. */
1205
+ async function protectMain(api, repo) {
1206
+ return (await githubRequest(api, `/repos/${repoPath(repo.full_name)}/rulesets`, {
1207
+ method: "POST",
1208
+ body: {
1209
+ name: "Protect the published history",
1210
+ target: "branch",
1211
+ enforcement: "active",
1212
+ conditions: { ref_name: {
1213
+ include: ["~DEFAULT_BRANCH"],
1214
+ exclude: []
1215
+ } },
1216
+ rules: [{ type: "non_fast_forward" }, { type: "deletion" }]
1217
+ },
1218
+ allow: [
1219
+ 403,
1220
+ 404,
1221
+ 422
1222
+ ]
1223
+ })).ok;
1224
+ }
1225
+ /**
1226
+ * Lets each private repository's workflows read the other, as their setup
1227
+ * files say: a read-only deploy key on the one read, with its private half
1228
+ * a secret of the one reading. Public repositories need nothing. Returns
1229
+ * whether it worked: an older sign-in may not be allowed to set secrets.
1230
+ */
1231
+ async function connectRepositories(link) {
1232
+ if (!link.private) return true;
1233
+ const { newDeployKey, sealSecret } = await import("./deploy-keys-DAj2oX-N.js").then((n) => n.t);
1234
+ try {
1235
+ for (const { reader, read, secret } of link.links.reads) {
1236
+ if (!secret) continue;
1237
+ const readerRepo = link[reader];
1238
+ const readRepo = link[read];
1239
+ const readerApi = createdApis.get(readerRepo.toLowerCase());
1240
+ const readApi = createdApis.get(readRepo.toLowerCase());
1241
+ if (!readerApi || !readApi) throw new Error(`${readerRepo} and ${readRepo} have to be created first.`);
1242
+ const key = await newDeployKey(`goodfellow-${secret.toLowerCase()}`);
1243
+ await githubRequest(readApi, `/repos/${repoPath(readRepo)}/keys`, {
721
1244
  method: "POST",
722
1245
  body: {
723
- message: site.message,
724
- tree: created.sha,
725
- parents: []
1246
+ title: `${readerRepo}'s builds`,
1247
+ key: key.publicKey,
1248
+ read_only: true
726
1249
  }
727
1250
  });
728
- await githubRequest(api, `${base}/git/refs/heads/${MAIN}`, {
729
- method: "PATCH",
1251
+ const base = `/repos/${repoPath(readerRepo)}/actions/secrets`;
1252
+ const publicKey = await githubJson(readerApi, `${base}/public-key`);
1253
+ const sealed = await sealSecret(key.privateKey, decodeBase64(publicKey.key));
1254
+ await githubRequest(readerApi, `${base}/${secret}`, {
1255
+ method: "PUT",
730
1256
  body: {
731
- sha: commit.sha,
732
- force: true
1257
+ encrypted_value: encodeBase64Bytes(sealed),
1258
+ key_id: publicKey.key_id
733
1259
  }
734
1260
  });
735
- } catch (error) {
736
- if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", "This sign-in can't write the site's files, or its build setup.");
737
- throw error;
738
1261
  }
1262
+ return true;
1263
+ } catch (error) {
1264
+ if (error instanceof GitApiError && [
1265
+ 403,
1266
+ 404,
1267
+ 422
1268
+ ].includes(error.status)) return false;
1269
+ throw error;
739
1270
  }
740
- /** Stops anyone rewriting or deleting the main branch's history. Not every plan allows it. */
741
- async function protectMain(repo) {
742
- return (await githubRequest(api, `/repos/${repoPath(repo.full_name)}/rulesets`, {
743
- method: "POST",
744
- body: {
745
- name: "Protect the published history",
746
- target: "branch",
747
- enforcement: "active",
748
- conditions: { ref_name: {
749
- include: ["~DEFAULT_BRANCH"],
750
- exclude: []
751
- } },
752
- rules: [{ type: "non_fast_forward" }, { type: "deletion" }]
753
- },
754
- allow: [
755
- 403,
756
- 404,
757
- 422
758
- ]
759
- })).ok;
1271
+ }
1272
+ return {
1273
+ user: creator.user,
1274
+ owners: () => creator.owners(),
1275
+ isAvailable: (owner, name) => creator.isAvailable(owner, name),
1276
+ async createSite(site, onStep) {
1277
+ const warnings = [];
1278
+ onStep?.("repository");
1279
+ const { repo, api, signInInstallUrl } = await creator.create(site);
1280
+ const connect = async (link) => {
1281
+ onStep?.("connect");
1282
+ return connectRepositories(link);
1283
+ };
1284
+ await useMainBranch(api, repo);
1285
+ let siteUrl;
1286
+ if (site.pages) {
1287
+ onStep?.("pages");
1288
+ siteUrl = await turnOnPages(api, repo);
1289
+ if (!siteUrl) warnings.push("pages-unavailable");
1290
+ }
1291
+ createdApis.set(repo.full_name.toLowerCase(), api);
1292
+ if (site.link && !await connect(site.link)) warnings.push("connect-unavailable");
1293
+ onStep?.("files");
1294
+ await writeFiles(api, repo, site);
1295
+ onStep?.("protection");
1296
+ if (!await protectMain(api, repo)) warnings.push("protection-unavailable");
1297
+ if (signInInstallUrl) warnings.push("sign-in-not-installed");
1298
+ return {
1299
+ repo: repo.full_name,
1300
+ webUrl: repo.html_url,
1301
+ siteUrl,
1302
+ buildsUrl: `${repo.html_url}/actions`,
1303
+ pagesUrl: site.pages ? `${repo.html_url}/settings/pages` : void 0,
1304
+ ...signInInstallUrl && { signInInstallUrl },
1305
+ warnings
1306
+ };
760
1307
  }
761
- return {
762
- user: {
763
- login: user.login,
764
- name: user.name ?? void 0,
765
- avatarUrl: user.avatar_url
766
- },
767
- owners,
768
- isAvailable,
769
- async createSite(site, onStep) {
770
- const warnings = [];
771
- onStep?.("repository");
772
- const repo = await createRepository(site);
773
- await useMainBranch(repo);
774
- let siteUrl;
775
- if (site.pages) {
776
- onStep?.("pages");
777
- siteUrl = await turnOnPages(repo);
778
- if (!siteUrl) warnings.push("pages-unavailable");
779
- }
780
- onStep?.("files");
781
- await writeFiles(repo, site);
782
- onStep?.("protection");
783
- if (!await protectMain(repo)) warnings.push("protection-unavailable");
1308
+ };
1309
+ }
1310
+ /** Creating repositories with the person's own token. */
1311
+ async function tokenCreator(api) {
1312
+ const user = await githubJson(api, "/user");
1313
+ async function isAvailable(owner, name) {
1314
+ return (await githubRequest(api, `/repos/${repoPath(`${owner.path}/${name}`)}`, { allow: [404] })).status === 404;
1315
+ }
1316
+ return {
1317
+ user: {
1318
+ login: user.login,
1319
+ name: user.name ?? void 0,
1320
+ avatarUrl: user.avatar_url
1321
+ },
1322
+ async owners() {
1323
+ return [{
1324
+ path: user.login,
1325
+ name: user.name || user.login,
1326
+ kind: "personal"
1327
+ }, ...(await githubJson(api, "/user/orgs?per_page=100").catch(() => [])).map((org) => ({
1328
+ path: org.login,
1329
+ name: org.login,
1330
+ kind: "organization"
1331
+ }))];
1332
+ },
1333
+ isAvailable,
1334
+ async create(site) {
1335
+ const path = site.owner.kind === "personal" ? "/user/repos" : `/orgs/${encodeURIComponent(site.owner.path)}/repos`;
1336
+ if (!await isAvailable(site.owner, site.name)) throw new SetupError("name-taken", `There's already a repository called ${site.owner.path}/${site.name}.`);
1337
+ try {
784
1338
  return {
785
- repo: repo.full_name,
786
- webUrl: repo.html_url,
787
- siteUrl,
788
- buildsUrl: `${repo.html_url}/actions`,
789
- pagesUrl: site.pages ? `${repo.html_url}/settings/pages` : void 0,
790
- warnings
1339
+ repo: await githubJson(api, path, {
1340
+ method: "POST",
1341
+ body: {
1342
+ name: site.name,
1343
+ private: site.private,
1344
+ description: site.description,
1345
+ auto_init: true
1346
+ }
1347
+ }),
1348
+ api
791
1349
  };
1350
+ } catch (error) {
1351
+ if (error instanceof GitApiError && error.status === 422) throw new SetupError("name-invalid", `GitHub can't use "${site.name}" as a repository name.`);
1352
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", `This sign-in can't create repositories in ${site.owner.path}.`);
1353
+ throw error;
792
1354
  }
793
- };
1355
+ }
1356
+ };
1357
+ }
1358
+ /**
1359
+ * Creating repositories through the sign-in worker, which uses the person's
1360
+ * GitHub token itself and gives back a token for the new repository only.
1361
+ */
1362
+ function workerCreator(service, apiUrl, fetcher, signedIn) {
1363
+ let session = signedIn.session;
1364
+ async function call(path, body) {
1365
+ const answer = await service.postAnswer(path, {
1366
+ session,
1367
+ ...body
1368
+ });
1369
+ if (typeof answer.body.session === "string") session = answer.body.session;
1370
+ if (!answer.ok) throw setupProblem(answer.body);
1371
+ return answer.body;
1372
+ }
1373
+ const { user } = signedIn;
1374
+ return {
1375
+ user: {
1376
+ login: user.login,
1377
+ name: user.name,
1378
+ avatarUrl: user.avatarUrl
1379
+ },
1380
+ author: {
1381
+ name: user.name || user.login,
1382
+ email: user.email
1383
+ },
1384
+ async owners() {
1385
+ const found = await call("/github/setup/owners", {});
1386
+ return (found.owners ?? []).map(({ installed, ...owner }) => ({
1387
+ ...owner,
1388
+ ...!installed && found.installUrl && { installUrl: found.installUrl }
1389
+ }));
1390
+ },
1391
+ async isAvailable(owner, name) {
1392
+ return (await call("/github/setup/available", {
1393
+ owner: owner.path,
1394
+ name
1395
+ })).available === true;
1396
+ },
1397
+ async create(site) {
1398
+ if (site.owner.installUrl) throw new SetupError("not-installed", `Goodfellow isn't installed on ${site.owner.path}.`, site.owner.installUrl);
1399
+ const created = await call("/github/setup/repository", {
1400
+ owner: site.owner.path,
1401
+ name: site.name,
1402
+ private: site.private,
1403
+ description: site.description,
1404
+ ...site.more && { more: true }
1405
+ });
1406
+ if (!created.repo || !created.token) throw new SignInError("unavailable", "The sign-in service didn't answer.");
1407
+ return {
1408
+ repo: created.repo,
1409
+ api: {
1410
+ apiUrl,
1411
+ token: created.token,
1412
+ fetch: fetcher
1413
+ },
1414
+ ...created.editInstallUrl && { signInInstallUrl: created.editInstallUrl }
1415
+ };
1416
+ }
1417
+ };
1418
+ }
1419
+ /** The worker's answer when it couldn't create the repository, as a `SetupError` or `SignInError`. */
1420
+ function setupProblem(answer) {
1421
+ switch (answer.problem) {
1422
+ case "name-taken": return new SetupError("name-taken", "There's already a repository with that name.");
1423
+ case "name-invalid": return new SetupError("name-invalid", "GitHub can't use that as a repository name.");
1424
+ case "not-allowed": return new SetupError("not-allowed", "This sign-in can't create repositories there.");
1425
+ case "not-installed": return new SetupError("not-installed", "Goodfellow isn't installed there.", answer.installUrl);
1426
+ default: return signInProblem(answer);
794
1427
  }
1428
+ }
1429
+ /** Signing in to GitHub to create a site's repository, with GitHub Pages if wanted. */
1430
+ function githubSetup(options = {}) {
1431
+ const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
1432
+ const links = githubSetupTokenLinks(options.webUrl);
1433
+ const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
1434
+ const location = options.location ?? browserLocation();
1435
+ const signInUrl = options.signIn;
1436
+ const service = signInUrl && location && !isLocalAddress(location.href()) ? new SignInService(signInUrl, fetcher) : void 0;
1437
+ const pending = credentialStorage("goodfellow:github:setup:pending", options.storage);
795
1438
  return {
796
1439
  name: "GitHub",
797
- canRedirect: false,
1440
+ canRedirect: service !== void 0,
798
1441
  tokenLinks: [{
799
1442
  url: links.fineGrained,
800
1443
  label: "signIn.token.create"
@@ -804,20 +1447,50 @@ function githubSetup(options = {}) {
804
1447
  hint: "signIn.token.createBroadHint"
805
1448
  }],
806
1449
  async restore() {
807
- return null;
1450
+ if (!location) return null;
1451
+ const url = new URL(location.href());
1452
+ const grant = url.searchParams.get(GRANT_PARAM);
1453
+ const failed = url.searchParams.get(ERROR_PARAM);
1454
+ if (!grant && !failed) return null;
1455
+ url.searchParams.delete(GRANT_PARAM);
1456
+ url.searchParams.delete(ERROR_PARAM);
1457
+ location.replace(url.toString());
1458
+ const verifier = pending.load();
1459
+ pending.clear();
1460
+ if (failed || !grant || !verifier || !service) throw signInProblem({ problem: failed ?? "cancelled" });
1461
+ const signedIn = await service.session(grant, verifier);
1462
+ return setupAccount(workerCreator(service, apiUrl, fetcher, signedIn));
808
1463
  },
809
1464
  async signInWithToken(token) {
810
1465
  const trimmed = token.trim();
811
1466
  if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
812
- return connect(trimmed);
1467
+ return setupAccount(await tokenCreator({
1468
+ apiUrl,
1469
+ token: trimmed,
1470
+ fetch: fetcher
1471
+ }));
813
1472
  },
814
- async startRedirect() {
815
- throw new Error("Signing in by redirect needs a sign-in server, which isn't supported for GitHub yet.");
1473
+ async startRedirect({ install } = {}) {
1474
+ if (!service || !location) throw new SignInError("unavailable", "Signing in with GitHub isn't available here.");
1475
+ await service.health();
1476
+ const { verifier, challenge } = await newVerifier();
1477
+ pending.save(verifier, false);
1478
+ location.assign(service.startUrl({
1479
+ purpose: "setup",
1480
+ back: location.href(),
1481
+ challenge,
1482
+ remember: false,
1483
+ install
1484
+ }));
816
1485
  }
817
1486
  };
818
1487
  }
819
1488
  //#endregion
820
1489
  //#region src/index.ts
1490
+ /** Saved sign-ins to forget when they stop working: not when the worker can't be reached, which passes. */
1491
+ function forgets(error) {
1492
+ return error instanceof SignInError && error.problem !== "unavailable";
1493
+ }
821
1494
  /**
822
1495
  * Links to GitHub's token page with everything filled in except the
823
1496
  * repository, which GitHub doesn't let links choose.
@@ -855,14 +1528,175 @@ function githubTokenLinks(repo, webUrl = "https://github.com") {
855
1528
  /** Sign-in and storage for a site whose repository is on GitHub. */
856
1529
  function github(options) {
857
1530
  if (!/^[\w.-]+\/[\w.-]+$/.test(options.repo)) throw new Error(`github({ repo }) must look like "owner/name" (got "${options.repo}").`);
1531
+ if (options.code !== void 0 && !/^[\w.-]+\/[\w.-]+$/.test(options.code)) throw new Error(`github({ code }) must look like "owner/name" (got "${options.code}").`);
858
1532
  const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
1533
+ const webUrl = (options.webUrl ?? "https://github.com").replace(/\/$/, "");
1534
+ /** The code repository, where the content is in a repository of its own, read with `api`. */
1535
+ const codeRepository = (api) => options.contentOnly && options.code ? { code: {
1536
+ repo: options.code,
1537
+ webUrl,
1538
+ ...api && { api }
1539
+ } } : {};
859
1540
  const links = githubTokenLinks(options.repo, options.webUrl);
860
1541
  const storage = credentialStorage(`goodfellow:github:${options.repo}`, options.storage);
1542
+ const pending = credentialStorage(`goodfellow:github:pending:${options.repo}`, options.storage);
1543
+ const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
1544
+ const location = options.location ?? browserLocation();
1545
+ const signInUrl = options.signIn;
1546
+ const service = signInUrl && location && !isLocalAddress(location.href()) ? new SignInService(signInUrl, fetcher) : void 0;
1547
+ /** The code repository to ask the worker for a token for, where the Updates screen follows it. */
1548
+ const codeName = () => options.contentOnly ? options.code : void 0;
1549
+ function save(value, remember) {
1550
+ storage.save(value.type === "token" ? value.token : JSON.stringify(value), remember);
1551
+ }
1552
+ function loadSaved() {
1553
+ const value = storage.load();
1554
+ if (!value) return void 0;
1555
+ try {
1556
+ const parsed = JSON.parse(value);
1557
+ if (parsed.type === "worker" || parsed.type === "token") return parsed;
1558
+ } catch {}
1559
+ return {
1560
+ type: "token",
1561
+ token: value
1562
+ };
1563
+ }
1564
+ /**
1565
+ * Sends the browser to GitHub through the worker, which comes back to this
1566
+ * screen. With `install`, GitHub's install page installs Goodfellow first.
1567
+ */
1568
+ async function redirect(purpose, remember, { install } = {}) {
1569
+ if (!service || !location) throw new SignInError("unavailable", "Signing in with GitHub isn't available here.");
1570
+ if (!install) await service.check(options.repo, purpose);
1571
+ const { verifier, challenge } = await newVerifier();
1572
+ pending.save(JSON.stringify({
1573
+ verifier,
1574
+ purpose,
1575
+ remember
1576
+ }), false);
1577
+ location.assign(service.startUrl({
1578
+ purpose,
1579
+ repo: options.repo,
1580
+ back: location.href(),
1581
+ challenge,
1582
+ remember,
1583
+ install
1584
+ }));
1585
+ }
1586
+ /** Connects with a session from the worker, whose tokens renew themselves. */
1587
+ async function connectWorker(issued, remember) {
1588
+ const source = renewingToken(service, issued, (renewed) => save({
1589
+ type: "worker",
1590
+ session: renewed.session
1591
+ }, remember), options.code);
1592
+ save({
1593
+ type: "worker",
1594
+ session: issued.session
1595
+ }, remember);
1596
+ const api = {
1597
+ apiUrl,
1598
+ token: source.token,
1599
+ fetch: fetcher
1600
+ };
1601
+ const repo = await githubJson(api, `/repos/${repoPath(options.repo)}`);
1602
+ const { user } = issued;
1603
+ return new GitHubBackend({
1604
+ api,
1605
+ repo: options.repo,
1606
+ branch: options.branch ?? repo.default_branch,
1607
+ ...options.contentOnly && { contentOnly: true },
1608
+ ...codeRepository({
1609
+ apiUrl,
1610
+ token: source.codeToken,
1611
+ fetch: fetcher
1612
+ }),
1613
+ user: {
1614
+ login: user.login,
1615
+ name: user.name,
1616
+ avatarUrl: user.avatarUrl
1617
+ },
1618
+ author: {
1619
+ name: user.name || user.login,
1620
+ email: user.email
1621
+ },
1622
+ onSignOut: () => {
1623
+ storage.clear();
1624
+ revokeIssued(source.current());
1625
+ },
1626
+ ownerTokenLink: {
1627
+ url: links.owner,
1628
+ label: "owner.token.create"
1629
+ },
1630
+ ownerRedirect: (redirectOptions) => redirect("owner", false, redirectOptions)
1631
+ });
1632
+ }
1633
+ /** Revokes a token the worker gave, as signing out does. Never fails: signing out goes ahead regardless. */
1634
+ function revokeToken(token) {
1635
+ githubRequest({
1636
+ apiUrl,
1637
+ token,
1638
+ fetch: fetcher
1639
+ }, "/installation/token", { method: "DELETE" }).catch(() => {});
1640
+ }
1641
+ /** Revokes the tokens the worker gave: the site's, and the code repository's. */
1642
+ function revokeIssued(issued) {
1643
+ revokeToken(issued.token);
1644
+ if (issued.code) revokeToken(issued.code.token);
1645
+ }
1646
+ /** Gives the backend an owner's sign-in that has just come back from GitHub. It's kept in memory only. */
1647
+ async function useOwner(backend, grant, verifier) {
1648
+ const worker = service;
1649
+ try {
1650
+ const source = renewingToken(worker, await worker.session(grant, verifier, codeName()), () => {}, codeName());
1651
+ backend.useOwnerSignIn(source.token, () => revokeIssued(source.current()), codeName() ? source.codeToken : void 0);
1652
+ } catch (error) {
1653
+ backend.ownerSignInFailed(error);
1654
+ }
1655
+ }
1656
+ /** Finishes a sign-in through the worker if the page has just come back from it. */
1657
+ async function completeRedirect() {
1658
+ if (!location) return void 0;
1659
+ const url = new URL(location.href());
1660
+ const grant = url.searchParams.get(GRANT_PARAM);
1661
+ const failed = url.searchParams.get(ERROR_PARAM);
1662
+ if (!grant && !failed) return void 0;
1663
+ url.searchParams.delete(GRANT_PARAM);
1664
+ url.searchParams.delete(ERROR_PARAM);
1665
+ location.replace(url.toString());
1666
+ const saved = pending.load();
1667
+ pending.clear();
1668
+ const redirect = saved ? JSON.parse(saved) : void 0;
1669
+ if (!redirect || !service) throw new SignInError("redirect-failed", "Signing in with GitHub didn't finish in this tab.");
1670
+ if (redirect.purpose === "owner") {
1671
+ const backend = await restoreSaved();
1672
+ if (!backend) throw new SignInError("redirect-failed", "Sign in again, then sign in as an owner.");
1673
+ if (failed) backend.ownerSignInFailed(signInProblem({ problem: failed }));
1674
+ else if (grant) await useOwner(backend, grant, redirect.verifier);
1675
+ return backend;
1676
+ }
1677
+ if (failed || !grant) throw signInProblem({ problem: failed });
1678
+ return connectWorker(await service.session(grant, redirect.verifier, codeName()), redirect.remember);
1679
+ }
1680
+ async function restoreSaved() {
1681
+ const saved = loadSaved();
1682
+ if (!saved) return null;
1683
+ try {
1684
+ if (saved.type === "token") return await connect(saved.token);
1685
+ if (!service) {
1686
+ storage.clear();
1687
+ return null;
1688
+ }
1689
+ return await connectWorker(await service.token(saved.session, codeName()), storage.remembered());
1690
+ } catch (error) {
1691
+ if (forgets(error)) storage.clear();
1692
+ throw error;
1693
+ }
1694
+ }
861
1695
  async function connect(token) {
862
1696
  const api = {
863
1697
  apiUrl,
864
1698
  token,
865
- fetch: options.fetch ?? globalThis.fetch.bind(globalThis)
1699
+ fetch: fetcher
866
1700
  };
867
1701
  const user = await githubJson(api, "/user");
868
1702
  let repo;
@@ -877,6 +1711,8 @@ function github(options) {
877
1711
  api,
878
1712
  repo: options.repo,
879
1713
  branch: options.branch ?? repo.default_branch,
1714
+ ...options.contentOnly && { contentOnly: true },
1715
+ ...codeRepository(api),
880
1716
  user: {
881
1717
  login: user.login,
882
1718
  name: user.name ?? void 0,
@@ -886,12 +1722,53 @@ function github(options) {
886
1722
  ownerTokenLink: {
887
1723
  url: links.owner,
888
1724
  label: "owner.token.create"
889
- }
1725
+ },
1726
+ ...service && { ownerRedirect: (redirectOptions) => redirect("owner", false, redirectOptions) }
1727
+ });
1728
+ }
1729
+ /** The repository through a token of the site's own, on a full-stack site's server. */
1730
+ function server({ token }) {
1731
+ const api = {
1732
+ apiUrl,
1733
+ token: typeof token === "string" ? token : () => token(),
1734
+ fetch: fetcher
1735
+ };
1736
+ let branch;
1737
+ const liveBranch = () => {
1738
+ branch ??= options.branch ? Promise.resolve(options.branch) : githubJson(api, `/repos/${repoPath(options.repo)}`).then((repo) => repo.default_branch);
1739
+ branch.catch(() => {
1740
+ branch = void 0;
1741
+ });
1742
+ return branch;
1743
+ };
1744
+ const backend = async (revision, author) => new GitHubBackend({
1745
+ api,
1746
+ repo: options.repo,
1747
+ branch: await liveBranch(),
1748
+ ...options.contentOnly && { contentOnly: true },
1749
+ user: {
1750
+ login: author?.name ?? options.repo,
1751
+ ...author && { name: author.name }
1752
+ },
1753
+ onSignOut: () => {},
1754
+ ownerTokenLink: {
1755
+ url: links.owner,
1756
+ label: "owner.token.create"
1757
+ },
1758
+ ...revision && { revision },
1759
+ ...author && { author }
890
1760
  });
1761
+ return {
1762
+ ...options.contentOnly && { contentOnly: true },
1763
+ async revision() {
1764
+ return (await backend()).revision();
1765
+ },
1766
+ at: backend
1767
+ };
891
1768
  }
892
1769
  return {
893
1770
  name: "GitHub",
894
- canRedirect: false,
1771
+ canRedirect: service !== void 0,
895
1772
  tokenLinks: [{
896
1773
  url: links.fineGrained,
897
1774
  label: "signIn.token.create"
@@ -901,25 +1778,24 @@ function github(options) {
901
1778
  hint: "signIn.token.createBroadHint"
902
1779
  }],
903
1780
  async restore() {
904
- const token = storage.load();
905
- if (!token) return null;
906
- try {
907
- return await connect(token);
908
- } catch (error) {
909
- if (error instanceof SignInError) storage.clear();
910
- throw error;
911
- }
1781
+ const redirected = await completeRedirect();
1782
+ if (redirected !== void 0) return redirected;
1783
+ return restoreSaved();
912
1784
  },
913
1785
  async signInWithToken(token, remember) {
914
1786
  const trimmed = token.trim();
915
1787
  if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
916
1788
  const backend = await connect(trimmed);
917
- storage.save(trimmed, remember);
1789
+ save({
1790
+ type: "token",
1791
+ token: trimmed
1792
+ }, remember);
918
1793
  return backend;
919
1794
  },
920
- async startRedirect() {
921
- throw new Error("Signing in by redirect needs a sign-in server, which isn't supported for GitHub yet.");
922
- }
1795
+ startRedirect(remember, redirectOptions) {
1796
+ return redirect("edit", remember, redirectOptions);
1797
+ },
1798
+ server
923
1799
  };
924
1800
  }
925
1801
  //#endregion