@goodfellow-cms/github 0.2.0 → 0.3.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, SetupError, SignInError, UpdatesError, branchDrafts, buildStepFor, compareReleases, credentialStorage, 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,28 @@ 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
+ let endSignIn;
152
+ let problem;
145
153
  return {
146
154
  access: {
147
155
  tokenLink,
148
156
  get active() {
149
157
  return owner !== void 0;
150
158
  },
159
+ get problem() {
160
+ return problem;
161
+ },
162
+ ...redirect && { async startRedirect(options) {
163
+ problem = void 0;
164
+ await redirect(options);
165
+ } },
151
166
  async applyToken(token) {
152
167
  const api = {
153
168
  ...base,
@@ -162,12 +177,28 @@ function githubOwnerAccess(base, repo, tokenLink) {
162
177
  throw error;
163
178
  }
164
179
  owner = api;
180
+ problem = void 0;
165
181
  },
166
182
  forget() {
167
183
  owner = void 0;
184
+ endSignIn?.();
185
+ endSignIn = void 0;
168
186
  }
169
187
  },
170
- api: () => owner
188
+ api: () => owner,
189
+ /** Uses an owner's sign-in through the worker. `end` runs when it's forgotten. */
190
+ useSignIn(token, end) {
191
+ owner = {
192
+ ...base,
193
+ token
194
+ };
195
+ endSignIn = end;
196
+ problem = void 0;
197
+ },
198
+ /** Remembers why an owner's sign-in didn't work, for the screen that started it to show. */
199
+ failed(error) {
200
+ problem = error;
201
+ }
171
202
  };
172
203
  }
173
204
  //#endregion
@@ -361,6 +392,8 @@ function githubUpdates(read, manage, repo, branch, files) {
361
392
  }
362
393
  //#endregion
363
394
  //#region src/backend.ts
395
+ /** How many files one GraphQL request reads. */
396
+ const GRAPHQL_BATCH = 100;
364
397
  const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
365
398
  createCommitOnBranch(input: $input) { commit { oid } }
366
399
  }`;
@@ -369,30 +402,99 @@ const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
369
402
  * returned; saves are single commits made with GraphQL's `createCommitOnBranch`,
370
403
  * which fails if the branch has moved, so nothing is ever overwritten.
371
404
  */
372
- var GitHubBackend = class {
405
+ var GitHubBackend = class GitHubBackend {
373
406
  user;
374
407
  pages;
375
408
  editors;
376
409
  ownerAccess;
377
410
  updates;
411
+ drafts;
412
+ options;
378
413
  api;
379
414
  repo;
380
415
  branch;
381
416
  onSignOut;
417
+ author;
418
+ owner;
382
419
  pinned;
383
420
  trees = /* @__PURE__ */ new Map();
421
+ /** Each file's size in bytes at a commit, which GitHub lists with its tree. */
422
+ treeSizes = /* @__PURE__ */ new Map();
384
423
  blobs = /* @__PURE__ */ new Map();
385
424
  constructor(options) {
425
+ this.options = options;
386
426
  this.api = options.api;
387
427
  this.repo = repoPath(options.repo);
388
428
  this.branch = options.branch;
389
429
  this.user = options.user;
390
430
  this.onSignOut = options.onSignOut;
391
- const owner = githubOwnerAccess(this.api, this.repo, options.ownerTokenLink);
431
+ this.author = options.author;
432
+ const owner = githubOwnerAccess(this.api, this.repo, options.ownerTokenLink, options.ownerRedirect);
433
+ this.owner = owner;
392
434
  this.ownerAccess = owner.access;
393
435
  this.pages = githubPages(() => owner.api() ?? this.api, this.repo);
394
436
  this.editors = githubEditors(this.api, owner.api, this.repo, this.user.login);
395
437
  this.updates = githubUpdates(this.api, owner.api, this.repo, this.branch, this);
438
+ this.drafts = branchDrafts(this, this.draftHost());
439
+ }
440
+ /** Drafts on branches of the repository, with pull requests for review. */
441
+ draftHost() {
442
+ const base = `/repos/${this.repo}`;
443
+ const ref = (branch) => `${base}/git/refs/heads/${encodeURIComponent(branch)}`;
444
+ return {
445
+ liveBranch: this.branch,
446
+ branchStore: (branch) => new GitHubBackend({
447
+ ...this.options,
448
+ branch
449
+ }),
450
+ createBranch: async (branch, revision) => {
451
+ if ((await githubRequest(this.api, `${base}/git/refs`, {
452
+ method: "POST",
453
+ body: {
454
+ ref: `refs/heads/${branch}`,
455
+ sha: revision
456
+ },
457
+ allow: [422]
458
+ })).status === 422) throw new DraftError("exists", `There's already a draft called ${branch}.`);
459
+ },
460
+ deleteBranch: async (branch) => {
461
+ await githubRequest(this.api, ref(branch), {
462
+ method: "DELETE",
463
+ allow: [404, 422]
464
+ });
465
+ },
466
+ mergeBase: async (branch) => {
467
+ const response = await githubRequest(this.api, `${base}/compare/${encodeURIComponent(this.branch)}...${encodeURIComponent(branch)}`, { allow: [404] });
468
+ if (response.status === 404) throw new DraftError("not-found", `There's no draft ${branch}.`);
469
+ return (await response.json()).merge_base_commit.sha;
470
+ },
471
+ openReview: async ({ branch, title, description }) => {
472
+ const response = await githubRequest(this.api, `${base}/pulls`, {
473
+ method: "POST",
474
+ body: {
475
+ title,
476
+ head: branch,
477
+ base: this.branch,
478
+ body: description
479
+ },
480
+ allow: [
481
+ 403,
482
+ 404,
483
+ 422
484
+ ]
485
+ });
486
+ if (response.ok) return { url: (await response.json()).html_url };
487
+ if (response.status !== 422) throw new DraftError("no-reviews", "This sign-in can't open pull requests on the site's repository.");
488
+ const owner = decodeURIComponent(this.repo).split("/")[0];
489
+ const query = new URLSearchParams({
490
+ head: `${owner}:${branch}`,
491
+ state: "open"
492
+ });
493
+ const [open] = await githubJson(this.api, `${base}/pulls?${query}`);
494
+ if (!open) throw new GitApiError(422, "GitHub couldn't open the pull request.");
495
+ return { url: open.html_url };
496
+ }
497
+ };
396
498
  }
