@goodfellow-cms/github 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,18 +1,20 @@
1
- import { ConflictError, GitApiError, SignInError, credentialStorage, encodeBase64, encodeBase64Bytes } from "@goodfellow-cms/core";
1
+ import { ConflictError, DomainError, DraftError, EditorsError, GitApiError, SetupError, SignInError, UpdatesError, branchDrafts, buildStepFor, compareReleases, credentialStorage, encodeBase64, encodeBase64Bytes, siteVersion } from "@goodfellow-cms/core";
2
2
  //#region src/api.ts
3
3
  /** Calls the GitHub REST or GraphQL API. Treats 401 as an expired sign-in; other errors become `GitApiError`. */
4
4
  async function githubRequest({ apiUrl, token, fetch }, path, init = {}) {
5
- const response = await fetch(`${apiUrl}${path}`, {
5
+ const send = async (refresh) => fetch(`${apiUrl}${path}`, {
6
6
  method: init.method ?? "GET",
7
7
  headers: {
8
8
  accept: init.accept ?? "application/vnd.github+json",
9
- authorization: `Bearer ${token}`,
9
+ authorization: `Bearer ${typeof token === "string" ? token : await token({ refresh })}`,
10
10
  "x-github-api-version": "2022-11-28",
11
11
  ...init.body !== void 0 && { "content-type": "application/json" }
12
12
  },
13
13
  body: init.body === void 0 ? void 0 : JSON.stringify(init.body),
14
14
  cache: "no-store"
15
15
  });
16
+ let response = await send(false);
17
+ if (response.status === 401 && typeof token !== "string") response = await send(true);
16
18
  if (response.status === 401) throw new SignInError("invalid", "GitHub didn't accept the sign-in. It may have expired.");
17
19
  if (!response.ok && !init.allow?.includes(response.status)) {
18
20
  const body = await response.json().catch(() => ({}));
@@ -28,7 +30,370 @@ function repoPath(repo) {
28
30
  return repo.split("/").map((part) => encodeURIComponent(part)).join("/");
29
31
  }
30
32
  //#endregion
33
+ //#region src/editors.ts
34
+ /** GitHub account names: letters, digits and single hyphens, up to 39 characters. */
35
+ const USERNAME = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i;
36
+ const INVITATION = "invitation:";
37
+ function roleOf(permissions) {
38
+ if (permissions?.admin) return "owner";
39
+ return permissions?.maintain || permissions?.push ? "editor" : "viewer";
40
+ }
41
+ function invitationRole(permission) {
42
+ if (permission === "admin") return "owner";
43
+ return permission === "write" || permission === "maintain" ? "editor" : "viewer";
44
+ }
45
+ function isNotAllowed(error) {
46
+ return error instanceof GitApiError && (error.status === 403 || error.status === 404);
47
+ }
48
+ /**
49
+ * The repository's collaborators. Anyone who can publish can list them, but
50
+ * inviting and removing people needs a token with GitHub's Administration
51
+ * permission, which signing in doesn't ask for: `manage()` gives the owner
52
+ * token when one is in use.
53
+ */
54
+ function githubEditors(read, manage, repo, login) {
55
+ const path = `/repos/${repo}`;
56
+ function managing() {
57
+ const api = manage();
58
+ if (!api) throw new EditorsError("not-allowed", "Changing editors on GitHub needs an owner token.");
59
+ return api;
60
+ }
61
+ async function change(run) {
62
+ try {
63
+ await run(managing());
64
+ } catch (error) {
65
+ if (isNotAllowed(error)) throw new EditorsError("not-allowed", "This token can't change the site's editors.");
66
+ throw error;
67
+ }
68
+ }
69
+ async function collaborators(api) {
70
+ const all = [];
71
+ for (let page = 1; page <= 10; page++) {
72
+ const found = await githubJson(api, `${path}/collaborators?per_page=100&page=${page}`);
73
+ all.push(...found);
74
+ if (found.length < 100) break;
75
+ }
76
+ return all;
77
+ }
78
+ return {
79
+ invitesByEmail: false,
80
+ async list() {
81
+ const api = manage() ?? read;
82
+ const people = await collaborators(api);
83
+ let invitations;
84
+ try {
85
+ invitations = await githubJson(api, `${path}/invitations?per_page=100`);
86
+ } catch (error) {
87
+ if (!isNotAllowed(error)) throw error;
88
+ }
89
+ const editors = [...people.map((person) => ({
90
+ id: person.login,
91
+ login: person.login,
92
+ avatarUrl: person.avatar_url,
93
+ role: roleOf(person.permissions),
94
+ ...person.login.toLowerCase() === login.toLowerCase() && { self: true }
95
+ })), ...(invitations ?? []).flatMap((invitation) => invitation.invitee ? [{
96
+ id: `${INVITATION}${invitation.id}`,
97
+ login: invitation.invitee.login,
98
+ avatarUrl: invitation.invitee.avatar_url,
99
+ role: invitationRole(invitation.permissions),
100
+ invited: true
101
+ }] : [])];
102
+ const owner = editors.some((editor) => editor.self && editor.role === "owner");
103
+ return {
104
+ editors,
105
+ manage: invitations ? "yes" : owner ? "token" : "no",
106
+ editorsCanPublish: true
107
+ };
108
+ },
109
+ async invite(who, role) {
110
+ const name = who.trim().replace(/^@/, "");
111
+ if (!USERNAME.test(name)) throw new EditorsError("invalid", "That isn't a GitHub username.");
112
+ const api = managing();
113
+ try {
114
+ await githubRequest(api, `${path}/collaborators/${encodeURIComponent(name)}`, {
115
+ method: "PUT",
116
+ body: { permission: role === "owner" ? "admin" : "push" }
117
+ });
118
+ } catch (error) {
119
+ if (error instanceof GitApiError && error.status === 404) {
120
+ if ((await githubRequest(api, `/users/${encodeURIComponent(name)}`, { allow: [404] })).status === 404) throw new EditorsError("not-found", `There's no GitHub account named ${name}.`);
121
+ }
122
+ if (isNotAllowed(error)) throw new EditorsError("not-allowed", "This token can't change the site's editors.");
123
+ if (error instanceof GitApiError && error.status === 422) throw new EditorsError("invalid", `GitHub didn't accept the invitation: ${error.message}`);
124
+ throw error;
125
+ }
126
+ },
127
+ async setRole(editor, role) {
128
+ await change(async (api) => {
129
+ if (editor.id.startsWith(INVITATION)) await githubRequest(api, `${path}/invitations/${editor.id.slice(11)}`, {
130
+ method: "PATCH",
131
+ body: { permissions: role === "owner" ? "admin" : "write" }
132
+ });
133
+ else await githubRequest(api, `${path}/collaborators/${encodeURIComponent(editor.id)}`, {
134
+ method: "PUT",
135
+ body: { permission: role === "owner" ? "admin" : "push" }
136
+ });
137
+ });
138
+ },
139
+ async remove(editor) {
140
+ await change((api) => githubRequest(api, editor.id.startsWith(INVITATION) ? `${path}/invitations/${editor.id.slice(11)}` : `${path}/collaborators/${encodeURIComponent(editor.id)}`, { method: "DELETE" }));
141
+ }
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) {
150
+ let owner;
151
+ let endSignIn;
152
+ let problem;
153
+ return {
154
+ access: {
155
+ tokenLink,
156
+ get active() {
157
+ return owner !== void 0;
158
+ },
159
+ get problem() {
160
+ return problem;
161
+ },
162
+ ...redirect && { async startRedirect(options) {
163
+ problem = void 0;
164
+ await redirect(options);
165
+ } },
166
+ async applyToken(token) {
167
+ const api = {
168
+ ...base,
169
+ token: token.trim()
170
+ };
171
+ try {
172
+ if (!(await githubJson(api, `/repos/${repo}`)).permissions?.admin) throw new EditorsError("not-allowed", "This token's account isn't an owner of the site.");
173
+ await githubJson(api, `/repos/${repo}/invitations?per_page=1`);
174
+ } catch (error) {
175
+ if (error instanceof EditorsError) throw error;
176
+ if (error instanceof SignInError || isNotAllowed(error)) throw new EditorsError("not-allowed", "This token can't change the site's editors.");
177
+ throw error;
178
+ }
179
+ owner = api;
180
+ problem = void 0;
181
+ },
182
+ forget() {
183
+ owner = void 0;
184
+ endSignIn?.();
185
+ endSignIn = void 0;
186
+ }
187
+ },
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
+ }
202
+ };
203
+ }
204
+ //#endregion
205
+ //#region src/pages.ts
206
+ /** GitHub Pages' addresses for an apex domain, from GitHub's documentation. */
207
+ const PAGES_IPV4 = [
208
+ "185.199.108.153",
209
+ "185.199.109.153",
210
+ "185.199.110.153",
211
+ "185.199.111.153"
212
+ ];
213
+ const PAGES_IPV6 = [
214
+ "2606:50c0:8000::153",
215
+ "2606:50c0:8001::153",
216
+ "2606:50c0:8002::153",
217
+ "2606:50c0:8003::153"
218
+ ];
219
+ /** Certificate states in which GitHub serves the domain over HTTPS. */
220
+ const READY = /* @__PURE__ */ new Set([
221
+ "approved",
222
+ "issued",
223
+ "uploaded"
224
+ ]);
225
+ const FAILED = /* @__PURE__ */ new Set([
226
+ "errored",
227
+ "bad_authz",
228
+ "authorization_revoked"
229
+ ]);
230
+ /** The records GitHub Pages needs: its addresses for an apex domain, or an alias of the owner's github.io. */
231
+ function githubPagesRecords(repo, domain, { apex, www }) {
232
+ const target = `${(repo.split("/")[0] ?? "").toLowerCase()}.github.io`;
233
+ if (!apex) return [{
234
+ type: "CNAME",
235
+ name: domain,
236
+ value: target,
237
+ purpose: "site"
238
+ }];
239
+ return [
240
+ ...PAGES_IPV4.map((value) => ({
241
+ type: "A",
242
+ name: domain,
243
+ value,
244
+ purpose: "site"
245
+ })),
246
+ ...PAGES_IPV6.map((value) => ({
247
+ type: "AAAA",
248
+ name: domain,
249
+ value,
250
+ purpose: "site",
251
+ optional: true
252
+ })),
253
+ ...www ? [{
254
+ type: "CNAME",
255
+ name: `www.${domain}`,
256
+ value: target,
257
+ purpose: "www",
258
+ optional: true
259
+ }] : []
260
+ ];
261
+ }
262
+ /**
263
+ * GitHub Pages, for a site's repository: where it's published, and its custom
264
+ * domain. `api` gives the owner token when one is in use (see `ownerAccess`).
265
+ */
266
+ function githubPages(api, repo) {
267
+ const path = `/repos/${repo}/pages`;
268
+ async function pages() {
269
+ const response = await githubRequest(api(), path, { allow: [404] });
270
+ return response.status === 404 ? void 0 : await response.json();
271
+ }
272
+ async function update(body) {
273
+ try {
274
+ await githubRequest(api(), path, {
275
+ method: "PUT",
276
+ body
277
+ });
278
+ } catch (error) {
279
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new DomainError("not-allowed", "This sign-in can't change the site's GitHub Pages settings.");
280
+ if (error instanceof GitApiError && (error.status === 400 || error.status === 422)) {
281
+ if (/taken|already/i.test(error.message)) throw new DomainError("taken", "Another GitHub Pages site already uses this domain.");
282
+ if ("https_enforced" in body) throw new DomainError("not-ready", "The domain's certificate isn't ready yet.");
283
+ throw new DomainError("invalid", "GitHub Pages doesn't accept this domain.");
284
+ }
285
+ throw error;
286
+ }
287
+ }
288
+ return {
289
+ async site() {
290
+ const found = await pages();
291
+ return found && {
292
+ url: found.html_url,
293
+ domain: found.cname ?? void 0
294
+ };
295
+ },
296
+ async connect(domain, options) {
297
+ await update({ cname: domain });
298
+ return githubPagesRecords(repo, domain, options);
299
+ },
300
+ async records(domain, options) {
301
+ return githubPagesRecords(repo, domain, options);
302
+ },
303
+ async status(domain) {
304
+ const found = await githubJson(api(), path);
305
+ const state = found.https_certificate?.state ?? "";
306
+ return {
307
+ verified: true,
308
+ certificate: READY.has(state) ? "ready" : FAILED.has(state) ? "failed" : "pending",
309
+ httpsOnly: found.https_enforced === true,
310
+ primary: found.cname === domain
311
+ };
312
+ },
313
+ async secure() {
314
+ await update({ https_enforced: true });
315
+ },
316
+ async disconnect() {
317
+ await update({ cname: null });
318
+ }
319
+ };
320
+ }
321
+ //#endregion
322
+ //#region src/updates.ts
323
+ /** The starters' GitHub Pages workflow, which runs `goodfellow update` each night and when it's dispatched. */
324
+ const WORKFLOW = "deploy.yml";
325
+ const WORKFLOW_FILE = `.github/workflows/${WORKFLOW}`;
326
+ /**
327
+ * The site's nightly update, which runs in its GitHub Pages workflow. Running
328
+ * it now dispatches the workflow, which needs GitHub's Actions permission
329
+ * with write access: `manage()` gives the owner token when one is in use.
330
+ */
331
+ function githubUpdates(read, manage, repo, branch, files) {
332
+ const path = `/repos/${repo}/actions/workflows/${WORKFLOW}`;
333
+ return {
334
+ async setup() {
335
+ return (await files.read(WORKFLOW_FILE))?.includes("update --publish") ? "ready" : "missing";
336
+ },
337
+ async lastRun() {
338
+ const response = await githubRequest(read, `${path}/runs?branch=${encodeURIComponent(branch)}&per_page=20`, { allow: [403, 404] });
339
+ if (!response.ok) return void 0;
340
+ const { workflow_runs: runs = [] } = await response.json();
341
+ for (const run of runs.filter((candidate) => ["schedule", "workflow_dispatch"].includes(candidate.event))) {
342
+ const base = {
343
+ date: run.created_at,
344
+ detailsUrl: run.html_url
345
+ };
346
+ if (run.status !== "completed") return {
347
+ state: "running",
348
+ ...base
349
+ };
350
+ const { jobs = [] } = await githubJson(read, `/repos/${repo}/actions/runs/${run.id}/jobs`);
351
+ const job = jobs.find((candidate) => candidate.name === "update");
352
+ if (!job || job.conclusion === "skipped") continue;
353
+ if (job.conclusion !== "success") return {
354
+ state: "failed",
355
+ ...base
356
+ };
357
+ const before = siteVersion(await files.readAt("package.json", run.head_sha) ?? "");
358
+ const now = siteVersion(await files.read("package.json") ?? "");
359
+ if (!before || !now || before === now) return {
360
+ state: "up-to-date",
361
+ ...base
362
+ };
363
+ const rollback = compareReleases(now, before) < 0;
364
+ return {
365
+ state: "updated",
366
+ ...base,
367
+ result: {
368
+ state: "updated",
369
+ from: before,
370
+ to: now,
371
+ ...rollback && { rollback }
372
+ }
373
+ };
374
+ }
375
+ },
376
+ async start(target) {
377
+ try {
378
+ await githubRequest(manage() ?? read, `${path}/dispatches`, {
379
+ method: "POST",
380
+ body: {
381
+ ref: branch,
382
+ inputs: { update: target }
383
+ }
384
+ });
385
+ } catch (error) {
386
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 401)) throw new UpdatesError("not-allowed", "Running the update needs a token that can run the site's workflows.");
387
+ if (error instanceof GitApiError && (error.status === 404 || error.status === 422)) throw new UpdatesError("missing", "The site's GitHub Pages workflow doesn't update Goodfellow.");
388
+ throw error;
389
+ }
390
+ }
391
+ };
392
+ }
393
+ //#endregion
31
394
  //#region src/backend.ts
395
+ /** How many files one GraphQL request reads. */
396
+ const GRAPHQL_BATCH = 100;
32
397
  const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
33
398
  createCommitOnBranch(input: $input) { commit { oid } }
34
399
  }`;
@@ -37,21 +402,99 @@ const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
37
402
  * returned; saves are single commits made with GraphQL's `createCommitOnBranch`,
38
403
  * which fails if the branch has moved, so nothing is ever overwritten.
39
404
  */
40
- var GitHubBackend = class {
405
+ var GitHubBackend = class GitHubBackend {
41
406
  user;
407
+ pages;
408
+ editors;
409
+ ownerAccess;
410
+ updates;
411
+ drafts;
412
+ options;
42
413
  api;
43
414
  repo;
44
415
  branch;
45
416
  onSignOut;
417
+ author;
418
+ owner;
46
419
  pinned;
47
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();
48
423
  blobs = /* @__PURE__ */ new Map();
49
424
  constructor(options) {
425
+ this.options = options;
50
426
  this.api = options.api;
51
427
  this.repo = repoPath(options.repo);
52
428
  this.branch = options.branch;
53
429
  this.user = options.user;
54
430
  this.onSignOut = options.onSignOut;
431
+ this.author = options.author;
432
+ const owner = githubOwnerAccess(this.api, this.repo, options.ownerTokenLink, options.ownerRedirect);
433
+ this.owner = owner;
434
+ this.ownerAccess = owner.access;
435
+ this.pages = githubPages(() => owner.api() ?? this.api, this.repo);
436
+ this.editors = githubEditors(this.api, owner.api, this.repo, this.user.login);
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
+ };
55
498
  }
56
499
  async revision() {
57
500
  const ref = await githubJson(this.api, `/repos/${this.repo}/git/ref/heads/${encodeURIComponent(this.branch)}`);
@@ -64,7 +507,9 @@ var GitHubBackend = class {
64
507
  if (!tree) {
65
508
  tree = githubJson(this.api, `/repos/${this.repo}/git/trees/${sha}?recursive=1`).then((response) => {
66
509
  if (response.truncated) throw new GitApiError(413, "The repository has too many files for GitHub to list at once.");
67
- return new Map(response.tree.filter((entry) => entry.type === "blob").map((entry) => [entry.path, entry.sha]));
510
+ const blobs = response.tree.filter((entry) => entry.type === "blob");
511
+ this.treeSizes.set(sha, new Map(blobs.flatMap((entry) => entry.size === void 0 ? [] : [[entry.path, entry.size]])));
512
+ return new Map(blobs.map((entry) => [entry.path, entry.sha]));
68
513
  });
69
514
  tree.catch(() => this.trees.delete(sha));
70
515
  this.trees.set(sha, tree);
@@ -75,8 +520,11 @@ var GitHubBackend = class {
75
520
  return this.tree(this.pinned ?? await this.revision());
76
521
  }
77
522
  async read(path) {
78
- const sha = (await this.currentTree()).get(path);
79
- if (!sha) return void 0;
523
+ return this.readFrom(await this.currentTree(), path);
524
+ }
525
+ readFrom(tree, path) {
526
+ const sha = tree.get(path);
527
+ if (!sha) return Promise.resolve(void 0);
80
528
  let blob = this.blobs.get(sha);
81
529
  if (!blob) {
82
530
  blob = githubRequest(this.api, `/repos/${this.repo}/git/blobs/${sha}`, { accept: "application/vnd.github.raw+json" }).then((response) => response.text());
@@ -85,6 +533,40 @@ var GitHubBackend = class {
85
533
  }
86
534
  return blob;
87
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
+ }
88
570
  async readBytes(path) {
89
571
  const sha = (await this.currentTree()).get(path);
90
572
  if (!sha) return void 0;
@@ -95,7 +577,100 @@ var GitHubBackend = class {
95
577
  const prefix = `${dir}/`;
96
578
  return [...(await this.currentTree()).keys()].filter((path) => path.startsWith(prefix)).sort();
97
579
  }
98
- async write(changes, { message, expectedRevision }) {
580
+ async fileSizes(dir) {
581
+ const prefix = `${dir}/`;
582
+ await this.currentTree();
583
+ const sizes = this.treeSizes.get(this.pinned ?? "") ?? /* @__PURE__ */ new Map();
584
+ return new Map([...sizes].filter(([path]) => path.startsWith(prefix)));
585
+ }
586
+ /** Uses an owner's sign-in through the worker for editors and GitHub Pages settings, until it's forgotten. */
587
+ useOwnerSignIn(token, end) {
588
+ this.owner.useSignIn(token, end);
589
+ }
590
+ /** Remembers why an owner's sign-in through the worker didn't work, which the Editors and Domain screens show. */
591
+ ownerSignInFailed(error) {
592
+ this.owner.failed(error);
593
+ }
594
+ async write(changes, options) {
595
+ return this.author ? this.writeCommit(changes, options, this.author) : this.writeGraphQL(changes, options);
596
+ }
597
+ /**
598
+ * Saves with Git's data API, naming the editor as the commit's author:
599
+ * GitHub refuses to move the branch unless the commit follows on from it, so
600
+ * nothing is overwritten.
601
+ */
602
+ async writeCommit(changes, { message, expectedRevision }, author) {
603
+ const existing = await this.tree(expectedRevision);
604
+ const base = `/repos/${this.repo}`;
605
+ const entries = [];
606
+ try {
607
+ for (const change of changes) if ("delete" in change) {
608
+ if (existing.has(change.path)) entries.push({
609
+ path: change.path,
610
+ mode: "100644",
611
+ type: "blob",
612
+ sha: null
613
+ });
614
+ } else if ("bytes" in change) {
615
+ const blob = await githubJson(this.api, `${base}/git/blobs`, {
616
+ method: "POST",
617
+ body: {
618
+ content: encodeBase64Bytes(change.bytes),
619
+ encoding: "base64"
620
+ }
621
+ });
622
+ entries.push({
623
+ path: change.path,
624
+ mode: "100644",
625
+ type: "blob",
626
+ sha: blob.sha
627
+ });
628
+ } else entries.push({
629
+ path: change.path,
630
+ mode: "100644",
631
+ type: "blob",
632
+ content: change.content
633
+ });
634
+ if (entries.length === 0) return { revision: expectedRevision };
635
+ const parent = await githubJson(this.api, `${base}/git/commits/${expectedRevision}`);
636
+ const tree = await githubJson(this.api, `${base}/git/trees`, {
637
+ method: "POST",
638
+ body: {
639
+ base_tree: parent.tree.sha,
640
+ tree: entries
641
+ }
642
+ });
643
+ const commit = await githubJson(this.api, `${base}/git/commits`, {
644
+ method: "POST",
645
+ body: {
646
+ message,
647
+ tree: tree.sha,
648
+ parents: [expectedRevision],
649
+ author: {
650
+ ...author,
651
+ date: (/* @__PURE__ */ new Date()).toISOString()
652
+ }
653
+ }
654
+ });
655
+ if ((await githubRequest(this.api, `${base}/git/refs/heads/${encodeURIComponent(this.branch)}`, {
656
+ method: "PATCH",
657
+ body: {
658
+ sha: commit.sha,
659
+ force: false
660
+ },
661
+ allow: [422]
662
+ })).status === 422) {
663
+ if (await this.revision() !== expectedRevision) throw new ConflictError();
664
+ throw new GitApiError(422, "GitHub couldn't save the changes.");
665
+ }
666
+ this.pinned = commit.sha;
667
+ return { revision: commit.sha };
668
+ } catch (error) {
669
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SignInError("cant-publish", "This sign-in can't publish to the site.");
670
+ throw error;
671
+ }
672
+ }
673
+ async writeGraphQL(changes, { message, expectedRevision }) {
99
674
  const existing = await this.tree(expectedRevision);
100
675
  const additions = changes.flatMap((change) => "delete" in change ? [] : [{
101
676
  path: change.path,
@@ -131,68 +706,655 @@ var GitHubBackend = class {
131
706
  if (error?.type === "FORBIDDEN") throw new SignInError("cant-publish", "This GitHub account can't publish to the site.");
132
707
  throw new GitApiError(422, `GitHub couldn't save the changes: ${error?.message ?? "unknown error"}`);
133
708
  }
