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