397
499
  async revision() {
398
500
  const ref = await githubJson(this.api, `/repos/${this.repo}/git/ref/heads/${encodeURIComponent(this.branch)}`);
@@ -405,7 +507,9 @@ var GitHubBackend = class {
405
507
  if (!tree) {
406
508
  tree = githubJson(this.api, `/repos/${this.repo}/git/trees/${sha}?recursive=1`).then((response) => {
407
509
  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]));
510
+ const blobs = response.tree.filter((entry) => entry.type === "blob");
511
+ this.treeSizes.set(sha, new Map(blobs.flatMap((entry) => entry.size === void 0 ? [] : [[entry.path, entry.size]])));
512
+ return new Map(blobs.map((entry) => [entry.path, entry.sha]));
409
513
  });
410
514
  tree.catch(() => this.trees.delete(sha));
411
515
  this.trees.set(sha, tree);
@@ -429,6 +533,40 @@ var GitHubBackend = class {
429
533
  }
430
534
  return blob;
431
535
  }
536
+ /** Reads many text files in a few GraphQL requests rather than one request each. */
537
+ async readFiles(paths) {
538
+ const tree = await this.currentTree();
539
+ const wanted = paths.flatMap((path) => {
540
+ const sha = tree.get(path);
541
+ return sha ? [[path, sha]] : [];
542
+ });
543
+ const fetch = [...new Set(wanted.map(([, sha]) => sha).filter((sha) => !this.blobs.has(sha)))];
544
+ const [owner, name] = decodeURIComponent(this.repo).split("/");
545
+ for (let start = 0; start < fetch.length; start += GRAPHQL_BATCH) {
546
+ const batch = fetch.slice(start, start + GRAPHQL_BATCH);
547
+ const fields = batch.map((sha, index) => `f${index}: object(oid: "${sha}") { ... on Blob { text isBinary } }`);
548
+ const response = await githubJson(this.api, "/graphql", {
549
+ method: "POST",
550
+ body: {
551
+ query: `query ($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${fields.join(" ")} } }`,
552
+ variables: {
553
+ owner,
554
+ name
555
+ }
556
+ }
557
+ });
558
+ batch.forEach((sha, index) => {
559
+ const blob = response.data?.repository?.[`f${index}`];
560
+ if (blob && !blob.isBinary && typeof blob.text === "string") this.blobs.set(sha, Promise.resolve(blob.text));
561
+ });
562
+ }
563
+ const result = /* @__PURE__ */ new Map();
564
+ for (const [path] of wanted) {
565
+ const text = await this.readFrom(tree, path);
566
+ if (text !== void 0) result.set(path, text);
567
+ }
568
+ return result;
569
+ }
432
570
  async readBytes(path) {
433
571
  const sha = (await this.currentTree()).get(path);
434
572
  if (!sha) return void 0;
@@ -439,7 +577,100 @@ var GitHubBackend = class {
439
577
  const prefix = `${dir}/`;
440
578
  return [...(await this.currentTree()).keys()].filter((path) => path.startsWith(prefix)).sort();
441
579
  }
442
- async write(changes, { message, expectedRevision }) {
580
+ async fileSizes(dir) {
581
+ const prefix = `${dir}/`;
582
+ await this.currentTree();
583
+ const sizes = this.treeSizes.get(this.pinned ?? "") ?? /* @__PURE__ */ new Map();
584
+ return new Map([...sizes].filter(([path]) => path.startsWith(prefix)));
585
+ }
586
+ /** Uses an owner's sign-in through the worker for editors and GitHub Pages settings, until it's forgotten. */
587
+ useOwnerSignIn(token, end) {
588
+ this.owner.useSignIn(token, end);
589
+ }
590
+ /** Remembers why an owner's sign-in through the worker didn't work, which the Editors and Domain screens show. */
591
+ ownerSignInFailed(error) {
592
+ this.owner.failed(error);
593
+ }
594
+ async write(changes, options) {
595
+ return this.author ? this.writeCommit(changes, options, this.author) : this.writeGraphQL(changes, options);
596
+ }
597
+ /**
598
+ * Saves with Git's data API, naming the editor as the commit's author:
599
+ * GitHub refuses to move the branch unless the commit follows on from it, so
600
+ * nothing is overwritten.
601
+ */
602
+ async writeCommit(changes, { message, expectedRevision }, author) {
603
+ const existing = await this.tree(expectedRevision);
604
+ const base = `/repos/${this.repo}`;
605
+ const entries = [];
606
+ try {
607
+ for (const change of changes) if ("delete" in change) {
608
+ if (existing.has(change.path)) entries.push({
609
+ path: change.path,
610
+ mode: "100644",
611
+ type: "blob",
612
+ sha: null
613
+ });
614
+ } else if ("bytes" in change) {
615
+ const blob = await githubJson(this.api, `${base}/git/blobs`, {
616
+ method: "POST",
617
+ body: {
618
+ content: encodeBase64Bytes(change.bytes),
619
+ encoding: "base64"
620
+ }
621
+ });
622
+ entries.push({
623
+ path: change.path,
624
+ mode: "100644",
625
+ type: "blob",
626
+ sha: blob.sha
627
+ });
628
+ } else entries.push({
629
+ path: change.path,
630
+ mode: "100644",
631
+ type: "blob",
632
+ content: change.content
633
+ });
634
+ if (entries.length === 0) return { revision: expectedRevision };
635
+ const parent = await githubJson(this.api, `${base}/git/commits/${expectedRevision}`);
636
+ const tree = await githubJson(this.api, `${base}/git/trees`, {
637
+ method: "POST",
638
+ body: {
639
+ base_tree: parent.tree.sha,
640
+ tree: entries
641
+ }
642
+ });
643
+ const commit = await githubJson(this.api, `${base}/git/commits`, {
644
+ method: "POST",
645
+ body: {
646
+ message,
647
+ tree: tree.sha,
648
+ parents: [expectedRevision],
649
+ author: {
650
+ ...author,
651
+ date: (/* @__PURE__ */ new Date()).toISOString()
652
+ }
653
+ }
654
+ });
655
+ if ((await githubRequest(this.api, `${base}/git/refs/heads/${encodeURIComponent(this.branch)}`, {
656
+ method: "PATCH",
657
+ body: {
658
+ sha: commit.sha,
659
+ force: false
660
+ },
661
+ allow: [422]
662
+ })).status === 422) {
663
+ if (await this.revision() !== expectedRevision) throw new ConflictError();
664
+ throw new GitApiError(422, "GitHub couldn't save the changes.");
665
+ }
666
+ this.pinned = commit.sha;
667
+ return { revision: commit.sha };
668
+ } catch (error) {
669
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SignInError("cant-publish", "This sign-in can't publish to the site.");
670
+ throw error;
671
+ }
672
+ }
673
+ async writeGraphQL(changes, { message, expectedRevision }) {
443
674
  const existing = await this.tree(expectedRevision);
444
675
  const additions = changes.flatMap((change) => "delete" in change ? [] : [{
445
676
  path: change.path,
@@ -576,6 +807,164 @@ var GitHubBackend = class {
576
807
  }
577
808
  };
578
809
  //#endregion
810
+ //#region src/sign-in.ts
811
+ /**
812
+ * Talking to the sign-in worker (`@goodfellow-cms/worker`), which signs
813
+ * editors in with GitHub and gives the admin panel a token for the site's
814
+ * repository, lasting an hour. Requests are sent as plain text, so browsers
815
+ * send them straight away without asking the worker first.
816
+ */
817
+ /** The sign-in worker Goodfellow runs for every site. Sites can run their own and name it in `github({ signIn })`. */
818
+ const GOODFELLOW_SIGN_IN = "https://sign-in.goodfellow-cms.workers.dev";
819
+ /** The worker's problems, as the admin panel's sign-in problems. */
820
+ const PROBLEMS = {
821
+ invalid: "invalid",
822
+ "no-access": "no-access",
823
+ "cant-publish": "cant-publish",
824
+ cancelled: "redirect-failed",
825
+ "not-installed": "not-installed",
826
+ "no-address": "no-address",
827
+ "wrong-address": "wrong-address",
828
+ "local-address": "local-address",
829
+ "not-owner": "not-owner"
830
+ };
831
+ /** The worker's answer as a `SignInError`. */
832
+ function signInProblem(answer) {
833
+ const problem = PROBLEMS[String(answer.problem)] ?? "unavailable";
834
+ const link = typeof answer.installUrl === "string" && /^https:\/\//.test(answer.installUrl) ? answer.installUrl : void 0;
835
+ return new SignInError(problem, `The sign-in service said: ${String(answer.problem ?? "no answer")}.`, link);
836
+ }
837
+ /** Addresses on the computer itself, which the worker never gives tokens to. */
838
+ function isLocalAddress(href) {
839
+ let host;
840
+ try {
841
+ host = new URL(href).hostname.toLowerCase();
842
+ } catch {
843
+ return true;
844
+ }
845
+ return host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || host === "[::1]" || /^127(\.\d{1,3}){3}$/.test(host);
846
+ }
847
+ function base64Url(bytes) {
848
+ let binary = "";
849
+ for (const byte of bytes) binary += String.fromCharCode(byte);
850
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
851
+ }
852
+ /** A secret that never leaves this tab, and its hash, which the worker checks it against (PKCE's S256). */
853
+ async function newVerifier() {
854
+ const verifier = base64Url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)));
855
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
856
+ return {
857
+ verifier,
858
+ challenge: base64Url(new Uint8Array(digest))
859
+ };
860
+ }
861
+ function browserLocation() {
862
+ if (typeof window === "undefined") return void 0;
863
+ return {
864
+ href: () => window.location.href,
865
+ assign: (url) => window.location.assign(url),
866
+ replace: (url) => window.history.replaceState(window.history.state, "", url)
867
+ };
868
+ }
869
+ /** Query parameters the worker comes back with. */
870
+ const GRANT_PARAM = "goodfellow_sign_in";
871
+ const ERROR_PARAM = "goodfellow_sign_in_error";
872
+ var SignInService = class {
873
+ url;
874
+ fetcher;
875
+ constructor(url, fetcher) {
876
+ this.url = url.replace(/\/$/, "");
877
+ this.fetcher = fetcher;
878
+ }
879
+ async answer(path, init) {
880
+ let response;
881
+ try {
882
+ response = await this.fetcher(`${this.url}${path}`, init);
883
+ } catch (error) {
884
+ throw new SignInError("unavailable", `Couldn't reach the sign-in service: ${error.message}`);
885
+ }
886
+ const body = await response.json().catch(() => ({}));
887
+ if (!response.ok) throw signInProblem(body);
888
+ return body;
889
+ }
890
+ /** Posts JSON as plain text. */
891
+ post(path, body) {
892
+ return this.answer(path, {
893
+ method: "POST",
894
+ headers: { "content-type": "text/plain" },
895
+ body: JSON.stringify(body)
896
+ });
897
+ }
898
+ /** Posts JSON as plain text, and gives the answer whatever it is, for callers that explain problems of their own. */
899
+ async postAnswer(path, body) {
900
+ let response;
901
+ try {
902
+ response = await this.fetcher(`${this.url}${path}`, {
903
+ method: "POST",
904
+ headers: { "content-type": "text/plain" },
905
+ body: JSON.stringify(body)
906
+ });
907
+ } catch (error) {
908
+ throw new SignInError("unavailable", `Couldn't reach the sign-in service: ${error.message}`);
909
+ }
910
+ return {
911
+ ok: response.ok,
912
+ body: await response.json().catch(() => ({}))
913
+ };
914
+ }
915
+ /** Whether the worker is set up and answering. Throws `SignInError` if it isn't. */
916
+ async health() {
917
+ if ((await this.answer("/health")).ok !== true) throw new SignInError("unavailable", "The sign-in service isn't set up.");
918
+ }
919
+ /** Whether this page can sign in to the repository through the worker. Throws `SignInError` if it can't. */
920
+ async check(repo, purpose) {
921
+ await this.answer(`/github/check?${new URLSearchParams({
922
+ repo,
923
+ purpose
924
+ })}`);
925
+ }
926
+ /** Where to send the browser to sign in, or with `install`, to install Goodfellow and sign in. */
927
+ startUrl(options) {
928
+ const query = new URLSearchParams({
929
+ purpose: options.purpose,
930
+ ...options.repo && { repo: options.repo },
931
+ return: options.back,
932
+ challenge: options.challenge,
933
+ remember: options.remember ? "1" : "0",
934
+ ...options.install && { install: "1" }
935
+ });
936
+ return `${this.url}/github/start?${query}`;
937
+ }
938
+ /** Exchanges the grant the worker came back with, and this tab's verifier, for a session (and for a site, its first token). */
939
+ session(grant, verifier) {
940
+ return this.post("/github/session", {
941
+ grant,
942
+ verifier
943
+ });
944
+ }
945
+ token(session) {
946
+ return this.post("/github/token", { session });
947
+ }
948
+ };
949
+ /** A token that renews itself through the worker before it expires, or when GitHub stops accepting it. */
950
+ function renewingToken(service, first, onRenew) {
951
+ let current = first;
952
+ let renewing;
953
+ return {
954
+ current: () => current,
955
+ async token({ refresh = false } = {}) {
956
+ if (refresh || Date.parse(current.expiresAt) - 3e5 < Date.now()) {
957
+ renewing ??= service.token(current.session).finally(() => {
958
+ renewing = void 0;
959
+ });
960
+ current = await renewing;
961
+ onRenew(current);
962
+ }
963
+ return current.token;
964
+ }
965
+ };
966
+ }
967
+ //#endregion
579
968
  //#region src/setup.ts
580
969
  /** The branch the deploy setups build from. */
581
970
  const MAIN = "main";
@@ -603,19 +992,167 @@ function githubSetupTokenLinks(webUrl = "https://github.com") {
603
992
  classic: `${webUrl}/settings/tokens/new?${classic}`
604
993
  };
605
994
  }
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() {
995
+ /** The steps after the repository exists, which are the same however it was created. */
996
+ function setupAccount(creator) {
997
+ /** Makes `main` the default branch, which the deploy setups build from. */
998
+ async function useMainBranch(api, repo) {
999
+ if (repo.default_branch === MAIN) return;
1000
+ const base = `/repos/${repoPath(repo.full_name)}`;
1001
+ const ref = await githubJson(api, `${base}/git/ref/heads/${repo.default_branch}`);
1002
+ await githubRequest(api, `${base}/git/refs`, {
1003
+ method: "POST",
1004
+ body: {
1005
+ ref: `refs/heads/${MAIN}`,
1006
+ sha: ref.object.sha
1007
+ }
1008
+ });
1009
+ await githubRequest(api, base, {
1010
+ method: "PATCH",
1011
+ body: { default_branch: MAIN }
1012
+ });
1013
+ await githubRequest(api, `${base}/git/refs/heads/${repo.default_branch}`, { method: "DELETE" });
1014
+ }
1015
+ /** Turns on GitHub Pages, published by the site's own workflow. Returns the site's address. */
1016
+ async function turnOnPages(api, repo) {
1017
+ try {
1018
+ return (await githubJson(api, `/repos/${repoPath(repo.full_name)}/pages`, {
1019
+ method: "POST",
1020
+ body: { build_type: "workflow" }
1021
+ })).html_url;
1022
+ } catch (error) {
1023
+ if (error instanceof GitApiError && [
1024
+ 403,
1025
+ 404,
1026
+ 409,
1027
+ 422
1028
+ ].includes(error.status)) return void 0;
1029
+ throw error;
1030
+ }
1031
+ }
1032
+ /** Writes every file in one commit, which replaces the starting README rather than following it. */
1033
+ async function writeFiles(api, repo, site) {
1034
+ const base = `/repos/${repoPath(repo.full_name)}`;
1035
+ const tree = [];
1036
+ for (const file of site.files) {
1037
+ if ("delete" in file) continue;
1038
+ if ("content" in file) {
1039
+ tree.push({
1040
+ path: file.path,
1041
+ mode: "100644",
1042
+ type: "blob",
1043
+ content: file.content
1044
+ });
1045
+ continue;
1046
+ }
1047
+ const blob = await githubJson(api, `${base}/git/blobs`, {
1048
+ method: "POST",
1049
+ body: {
1050
+ content: encodeBase64Bytes(file.bytes),
1051
+ encoding: "base64"
1052
+ }
1053
+ });
1054
+ tree.push({
1055
+ path: file.path,
1056
+ mode: "100644",
1057
+ type: "blob",
1058
+ sha: blob.sha
1059
+ });
1060
+ }
1061
+ try {
1062
+ const created = await githubJson(api, `${base}/git/trees`, {
1063
+ method: "POST",
1064
+ body: { tree }
1065
+ });
1066
+ const commit = await githubJson(api, `${base}/git/commits`, {
1067
+ method: "POST",
1068
+ body: {
1069
+ message: site.message,
1070
+ tree: created.sha,
1071
+ parents: [],
1072
+ ...creator.author && { author: {
1073
+ ...creator.author,
1074
+ date: (/* @__PURE__ */ new Date()).toISOString()
1075
+ } }
1076
+ }
1077
+ });
1078
+ await githubRequest(api, `${base}/git/refs/heads/${MAIN}`, {
1079
+ method: "PATCH",
1080
+ body: {
1081
+ sha: commit.sha,
1082
+ force: true
1083
+ }
1084
+ });
1085
+ } catch (error) {
1086
+ 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.");
1087
+ throw error;
1088
+ }
1089
+ }
1090
+ /** Stops anyone rewriting or deleting the main branch's history. Not every plan allows it. */
1091
+ async function protectMain(api, repo) {
1092
+ return (await githubRequest(api, `/repos/${repoPath(repo.full_name)}/rulesets`, {
1093
+ method: "POST",
1094
+ body: {
1095
+ name: "Protect the published history",
1096
+ target: "branch",
1097
+ enforcement: "active",
1098
+ conditions: { ref_name: {
1099
+ include: ["~DEFAULT_BRANCH"],
1100
+ exclude: []
1101
+ } },
1102
+ rules: [{ type: "non_fast_forward" }, { type: "deletion" }]
1103
+ },
1104
+ allow: [
1105
+ 403,
1106
+ 404,
1107
+ 422
1108
+ ]
1109
+ })).ok;
1110
+ }
1111
+ return {
1112
+ user: creator.user,
1113
+ owners: () => creator.owners(),
1114
+ isAvailable: (owner, name) => creator.isAvailable(owner, name),
1115
+ async createSite(site, onStep) {
1116
+ const warnings = [];
1117
+ onStep?.("repository");
1118
+ const { repo, api, signInInstallUrl } = await creator.create(site);
1119
+ await useMainBranch(api, repo);
1120
+ let siteUrl;
1121
+ if (site.pages) {
1122
+ onStep?.("pages");
1123
+ siteUrl = await turnOnPages(api, repo);
1124
+ if (!siteUrl) warnings.push("pages-unavailable");
1125
+ }
1126
+ onStep?.("files");
1127
+ await writeFiles(api, repo, site);
1128
+ onStep?.("protection");
1129
+ if (!await protectMain(api, repo)) warnings.push("protection-unavailable");
1130
+ if (signInInstallUrl) warnings.push("sign-in-not-installed");
1131
+ return {
1132
+ repo: repo.full_name,
1133
+ webUrl: repo.html_url,
1134
+ siteUrl,
1135
+ buildsUrl: `${repo.html_url}/actions`,
1136
+ pagesUrl: site.pages ? `${repo.html_url}/settings/pages` : void 0,
1137
+ ...signInInstallUrl && { signInInstallUrl },
1138
+ warnings
1139
+ };
1140
+ }
1141
+ };
1142
+ }
1143
+ /** Creating repositories with the person's own token. */
1144
+ async function tokenCreator(api) {
1145
+ const user = await githubJson(api, "/user");
1146
+ async function isAvailable(owner, name) {
1147
+ return (await githubRequest(api, `/repos/${repoPath(`${owner.path}/${name}`)}`, { allow: [404] })).status === 404;
1148
+ }
1149
+ return {
1150
+ user: {
1151
+ login: user.login,
1152
+ name: user.name ?? void 0,
1153
+ avatarUrl: user.avatar_url
1154
+ },
1155
+ async owners() {
619
1156
  return [{
620
1157
  path: user.login,
621
1158
  name: user.name || user.login,
@@ -625,176 +1162,114 @@ function githubSetup(options = {}) {
625
1162
  name: org.login,
626
1163
  kind: "organization"
627
1164
  }))];
628
- }
629
- async function isAvailable(owner, name) {
630
- return (await githubRequest(api, `/repos/${repoPath(`${owner.path}/${name}`)}`, { allow: [404] })).status === 404;
631
- }
632
- async function createRepository(site) {
1165
+ },
1166
+ isAvailable,
1167
+ async create(site) {
633
1168
  const path = site.owner.kind === "personal" ? "/user/repos" : `/orgs/${encodeURIComponent(site.owner.path)}/repos`;
634
1169
  if (!await isAvailable(site.owner, site.name)) throw new SetupError("name-taken", `There's already a repository called ${site.owner.path}/${site.name}.`);
635
1170
  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
- }
644
- });
1171
+ return {
1172
+ repo: await githubJson(api, path, {
1173
+ method: "POST",
1174
+ body: {
1175
+ name: site.name,
1176
+ private: site.private,
1177
+ description: site.description,
1178
+ auto_init: true
1179
+ }
1180
+ }),
1181
+ api
1182
+ };
645
1183
  } catch (error) {
646
1184
  if (error instanceof GitApiError && error.status === 422) throw new SetupError("name-invalid", `GitHub can't use "${site.name}" as a repository name.`);
647
1185
  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
1186
  throw error;
649
1187
  }