709
+ async history(path, { page = 1, perPage = 20 } = {}) {
710
+ const query = new URLSearchParams({
711
+ sha: this.pinned ?? await this.revision(),
712
+ path,
713
+ per_page: String(perPage),
714
+ page: String(page)
715
+ });
716
+ return (await githubJson(this.api, `/repos/${this.repo}/commits?${query}`)).map((commit) => ({
717
+ revision: commit.sha,
718
+ date: commit.commit.author?.date ?? "",
719
+ author: commit.commit.author?.name || commit.author?.login || void 0,
720
+ message: commit.commit.message.split("\n")[0] ?? ""
721
+ }));
722
+ }
723
+ async readAt(path, revision) {
724
+ return this.readFrom(await this.tree(revision), path);
725
+ }
134
726
  async changedPaths(from, to) {
135
727
  const comparison = await githubJson(this.api, `/repos/${this.repo}/compare/${from}...${to}`);
136
728
  return [...new Set((comparison.files ?? []).flatMap((file) => [file.filename, file.previous_filename ?? []].flat()))];
137
729
  }
138
730
  /**
139
- * Reads the deployment GitHub Pages or Vercel created for a commit. Before
140
- * one exists, the site counts as building if it has ever been deployed.
731
+ * Reads the deployment GitHub Pages or Vercel created for a commit, and for
732
+ * GitHub Actions, the workflow run that builds it: a build that fails never
733
+ * creates a deployment. Before either exists, the site counts as building
734
+ * if it has ever been deployed.
141
735
  */
