@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.d.ts +70 -4
- package/dist/index.js +841 -197
- package/dist/testing.d.ts +51 -1
- package/dist/testing.js +468 -50
- package/package.json +2 -2
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
|
|
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
|
-
/**
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
607
|
-
function
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
630
|
-
|
|
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
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
404,
|
|
757
|
-
422
|
|
758
|
-
]
|
|
759
|
-
})).ok;
|
|
1246
|
+
...created.editInstallUrl && { signInInstallUrl: created.editInstallUrl }
|
|
1247
|
+
};
|
|
760
1248
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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:
|
|
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
|
|
1299
|
+
return setupAccount(await tokenCreator({
|
|
1300
|
+
apiUrl,
|
|
1301
|
+
token: trimmed,
|
|
1302
|
+
fetch: fetcher
|
|
1303
|
+
}));
|
|
813
1304
|
},
|
|
814
|
-
async startRedirect() {
|
|
815
|
-
throw new
|
|
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:
|
|
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:
|
|
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
|
|
905
|
-
if (
|
|
906
|
-
|
|
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
|
-
|
|
1558
|
+
save({
|
|
1559
|
+
type: "token",
|
|
1560
|
+
token: trimmed
|
|
1561
|
+
}, remember);
|
|
918
1562
|
return backend;
|
|
919
1563
|
},
|
|
920
|
-
|
|
921
|
-
|
|
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 };
|