650
1188
  }
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`, {
657
- method: "POST",
658
- body: {
659
- ref: `refs/heads/${MAIN}`,
660
- sha: ref.object.sha
661
- }
662
- });
663
- await githubRequest(api, base, {
664
- method: "PATCH",
665
- body: { default_branch: MAIN }
1189
+ };
1190
+ }
1191
+ /**
1192
+ * Creating repositories through the sign-in worker, which uses the person's
1193
+ * GitHub token itself and gives back a token for the new repository only.
1194
+ */
1195
+ function workerCreator(service, apiUrl, fetcher, signedIn) {
1196
+ let session = signedIn.session;
1197
+ async function call(path, body) {
1198
+ const answer = await service.postAnswer(path, {
1199
+ session,
1200
+ ...body
1201
+ });
1202
+ if (typeof answer.body.session === "string") session = answer.body.session;
1203
+ if (!answer.ok) throw setupProblem(answer.body);
1204
+ return answer.body;
1205
+ }
1206
+ const { user } = signedIn;
1207
+ return {
1208
+ user: {
1209
+ login: user.login,
1210
+ name: user.name,
1211
+ avatarUrl: user.avatarUrl
1212
+ },
1213
+ author: {
1214
+ name: user.name || user.login,
1215
+ email: user.email
1216
+ },
1217
+ async owners() {
1218
+ const found = await call("/github/setup/owners", {});
1219
+ return (found.owners ?? []).map(({ installed, ...owner }) => ({
1220
+ ...owner,
1221
+ ...!installed && found.installUrl && { installUrl: found.installUrl }
1222
+ }));
1223
+ },
1224
+ async isAvailable(owner, name) {
1225
+ return (await call("/github/setup/available", {
1226
+ owner: owner.path,
1227
+ name
1228
+ })).available === true;
1229
+ },
1230
+ async create(site) {
1231
+ if (site.owner.installUrl) throw new SetupError("not-installed", `Goodfellow isn't installed on ${site.owner.path}.`, site.owner.installUrl);
1232
+ const created = await call("/github/setup/repository", {
1233
+ owner: site.owner.path,
1234
+ name: site.name,
1235
+ private: site.private,
1236
+ description: site.description
666
1237
  });
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
- }
685
- }
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`, {
721
- method: "POST",
722
- body: {
723
- message: site.message,
724
- tree: created.sha,
725
- parents: []
726
- }
727
- });
728
- await githubRequest(api, `${base}/git/refs/heads/${MAIN}`, {
729
- method: "PATCH",
730
- body: {
731
- sha: commit.sha,
732
- force: true
733
- }
734
- });
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
- }
739
- }
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" }]
1238
+ if (!created.repo || !created.token) throw new SignInError("unavailable", "The sign-in service didn't answer.");
1239
+ return {
1240
+ repo: created.repo,
1241
+ api: {
1242
+ apiUrl,
1243
+ token: created.token,
1244
+ fetch: fetcher
753
1245
  },
754
- allow: [
755
- 403,
756
- 404,
757
- 422
758
- ]
759
- })).ok;
1246
+ ...created.editInstallUrl && { signInInstallUrl: created.editInstallUrl }
1247
+ };
760
1248
  }
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");
784
- 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
791
- };
792
- }
793
- };
1249
+ };
1250
+ }
1251
+ /** The worker's answer when it couldn't create the repository, as a `SetupError` or `SignInError`. */
1252
+ function setupProblem(answer) {
1253
+ switch (answer.problem) {
1254
+ case "name-taken": return new SetupError("name-taken", "There's already a repository with that name.");
1255
+ case "name-invalid": return new SetupError("name-invalid", "GitHub can't use that as a repository name.");
1256
+ case "not-allowed": return new SetupError("not-allowed", "This sign-in can't create repositories there.");
1257
+ case "not-installed": return new SetupError("not-installed", "Goodfellow isn't installed there.", answer.installUrl);
1258
+ default: return signInProblem(answer);
794
1259
  }
1260
+ }
1261
+ /** Signing in to GitHub to create a site's repository, with GitHub Pages if wanted. */
1262
+ function githubSetup(options = {}) {
1263
+ const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
1264
+ const links = githubSetupTokenLinks(options.webUrl);
1265
+ const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
1266
+ const location = options.location ?? browserLocation();
1267
+ const signInUrl = options.signIn === void 0 ? GOODFELLOW_SIGN_IN : options.signIn;
1268
+ const service = signInUrl && location && !isLocalAddress(location.href()) ? new SignInService(signInUrl, fetcher) : void 0;
1269
+ const pending = credentialStorage("goodfellow:github:setup:pending", options.storage);
795
1270
  return {
796
1271
  name: "GitHub",
797
- canRedirect: false,
1272
+ canRedirect: service !== void 0,
798
1273
  tokenLinks: [{
799
1274
  url: links.fineGrained,
800
1275
  label: "signIn.token.create"
@@ -804,20 +1279,50 @@ function githubSetup(options = {}) {
804
1279
  hint: "signIn.token.createBroadHint"
805
1280
  }],
806
1281
  async restore() {
807
- return null;
1282
+ if (!location) return null;
1283
+ const url = new URL(location.href());
1284
+ const grant = url.searchParams.get(GRANT_PARAM);
1285
+ const failed = url.searchParams.get(ERROR_PARAM);
1286
+ if (!grant && !failed) return null;
1287
+ url.searchParams.delete(GRANT_PARAM);
1288
+ url.searchParams.delete(ERROR_PARAM);
1289
+ location.replace(url.toString());
1290
+ const verifier = pending.load();
1291
+ pending.clear();
1292
+ if (failed || !grant || !verifier || !service) throw signInProblem({ problem: failed ?? "cancelled" });
1293
+ const signedIn = await service.session(grant, verifier);
1294
+ return setupAccount(workerCreator(service, apiUrl, fetcher, signedIn));
808
1295
  },
809
1296
  async signInWithToken(token) {
810
1297
  const trimmed = token.trim();
811
1298
  if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
812
- return connect(trimmed);
1299
+ return setupAccount(await tokenCreator({
1300
+ apiUrl,
1301
+ token: trimmed,
1302
+ fetch: fetcher
1303
+ }));
813
1304
  },
814
- async startRedirect() {
815
- throw new Error("Signing in by redirect needs a sign-in server, which isn't supported for GitHub yet.");
1305
+ async startRedirect({ install } = {}) {
1306
+ if (!service || !location) throw new SignInError("unavailable", "Signing in with GitHub isn't available here.");
1307
+ await service.health();
1308
+ const { verifier, challenge } = await newVerifier();
1309
+ pending.save(verifier, false);
1310
+ location.assign(service.startUrl({
1311
+ purpose: "setup",
1312
+ back: location.href(),
1313
+ challenge,
1314
+ remember: false,
1315
+ install
1316
+ }));
816
1317
  }
817
1318
  };
818
1319
  }
819
1320
  //#endregion
820
1321
  //#region src/index.ts
1322
+ /** Saved sign-ins to forget when they stop working: not when the worker can't be reached, which passes. */
1323
+ function forgets(error) {
1324
+ return error instanceof SignInError && error.problem !== "unavailable";
1325
+ }
821
1326
  /**
822
1327
  * Links to GitHub's token page with everything filled in except the
823
1328
  * repository, which GitHub doesn't let links choose.
@@ -858,11 +1363,151 @@ function github(options) {
858
1363
  const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
859
1364
  const links = githubTokenLinks(options.repo, options.webUrl);
860
1365
  const storage = credentialStorage(`goodfellow:github:${options.repo}`, options.storage);
1366
+ const pending = credentialStorage(`goodfellow:github:pending:${options.repo}`, options.storage);
1367
+ const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
1368
+ const location = options.location ?? browserLocation();
1369
+ const signInUrl = options.signIn === void 0 ? GOODFELLOW_SIGN_IN : options.signIn;
1370
+ const service = signInUrl && location && !isLocalAddress(location.href()) ? new SignInService(signInUrl, fetcher) : void 0;
1371
+ function save(value, remember) {
1372
+ storage.save(value.type === "token" ? value.token : JSON.stringify(value), remember);
1373
+ }
1374
+ function loadSaved() {
1375
+ const value = storage.load();
1376
+ if (!value) return void 0;
1377
+ try {
1378
+ const parsed = JSON.parse(value);
1379
+ if (parsed.type === "worker" || parsed.type === "token") return parsed;
1380
+ } catch {}
1381
+ return {
1382
+ type: "token",
1383
+ token: value
1384
+ };
1385
+ }
1386
+ /**
1387
+ * Sends the browser to GitHub through the worker, which comes back to this
1388
+ * screen. With `install`, GitHub's install page installs Goodfellow first.
1389
+ */
1390
+ async function redirect(purpose, remember, { install } = {}) {
1391
+ if (!service || !location) throw new SignInError("unavailable", "Signing in with GitHub isn't available here.");
1392
+ if (!install) await service.check(options.repo, purpose);
1393
+ const { verifier, challenge } = await newVerifier();
1394
+ pending.save(JSON.stringify({
1395
+ verifier,
1396
+ purpose,
1397
+ remember
1398
+ }), false);
1399
+ location.assign(service.startUrl({
1400
+ purpose,
1401
+ repo: options.repo,
1402
+ back: location.href(),
1403
+ challenge,
1404
+ remember,
1405
+ install
1406
+ }));
1407
+ }
1408
+ /** Connects with a session from the worker, whose tokens renew themselves. */
1409
+ async function connectWorker(issued, remember) {
1410
+ const source = renewingToken(service, issued, (renewed) => save({
1411
+ type: "worker",
1412
+ session: renewed.session
1413
+ }, remember));
1414
+ save({
1415
+ type: "worker",
1416
+ session: issued.session
1417
+ }, remember);
1418
+ const api = {
1419
+ apiUrl,
1420
+ token: source.token,
1421
+ fetch: fetcher
1422
+ };
1423
+ const repo = await githubJson(api, `/repos/${repoPath(options.repo)}`);
1424
+ const { user } = issued;
1425
+ return new GitHubBackend({
1426
+ api,
1427
+ repo: options.repo,
1428
+ branch: options.branch ?? repo.default_branch,
1429
+ user: {
1430
+ login: user.login,
1431
+ name: user.name,
1432
+ avatarUrl: user.avatarUrl
1433
+ },
1434
+ author: {
1435
+ name: user.name || user.login,
1436
+ email: user.email
1437
+ },
1438
+ onSignOut: () => {
1439
+ storage.clear();
1440
+ revokeToken(source.current().token);
1441
+ },
1442
+ ownerTokenLink: {
1443
+ url: links.owner,
1444
+ label: "owner.token.create"
1445
+ },
1446
+ ownerRedirect: (redirectOptions) => redirect("owner", false, redirectOptions)
1447
+ });
1448
+ }
1449
+ /** Revokes a token the worker gave, as signing out does. Never fails: signing out goes ahead regardless. */
1450
+ function revokeToken(token) {
1451
+ githubRequest({
1452
+ apiUrl,
1453
+ token,
1454
+ fetch: fetcher
1455
+ }, "/installation/token", { method: "DELETE" }).catch(() => {});
1456
+ }
1457
+ /** Gives the backend an owner's sign-in that has just come back from GitHub. It's kept in memory only. */
1458
+ async function useOwner(backend, grant, verifier) {
1459
+ const worker = service;
1460
+ try {
1461
+ const source = renewingToken(worker, await worker.session(grant, verifier), () => {});
1462
+ backend.useOwnerSignIn(source.token, () => revokeToken(source.current().token));
1463
+ } catch (error) {
1464
+ backend.ownerSignInFailed(error);
1465
+ }
1466
+ }
1467
+ /** Finishes a sign-in through the worker if the page has just come back from it. */
1468
+ async function completeRedirect() {
1469
+ if (!location) return void 0;
1470
+ const url = new URL(location.href());
1471
+ const grant = url.searchParams.get(GRANT_PARAM);
1472
+ const failed = url.searchParams.get(ERROR_PARAM);
1473
+ if (!grant && !failed) return void 0;
1474
+ url.searchParams.delete(GRANT_PARAM);
1475
+ url.searchParams.delete(ERROR_PARAM);
1476
+ location.replace(url.toString());
1477
+ const saved = pending.load();
1478
+ pending.clear();
1479
+ const redirect = saved ? JSON.parse(saved) : void 0;
1480
+ if (!redirect || !service) throw new SignInError("redirect-failed", "Signing in with GitHub didn't finish in this tab.");
1481
+ if (redirect.purpose === "owner") {
1482
+ const backend = await restoreSaved();
1483
+ if (!backend) throw new SignInError("redirect-failed", "Sign in again, then sign in as an owner.");
1484
+ if (failed) backend.ownerSignInFailed(signInProblem({ problem: failed }));
1485
+ else if (grant) await useOwner(backend, grant, redirect.verifier);
1486
+ return backend;
1487
+ }
1488
+ if (failed || !grant) throw signInProblem({ problem: failed });
1489
+ return connectWorker(await service.session(grant, redirect.verifier), redirect.remember);
1490
+ }
1491
+ async function restoreSaved() {
1492
+ const saved = loadSaved();
1493
+ if (!saved) return null;
1494
+ try {
1495
+ if (saved.type === "token") return await connect(saved.token);
1496
+ if (!service) {
1497
+ storage.clear();
1498
+ return null;
1499
+ }
1500
+ return await connectWorker(await service.token(saved.session), storage.remembered());
1501
+ } catch (error) {
1502
+ if (forgets(error)) storage.clear();
1503
+ throw error;
1504
+ }
1505
+ }
861
1506
  async function connect(token) {
862
1507
  const api = {
863
1508
  apiUrl,
864
1509
  token,
865
- fetch: options.fetch ?? globalThis.fetch.bind(globalThis)
1510
+ fetch: fetcher
866
1511
  };
867
1512
  const user = await githubJson(api, "/user");
868
1513
  let repo;
@@ -886,12 +1531,13 @@ function github(options) {
886
1531
  ownerTokenLink: {
887
1532
  url: links.owner,
888
1533
  label: "owner.token.create"
889
- }
1534
+ },
1535
+ ...service && { ownerRedirect: (redirectOptions) => redirect("owner", false, redirectOptions) }
890
1536
  });
891
1537
  }
892
1538
  return {
893
1539
  name: "GitHub",
894
- canRedirect: false,
1540
+ canRedirect: service !== void 0,
895
1541
  tokenLinks: [{
896
1542
  url: links.fineGrained,
897
1543
  label: "signIn.token.create"
@@ -901,26 +1547,24 @@ function github(options) {
901
1547
  hint: "signIn.token.createBroadHint"
902
1548
  }],
903
1549
  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
- }
1550
+ const redirected = await completeRedirect();
1551
+ if (redirected !== void 0) return redirected;
1552
+ return restoreSaved();
912
1553
  },
913
1554
  async signInWithToken(token, remember) {
914
1555
  const trimmed = token.trim();
915
1556
  if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
916
1557
  const backend = await connect(trimmed);
917
- storage.save(trimmed, remember);
1558
+ save({
1559
+ type: "token",
1560
+ token: trimmed
1561
+ }, remember);
918
1562
  return backend;
919
1563
  },
920
- async startRedirect() {
921
- throw new Error("Signing in by redirect needs a sign-in server, which isn't supported for GitHub yet.");
1564
+ startRedirect(remember, redirectOptions) {
1565
+ return redirect("edit", remember, redirectOptions);
922
1566
  }
923
1567
  };
924
1568
  }
925
1569
  //#endregion
926
- export { GitHubBackend, github, githubPagesRecords, githubSetup, githubSetupTokenLinks, githubTokenLinks };
1570
+ export { GOODFELLOW_SIGN_IN, GitHubBackend, github, githubPagesRecords, githubSetup, githubSetupTokenLinks, githubTokenLinks };