142
736
  async deployStatus(revision) {
143
737
  try {
144
738
  const latest = (await githubJson(this.api, `/repos/${this.repo}/deployments?sha=${revision}&per_page=5`))[0];
145
- if (!latest) return { state: (await githubJson(this.api, `/repos/${this.repo}/deployments?per_page=1`)).length > 0 ? "building" : "unknown" };
146
- const [status] = await githubJson(this.api, `/repos/${this.repo}/deployments/${latest.id}/statuses?per_page=1`);
147
- const detailsUrl = status?.log_url || status?.target_url || void 0;
148
- switch (status?.state) {
149
- case "success":
150
- case "inactive": return {
151
- state: "live",
152
- detailsUrl
153
- };
154
- case "failure":
155
- case "error": return {
156
- state: "failed",
157
- detailsUrl
158
- };
159
- default: return {
160
- state: "building",
161
- detailsUrl
162
- };
739
+ if (latest) {
740
+ const [status] = await githubJson(this.api, `/repos/${this.repo}/deployments/${latest.id}/statuses?per_page=1`);
741
+ const detailsUrl = status?.log_url || status?.target_url || void 0;
742
+ switch (status?.state) {
743
+ case "success":
744
+ case "inactive": return {
745
+ state: "live",
746
+ detailsUrl
747
+ };
748
+ case "failure":
749
+ case "error": {
750
+ const run = await this.workflowRun(revision);
751
+ return {
752
+ state: "failed",
753
+ detailsUrl,
754
+ problem: run ? await this.runProblem(run) : { step: "deploy" }
755
+ };
756
+ }
757
+ default: return {
758
+ state: "building",
759
+ detailsUrl
760
+ };
761
+ }
163
762
  }
763
+ const run = await this.workflowRun(revision);
764
+ if (run?.status === "completed" && run.conclusion !== "success" && run.conclusion !== "skipped") return {
765
+ state: "failed",
766
+ detailsUrl: run.html_url,
767
+ problem: await this.runProblem(run)
768
+ };
769
+ if (run) return {
770
+ state: "building",
771
+ detailsUrl: run.html_url
772
+ };
773
+ return { state: (await githubJson(this.api, `/repos/${this.repo}/deployments?per_page=1`)).length > 0 ? "building" : "unknown" };
164
774
  } catch (error) {
165
775
  if (error instanceof GitApiError) return { state: "unknown" };
166
776
  throw error;
167
777
  }
168
778
  }
779
+ /** The site's deploy workflow run for a commit, or `undefined` if there's none or the token can't see Actions. */
780
+ async workflowRun(revision) {
781
+ const response = await githubRequest(this.api, `/repos/${this.repo}/actions/runs?head_sha=${revision}&per_page=20`, { allow: [403, 404] });
782
+ if (!response.ok) return void 0;
783
+ const { workflow_runs: runs = [] } = await response.json();
784
+ return runs.find((run) => /deploy|pages/i.test(`${run.path ?? ""} ${run.name ?? ""}`)) ?? runs[0];
785
+ }
786
+ /** Which step of a failed run failed. */
787
+ async runProblem(run) {
788
+ const response = await githubRequest(this.api, `/repos/${this.repo}/actions/runs/${run.id}/jobs?per_page=50`, { allow: [403, 404] });
789
+ const failed = (response.ok ? (await response.json()).jobs ?? [] : []).find((job) => job.conclusion === "failure");
790
+ if (!failed) return {
791
+ step: run.conclusion === "startup_failure" ? "not-started" : "host",
792
+ detail: run.conclusion ?? void 0
793
+ };
794
+ const step = failed.steps?.find((candidate) => candidate.conclusion === "failure");
795
+ if (!step) return {
796
+ step: "not-started",
797
+ detail: failed.name
798
+ };
799
+ return {
800
+ step: buildStepFor(step.name) ?? (/deploy/i.test(failed.name) ? "deploy" : "build"),
801
+ detail: `${failed.name}: ${step.name}`
802
+ };
803
+ }
169
804
  signOut() {
805
+ this.ownerAccess.forget();
170
806
  this.onSignOut();
171
807
  }
172
808
  };
173
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
968
+ //#region src/setup.ts
969
+ /** The branch the deploy setups build from. */
970
+ const MAIN = "main";
971
+ /**
972
+ * Links to GitHub's token pages with what creating a site needs: creating the
973
+ * repository (administration), writing its files and its build setup
974
+ * (contents and workflows), and turning on GitHub Pages (pages).
975
+ */
976
+ function githubSetupTokenLinks(webUrl = "https://github.com") {
977
+ const fineGrained = new URLSearchParams({
978
+ name: "Goodfellow site setup",
979
+ description: "Create a Goodfellow site. It can be deleted once the site is created.",
980
+ expires_in: "7",
981
+ administration: "write",
982
+ contents: "write",
983
+ workflows: "write",
984
+ pages: "write"
985
+ });
986
+ const classic = new URLSearchParams({
987
+ scopes: "repo,workflow",
988
+ description: "Goodfellow site setup"
989
+ });
990
+ return {
991
+ fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
992
+ classic: `${webUrl}/settings/tokens/new?${classic}`
993
+ };
994
+ }
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() {
1156
+ return [{
1157
+ path: user.login,
1158
+ name: user.name || user.login,
1159
+ kind: "personal"
1160
+ }, ...(await githubJson(api, "/user/orgs?per_page=100").catch(() => [])).map((org) => ({
1161
+ path: org.login,
1162
+ name: org.login,
1163
+ kind: "organization"
1164
+ }))];
1165
+ },
1166
+ isAvailable,
1167
+ async create(site) {
1168
+ const path = site.owner.kind === "personal" ? "/user/repos" : `/orgs/${encodeURIComponent(site.owner.path)}/repos`;
1169
+ if (!await isAvailable(site.owner, site.name)) throw new SetupError("name-taken", `There's already a repository called ${site.owner.path}/${site.name}.`);
1170
+ try {
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
+ };
1183
+ } catch (error) {
1184
+ if (error instanceof GitApiError && error.status === 422) throw new SetupError("name-invalid", `GitHub can't use "${site.name}" as a repository name.`);
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}.`);
1186
+ throw error;
1187
+ }
1188
+ }
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
1237
+ });
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
1245
+ },
1246
+ ...created.editInstallUrl && { signInInstallUrl: created.editInstallUrl }
1247
+ };
1248
+ }
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);
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);
1270
+ return {
1271
+ name: "GitHub",
1272
+ canRedirect: service !== void 0,
1273
+ tokenLinks: [{
1274
+ url: links.fineGrained,
1275
+ label: "signIn.token.create"
1276
+ }, {
1277
+ url: links.classic,
1278
+ label: "signIn.token.createBroad",
1279
+ hint: "signIn.token.createBroadHint"
1280
+ }],
1281
+ async restore() {
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));
1295
+ },
1296
+ async signInWithToken(token) {
1297
+ const trimmed = token.trim();
1298
+ if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
1299
+ return setupAccount(await tokenCreator({
1300
+ apiUrl,
1301
+ token: trimmed,
1302
+ fetch: fetcher
1303
+ }));
1304
+ },
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
+ }));
1317
+ }
1318
+ };
1319
+ }
1320
+ //#endregion
174
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
+ }
175
1326
  /**
176
1327
  * Links to GitHub's token page with everything filled in except the
177
1328
  * repository, which GitHub doesn't let links choose.
178
1329
  */
179
1330
  function githubTokenLinks(repo, webUrl = "https://github.com") {
180
- const [owner = ""] = repo.split("/");
1331
+ const [repoOwner = ""] = repo.split("/");
181
1332
  const fineGrained = new URLSearchParams({
182
1333
  name: "Goodfellow admin",
183
1334
  description: `Edit ${repo} in its admin panel`,
184
- target_name: owner,
1335
+ target_name: repoOwner,
185
1336
  expires_in: "90",
186
1337
  contents: "write",
187
- deployments: "read"
1338
+ deployments: "read",
1339
+ actions: "read"
188
1340
  });
189
1341
  const classic = new URLSearchParams({
190
1342
  scopes: "repo",
191
1343
  description: `Goodfellow admin for ${repo}`
192
1344
  });
1345
+ const owner = new URLSearchParams({
1346
+ name: "Goodfellow owner",
1347
+ description: `Manage the editors, domain and updates of ${repo}`,
1348
+ target_name: repoOwner,
1349
+ expires_in: "1",
1350
+ administration: "write",
1351
+ pages: "write",
1352
+ actions: "write"
1353
+ });
193
1354
  return {
194
1355
  fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
195
- classic: `${webUrl}/settings/tokens/new?${classic}`
1356
+ classic: `${webUrl}/settings/tokens/new?${classic}`,
1357
+ owner: `${webUrl}/settings/personal-access-tokens/new?${owner}`
196
1358
  };
197
1359
  }
198
1360
  /** Sign-in and storage for a site whose repository is on GitHub. */
@@ -201,11 +1363,151 @@ function github(options) {
201
1363
  const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
202
1364
  const links = githubTokenLinks(options.repo, options.webUrl);
203
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
+ }
204
1506
  async function connect(token) {
205
1507
  const api = {
206
1508
  apiUrl,
207
1509
  token,
208
- fetch: options.fetch ?? globalThis.fetch.bind(globalThis)
1510
+ fetch: fetcher
209
1511
  };
210
1512
  const user = await githubJson(api, "/user");
211
1513
  let repo;
@@ -225,12 +1527,17 @@ function github(options) {
225
1527
  name: user.name ?? void 0,
226
1528
  avatarUrl: user.avatar_url
227
1529
  },
228
- onSignOut: () => storage.clear()
1530
+ onSignOut: () => storage.clear(),
1531
+ ownerTokenLink: {
1532
+ url: links.owner,
1533
+ label: "owner.token.create"
1534
+ },
1535
+ ...service && { ownerRedirect: (redirectOptions) => redirect("owner", false, redirectOptions) }
229
1536
  });
230
1537
  }
231
1538
  return {
232
1539
  name: "GitHub",
233
- canRedirect: false,
1540
+ canRedirect: service !== void 0,
234
1541
  tokenLinks: [{
235
1542
  url: links.fineGrained,
236
1543
  label: "signIn.token.create"
@@ -240,26 +1547,24 @@ function github(options) {
240
1547
  hint: "signIn.token.createBroadHint"
241
1548
  }],
242
1549
  async restore() {
243
- const token = storage.load();
244
- if (!token) return null;
245
- try {
246
- return await connect(token);
247
- } catch (error) {
248
- if (error instanceof SignInError) storage.clear();
249
- throw error;
250
- }
1550
+ const redirected = await completeRedirect();
1551
+ if (redirected !== void 0) return redirected;
1552
+ return restoreSaved();
251
1553
  },
252
1554
  async signInWithToken(token, remember) {
253
1555
  const trimmed = token.trim();
254
1556
  if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
255
1557
  const backend = await connect(trimmed);
256
- storage.save(trimmed, remember);
1558
+ save({
1559
+ type: "token",
1560
+ token: trimmed
1561
+ }, remember);
257
1562
  return backend;
258
1563
  },
259
- async startRedirect() {
260
- throw new Error("Signing in by redirect needs a sign-in server, which isn't supported for GitHub yet.");
1564
+ startRedirect(remember, redirectOptions) {
1565
+ return redirect("edit", remember, redirectOptions);
261
1566
  }
262
1567
  };
263
1568
  }
264
1569
  //#endregion
265
- export { GitHubBackend, github, githubTokenLinks };
1570
+ export { GOODFELLOW_SIGN_IN, GitHubBackend, github, githubPagesRecords, githubSetup, githubSetupTokenLinks, githubTokenLinks };