@goodfellow-cms/github 0.1.0 → 0.2.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,4 +1,4 @@
1
- import { ConflictError, GitApiError, SignInError, credentialStorage, encodeBase64, encodeBase64Bytes } from "@goodfellow-cms/core";
1
+ import { ConflictError, DomainError, EditorsError, GitApiError, SetupError, SignInError, UpdatesError, 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 = {}) {
@@ -28,6 +28,338 @@ function repoPath(repo) {
28
28
  return repo.split("/").map((part) => encodeURIComponent(part)).join("/");
29
29
  }
30
30
  //#endregion
31
+ //#region src/editors.ts
32
+ /** GitHub account names: letters, digits and single hyphens, up to 39 characters. */
33
+ const USERNAME = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i;
34
+ const INVITATION = "invitation:";
35
+ function roleOf(permissions) {
36
+ if (permissions?.admin) return "owner";
37
+ return permissions?.maintain || permissions?.push ? "editor" : "viewer";
38
+ }
39
+ function invitationRole(permission) {
40
+ if (permission === "admin") return "owner";
41
+ return permission === "write" || permission === "maintain" ? "editor" : "viewer";
42
+ }
43
+ function isNotAllowed(error) {
44
+ return error instanceof GitApiError && (error.status === 403 || error.status === 404);
45
+ }
46
+ /**
47
+ * The repository's collaborators. Anyone who can publish can list them, but
48
+ * inviting and removing people needs a token with GitHub's Administration
49
+ * permission, which signing in doesn't ask for: `manage()` gives the owner
50
+ * token when one is in use.
51
+ */
52
+ function githubEditors(read, manage, repo, login) {
53
+ const path = `/repos/${repo}`;
54
+ function managing() {
55
+ const api = manage();
56
+ if (!api) throw new EditorsError("not-allowed", "Changing editors on GitHub needs an owner token.");
57
+ return api;
58
+ }
59
+ async function change(run) {
60
+ try {
61
+ await run(managing());
62
+ } catch (error) {
63
+ if (isNotAllowed(error)) throw new EditorsError("not-allowed", "This token can't change the site's editors.");
64
+ throw error;
65
+ }
66
+ }
67
+ async function collaborators(api) {
68
+ const all = [];
69
+ for (let page = 1; page <= 10; page++) {
70
+ const found = await githubJson(api, `${path}/collaborators?per_page=100&page=${page}`);
71
+ all.push(...found);
72
+ if (found.length < 100) break;
73
+ }
74
+ return all;
75
+ }
76
+ return {
77
+ invitesByEmail: false,
78
+ async list() {
79
+ const api = manage() ?? read;
80
+ const people = await collaborators(api);
81
+ let invitations;
82
+ try {
83
+ invitations = await githubJson(api, `${path}/invitations?per_page=100`);
84
+ } catch (error) {
85
+ if (!isNotAllowed(error)) throw error;
86
+ }
87
+ const editors = [...people.map((person) => ({
88
+ id: person.login,
89
+ login: person.login,
90
+ avatarUrl: person.avatar_url,
91
+ role: roleOf(person.permissions),
92
+ ...person.login.toLowerCase() === login.toLowerCase() && { self: true }
93
+ })), ...(invitations ?? []).flatMap((invitation) => invitation.invitee ? [{
94
+ id: `${INVITATION}${invitation.id}`,
95
+ login: invitation.invitee.login,
96
+ avatarUrl: invitation.invitee.avatar_url,
97
+ role: invitationRole(invitation.permissions),
98
+ invited: true
99
+ }] : [])];
100
+ const owner = editors.some((editor) => editor.self && editor.role === "owner");
101
+ return {
102
+ editors,
103
+ manage: invitations ? "yes" : owner ? "token" : "no",
104
+ editorsCanPublish: true
105
+ };
106
+ },
107
+ async invite(who, role) {
108
+ const name = who.trim().replace(/^@/, "");
109
+ if (!USERNAME.test(name)) throw new EditorsError("invalid", "That isn't a GitHub username.");
110
+ const api = managing();
111
+ try {
112
+ await githubRequest(api, `${path}/collaborators/${encodeURIComponent(name)}`, {
113
+ method: "PUT",
114
+ body: { permission: role === "owner" ? "admin" : "push" }
115
+ });
116
+ } catch (error) {
117
+ if (error instanceof GitApiError && error.status === 404) {
118
+ if ((await githubRequest(api, `/users/${encodeURIComponent(name)}`, { allow: [404] })).status === 404) throw new EditorsError("not-found", `There's no GitHub account named ${name}.`);
119
+ }
120
+ if (isNotAllowed(error)) throw new EditorsError("not-allowed", "This token can't change the site's editors.");
121
+ if (error instanceof GitApiError && error.status === 422) throw new EditorsError("invalid", `GitHub didn't accept the invitation: ${error.message}`);
122
+ throw error;
123
+ }
124
+ },
125
+ async setRole(editor, role) {
126
+ await change(async (api) => {
127
+ if (editor.id.startsWith(INVITATION)) await githubRequest(api, `${path}/invitations/${editor.id.slice(11)}`, {
128
+ method: "PATCH",
129
+ body: { permissions: role === "owner" ? "admin" : "write" }
130
+ });
131
+ else await githubRequest(api, `${path}/collaborators/${encodeURIComponent(editor.id)}`, {
132
+ method: "PUT",
133
+ body: { permission: role === "owner" ? "admin" : "push" }
134
+ });
135
+ });
136
+ },
137
+ async remove(editor) {
138
+ await change((api) => githubRequest(api, editor.id.startsWith(INVITATION) ? `${path}/invitations/${editor.id.slice(11)}` : `${path}/collaborators/${encodeURIComponent(editor.id)}`, { method: "DELETE" }));
139
+ }
140
+ };
141
+ }
142
+ /** The owner token: GitHub's Administration and Pages permissions, for this tab only. */
143
+ function githubOwnerAccess(base, repo, tokenLink) {
144
+ let owner;
145
+ return {
146
+ access: {
147
+ tokenLink,
148
+ get active() {
149
+ return owner !== void 0;
150
+ },
151
+ async applyToken(token) {
152
+ const api = {
153
+ ...base,
154
+ token: token.trim()
155
+ };
156
+ try {
157
+ if (!(await githubJson(api, `/repos/${repo}`)).permissions?.admin) throw new EditorsError("not-allowed", "This token's account isn't an owner of the site.");
158
+ await githubJson(api, `/repos/${repo}/invitations?per_page=1`);
159
+ } catch (error) {
160
+ if (error instanceof EditorsError) throw error;
161
+ if (error instanceof SignInError || isNotAllowed(error)) throw new EditorsError("not-allowed", "This token can't change the site's editors.");
162
+ throw error;
163
+ }
164
+ owner = api;
165
+ },
166
+ forget() {
167
+ owner = void 0;
168
+ }
169
+ },
170
+ api: () => owner
171
+ };
172
+ }
173
+ //#endregion
174
+ //#region src/pages.ts
175
+ /** GitHub Pages' addresses for an apex domain, from GitHub's documentation. */
176
+ const PAGES_IPV4 = [
177
+ "185.199.108.153",
178
+ "185.199.109.153",
179
+ "185.199.110.153",
180
+ "185.199.111.153"
181
+ ];
182
+ const PAGES_IPV6 = [
183
+ "2606:50c0:8000::153",
184
+ "2606:50c0:8001::153",
185
+ "2606:50c0:8002::153",
186
+ "2606:50c0:8003::153"
187
+ ];
188
+ /** Certificate states in which GitHub serves the domain over HTTPS. */
189
+ const READY = /* @__PURE__ */ new Set([
190
+ "approved",
191
+ "issued",
192
+ "uploaded"
193
+ ]);
194
+ const FAILED = /* @__PURE__ */ new Set([
195
+ "errored",
196
+ "bad_authz",
197
+ "authorization_revoked"
198
+ ]);
199
+ /** The records GitHub Pages needs: its addresses for an apex domain, or an alias of the owner's github.io. */
200
+ function githubPagesRecords(repo, domain, { apex, www }) {
201
+ const target = `${(repo.split("/")[0] ?? "").toLowerCase()}.github.io`;
202
+ if (!apex) return [{
203
+ type: "CNAME",
204
+ name: domain,
205
+ value: target,
206
+ purpose: "site"
207
+ }];
208
+ return [
209
+ ...PAGES_IPV4.map((value) => ({
210
+ type: "A",
211
+ name: domain,
212
+ value,
213
+ purpose: "site"
214
+ })),
215
+ ...PAGES_IPV6.map((value) => ({
216
+ type: "AAAA",
217
+ name: domain,
218
+ value,
219
+ purpose: "site",
220
+ optional: true
221
+ })),
222
+ ...www ? [{
223
+ type: "CNAME",
224
+ name: `www.${domain}`,
225
+ value: target,
226
+ purpose: "www",
227
+ optional: true
228
+ }] : []
229
+ ];
230
+ }
231
+ /**
232
+ * GitHub Pages, for a site's repository: where it's published, and its custom
233
+ * domain. `api` gives the owner token when one is in use (see `ownerAccess`).
234
+ */
235
+ function githubPages(api, repo) {
236
+ const path = `/repos/${repo}/pages`;
237
+ async function pages() {
238
+ const response = await githubRequest(api(), path, { allow: [404] });
239
+ return response.status === 404 ? void 0 : await response.json();
240
+ }
241
+ async function update(body) {
242
+ try {
243
+ await githubRequest(api(), path, {
244
+ method: "PUT",
245
+ body
246
+ });
247
+ } catch (error) {
248
+ 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.");
249
+ if (error instanceof GitApiError && (error.status === 400 || error.status === 422)) {
250
+ if (/taken|already/i.test(error.message)) throw new DomainError("taken", "Another GitHub Pages site already uses this domain.");
251
+ if ("https_enforced" in body) throw new DomainError("not-ready", "The domain's certificate isn't ready yet.");
252
+ throw new DomainError("invalid", "GitHub Pages doesn't accept this domain.");
253
+ }
254
+ throw error;
255
+ }
256
+ }
257
+ return {
258
+ async site() {
259
+ const found = await pages();
260
+ return found && {
261
+ url: found.html_url,
262
+ domain: found.cname ?? void 0
263
+ };
264
+ },
265
+ async connect(domain, options) {
266
+ await update({ cname: domain });
267
+ return githubPagesRecords(repo, domain, options);
268
+ },
269
+ async records(domain, options) {
270
+ return githubPagesRecords(repo, domain, options);
271
+ },
272
+ async status(domain) {
273
+ const found = await githubJson(api(), path);
274
+ const state = found.https_certificate?.state ?? "";
275
+ return {
276
+ verified: true,
277
+ certificate: READY.has(state) ? "ready" : FAILED.has(state) ? "failed" : "pending",
278
+ httpsOnly: found.https_enforced === true,
279
+ primary: found.cname === domain
280
+ };
281
+ },
282
+ async secure() {
283
+ await update({ https_enforced: true });
284
+ },
285
+ async disconnect() {
286
+ await update({ cname: null });
287
+ }
288
+ };
289
+ }
290
+ //#endregion
291
+ //#region src/updates.ts
292
+ /** The starters' GitHub Pages workflow, which runs `goodfellow update` each night and when it's dispatched. */
293
+ const WORKFLOW = "deploy.yml";
294
+ const WORKFLOW_FILE = `.github/workflows/${WORKFLOW}`;
295
+ /**
296
+ * The site's nightly update, which runs in its GitHub Pages workflow. Running
297
+ * it now dispatches the workflow, which needs GitHub's Actions permission
298
+ * with write access: `manage()` gives the owner token when one is in use.
299
+ */
300
+ function githubUpdates(read, manage, repo, branch, files) {
301
+ const path = `/repos/${repo}/actions/workflows/${WORKFLOW}`;
302
+ return {
303
+ async setup() {
304
+ return (await files.read(WORKFLOW_FILE))?.includes("update --publish") ? "ready" : "missing";
305
+ },
306
+ async lastRun() {
307
+ const response = await githubRequest(read, `${path}/runs?branch=${encodeURIComponent(branch)}&per_page=20`, { allow: [403, 404] });
308
+ if (!response.ok) return void 0;
309
+ const { workflow_runs: runs = [] } = await response.json();
310
+ for (const run of runs.filter((candidate) => ["schedule", "workflow_dispatch"].includes(candidate.event))) {
311
+ const base = {
312
+ date: run.created_at,
313
+ detailsUrl: run.html_url
314
+ };
315
+ if (run.status !== "completed") return {
316
+ state: "running",
317
+ ...base
318
+ };
319
+ const { jobs = [] } = await githubJson(read, `/repos/${repo}/actions/runs/${run.id}/jobs`);
320
+ const job = jobs.find((candidate) => candidate.name === "update");
321
+ if (!job || job.conclusion === "skipped") continue;
322
+ if (job.conclusion !== "success") return {
323
+ state: "failed",
324
+ ...base
325
+ };
326
+ const before = siteVersion(await files.readAt("package.json", run.head_sha) ?? "");
327
+ const now = siteVersion(await files.read("package.json") ?? "");
328
+ if (!before || !now || before === now) return {
329
+ state: "up-to-date",
330
+ ...base
331
+ };
332
+ const rollback = compareReleases(now, before) < 0;
333
+ return {
334
+ state: "updated",
335
+ ...base,
336
+ result: {
337
+ state: "updated",
338
+ from: before,
339
+ to: now,
340
+ ...rollback && { rollback }
341
+ }
342
+ };
343
+ }
344
+ },
345
+ async start(target) {
346
+ try {
347
+ await githubRequest(manage() ?? read, `${path}/dispatches`, {
348
+ method: "POST",
349
+ body: {
350
+ ref: branch,
351
+ inputs: { update: target }
352
+ }
353
+ });
354
+ } catch (error) {
355
+ 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.");
356
+ if (error instanceof GitApiError && (error.status === 404 || error.status === 422)) throw new UpdatesError("missing", "The site's GitHub Pages workflow doesn't update Goodfellow.");
357
+ throw error;
358
+ }
359
+ }
360
+ };
361
+ }
362
+ //#endregion
31
363
  //#region src/backend.ts
32
364
  const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
33
365
  createCommitOnBranch(input: $input) { commit { oid } }
@@ -39,6 +371,10 @@ const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
39
371
  */
40
372
  var GitHubBackend = class {
41
373
  user;
374
+ pages;
375
+ editors;
376
+ ownerAccess;
377
+ updates;
42
378
  api;
43
379
  repo;
44
380
  branch;
@@ -52,6 +388,11 @@ var GitHubBackend = class {
52
388
  this.branch = options.branch;
53
389
  this.user = options.user;
54
390
  this.onSignOut = options.onSignOut;
391
+ const owner = githubOwnerAccess(this.api, this.repo, options.ownerTokenLink);
392
+ this.ownerAccess = owner.access;
393
+ this.pages = githubPages(() => owner.api() ?? this.api, this.repo);
394
+ this.editors = githubEditors(this.api, owner.api, this.repo, this.user.login);
395
+ this.updates = githubUpdates(this.api, owner.api, this.repo, this.branch, this);
55
396
  }
56
397
  async revision() {
57
398
  const ref = await githubJson(this.api, `/repos/${this.repo}/git/ref/heads/${encodeURIComponent(this.branch)}`);
@@ -75,8 +416,11 @@ var GitHubBackend = class {
75
416
  return this.tree(this.pinned ?? await this.revision());
76
417
  }
77
418
  async read(path) {
78
- const sha = (await this.currentTree()).get(path);
79
- if (!sha) return void 0;
419
+ return this.readFrom(await this.currentTree(), path);
420
+ }
421
+ readFrom(tree, path) {
422
+ const sha = tree.get(path);
423
+ if (!sha) return Promise.resolve(void 0);
80
424
  let blob = this.blobs.get(sha);
81
425
  if (!blob) {
82
426
  blob = githubRequest(this.api, `/repos/${this.repo}/git/blobs/${sha}`, { accept: "application/vnd.github.raw+json" }).then((response) => response.text());
@@ -131,68 +475,381 @@ var GitHubBackend = class {
131
475
  if (error?.type === "FORBIDDEN") throw new SignInError("cant-publish", "This GitHub account can't publish to the site.");
132
476
  throw new GitApiError(422, `GitHub couldn't save the changes: ${error?.message ?? "unknown error"}`);
133
477
  }
478
+ async history(path, { page = 1, perPage = 20 } = {}) {
479
+ const query = new URLSearchParams({
480
+ sha: this.pinned ?? await this.revision(),
481
+ path,
482
+ per_page: String(perPage),
483
+ page: String(page)
484
+ });
485
+ return (await githubJson(this.api, `/repos/${this.repo}/commits?${query}`)).map((commit) => ({
486
+ revision: commit.sha,
487
+ date: commit.commit.author?.date ?? "",
488
+ author: commit.commit.author?.name || commit.author?.login || void 0,
489
+ message: commit.commit.message.split("\n")[0] ?? ""
490
+ }));
491
+ }
492
+ async readAt(path, revision) {
493
+ return this.readFrom(await this.tree(revision), path);
494
+ }
134
495
  async changedPaths(from, to) {
135
496
  const comparison = await githubJson(this.api, `/repos/${this.repo}/compare/${from}...${to}`);
136
497
  return [...new Set((comparison.files ?? []).flatMap((file) => [file.filename, file.previous_filename ?? []].flat()))];
137
498
  }
138
499
  /**
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.
500
+ * Reads the deployment GitHub Pages or Vercel created for a commit, and for
501
+ * GitHub Actions, the workflow run that builds it: a build that fails never
502
+ * creates a deployment. Before either exists, the site counts as building
503
+ * if it has ever been deployed.
141
504
  */
142
505
  async deployStatus(revision) {
143
506
  try {
144
507
  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
- };
508
+ if (latest) {
509
+ const [status] = await githubJson(this.api, `/repos/${this.repo}/deployments/${latest.id}/statuses?per_page=1`);
510
+ const detailsUrl = status?.log_url || status?.target_url || void 0;
511
+ switch (status?.state) {
512
+ case "success":
513
+ case "inactive": return {
514
+ state: "live",
515
+ detailsUrl
516
+ };
517
+ case "failure":
518
+ case "error": {
519
+ const run = await this.workflowRun(revision);
520
+ return {
521
+ state: "failed",
522
+ detailsUrl,
523
+ problem: run ? await this.runProblem(run) : { step: "deploy" }
524
+ };
525
+ }
526
+ default: return {
527
+ state: "building",
528
+ detailsUrl
529
+ };
530
+ }
163
531
  }
532
+ const run = await this.workflowRun(revision);
533
+ if (run?.status === "completed" && run.conclusion !== "success" && run.conclusion !== "skipped") return {
534
+ state: "failed",
535
+ detailsUrl: run.html_url,
536
+ problem: await this.runProblem(run)
537
+ };
538
+ if (run) return {
539
+ state: "building",
540
+ detailsUrl: run.html_url
541
+ };
542
+ return { state: (await githubJson(this.api, `/repos/${this.repo}/deployments?per_page=1`)).length > 0 ? "building" : "unknown" };
164
543
  } catch (error) {
165
544
  if (error instanceof GitApiError) return { state: "unknown" };
166
545
  throw error;
167
546
  }
168
547
  }
548
+ /** The site's deploy workflow run for a commit, or `undefined` if there's none or the token can't see Actions. */
549
+ async workflowRun(revision) {
550
+ const response = await githubRequest(this.api, `/repos/${this.repo}/actions/runs?head_sha=${revision}&per_page=20`, { allow: [403, 404] });
551
+ if (!response.ok) return void 0;
552
+ const { workflow_runs: runs = [] } = await response.json();
553
+ return runs.find((run) => /deploy|pages/i.test(`${run.path ?? ""} ${run.name ?? ""}`)) ?? runs[0];
554
+ }
555
+ /** Which step of a failed run failed. */
556
+ async runProblem(run) {
557
+ const response = await githubRequest(this.api, `/repos/${this.repo}/actions/runs/${run.id}/jobs?per_page=50`, { allow: [403, 404] });
558
+ const failed = (response.ok ? (await response.json()).jobs ?? [] : []).find((job) => job.conclusion === "failure");
559
+ if (!failed) return {
560
+ step: run.conclusion === "startup_failure" ? "not-started" : "host",
561
+ detail: run.conclusion ?? void 0
562
+ };
563
+ const step = failed.steps?.find((candidate) => candidate.conclusion === "failure");
564
+ if (!step) return {
565
+ step: "not-started",
566
+ detail: failed.name
567
+ };
568
+ return {
569
+ step: buildStepFor(step.name) ?? (/deploy/i.test(failed.name) ? "deploy" : "build"),
570
+ detail: `${failed.name}: ${step.name}`
571
+ };
572
+ }
169
573
  signOut() {
574
+ this.ownerAccess.forget();
170
575
  this.onSignOut();
171
576
  }
172
577
  };
173
578
  //#endregion
579
+ //#region src/setup.ts
580
+ /** The branch the deploy setups build from. */
581
+ const MAIN = "main";
582
+ /**
583
+ * Links to GitHub's token pages with what creating a site needs: creating the
584
+ * repository (administration), writing its files and its build setup
585
+ * (contents and workflows), and turning on GitHub Pages (pages).
586
+ */
587
+ function githubSetupTokenLinks(webUrl = "https://github.com") {
588
+ const fineGrained = new URLSearchParams({
589
+ name: "Goodfellow site setup",
590
+ description: "Create a Goodfellow site. It can be deleted once the site is created.",
591
+ expires_in: "7",
592
+ administration: "write",
593
+ contents: "write",
594
+ workflows: "write",
595
+ pages: "write"
596
+ });
597
+ const classic = new URLSearchParams({
598
+ scopes: "repo,workflow",
599
+ description: "Goodfellow site setup"
600
+ });
601
+ return {
602
+ fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
603
+ classic: `${webUrl}/settings/tokens/new?${classic}`
604
+ };
605
+ }
606
+ /** Signing in to GitHub to create a site's repository, with GitHub Pages if wanted. */
607
+ function githubSetup(options = {}) {
608
+ const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
609
+ const links = githubSetupTokenLinks(options.webUrl);
610
+ const fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
611
+ async function connect(token) {
612
+ const api = {
613
+ apiUrl,
614
+ token,
615
+ fetch: fetcher
616
+ };
617
+ const user = await githubJson(api, "/user");
618
+ async function owners() {
619
+ return [{
620
+ path: user.login,
621
+ name: user.name || user.login,
622
+ kind: "personal"
623
+ }, ...(await githubJson(api, "/user/orgs?per_page=100").catch(() => [])).map((org) => ({
624
+ path: org.login,
625
+ name: org.login,
626
+ kind: "organization"
627
+ }))];
628
+ }
629
+ async function isAvailable(owner, name) {
630
+ return (await githubRequest(api, `/repos/${repoPath(`${owner.path}/${name}`)}`, { allow: [404] })).status === 404;
631
+ }
632
+ async function createRepository(site) {
633
+ const path = site.owner.kind === "personal" ? "/user/repos" : `/orgs/${encodeURIComponent(site.owner.path)}/repos`;
634
+ if (!await isAvailable(site.owner, site.name)) throw new SetupError("name-taken", `There's already a repository called ${site.owner.path}/${site.name}.`);
635
+ try {
636
+ return await githubJson(api, path, {
637
+ method: "POST",
638
+ body: {
639
+ name: site.name,
640
+ private: site.private,
641
+ description: site.description,
642
+ auto_init: true
643
+ }
644
+ });
645
+ } catch (error) {
646
+ if (error instanceof GitApiError && error.status === 422) throw new SetupError("name-invalid", `GitHub can't use "${site.name}" as a repository name.`);
647
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", `This sign-in can't create repositories in ${site.owner.path}.`);
648
+ throw error;
649
+ }
650
+ }
651
+ /** Makes `main` the default branch, which the deploy setups build from. */
652
+ async function useMainBranch(repo) {
653
+ if (repo.default_branch === MAIN) return;
654
+ const base = `/repos/${repoPath(repo.full_name)}`;
655
+ const ref = await githubJson(api, `${base}/git/ref/heads/${repo.default_branch}`);
656
+ await githubRequest(api, `${base}/git/refs`, {
657
+ method: "POST",
658
+ body: {
659
+ ref: `refs/heads/${MAIN}`,
660
+ sha: ref.object.sha
661
+ }
662
+ });
663
+ await githubRequest(api, base, {
664
+ method: "PATCH",
665
+ body: { default_branch: MAIN }
666
+ });
667
+ await githubRequest(api, `${base}/git/refs/heads/${repo.default_branch}`, { method: "DELETE" });
668
+ }
669
+ /** Turns on GitHub Pages, published by the site's own workflow. Returns the site's address. */
670
+ async function turnOnPages(repo) {
671
+ try {
672
+ return (await githubJson(api, `/repos/${repoPath(repo.full_name)}/pages`, {
673
+ method: "POST",
674
+ body: { build_type: "workflow" }
675
+ })).html_url;
676
+ } catch (error) {
677
+ if (error instanceof GitApiError && [
678
+ 403,
679
+ 404,
680
+ 409,
681
+ 422
682
+ ].includes(error.status)) return void 0;
683
+ throw error;
684
+ }
685
+ }
686
+ /** Writes every file in one commit, which replaces the starting README rather than following it. */
687
+ async function writeFiles(repo, site) {
688
+ const base = `/repos/${repoPath(repo.full_name)}`;
689
+ const tree = [];
690
+ for (const file of site.files) {
691
+ if ("delete" in file) continue;
692
+ if ("content" in file) {
693
+ tree.push({
694
+ path: file.path,
695
+ mode: "100644",
696
+ type: "blob",
697
+ content: file.content
698
+ });
699
+ continue;
700
+ }
701
+ const blob = await githubJson(api, `${base}/git/blobs`, {
702
+ method: "POST",
703
+ body: {
704
+ content: encodeBase64Bytes(file.bytes),
705
+ encoding: "base64"
706
+ }
707
+ });
708
+ tree.push({
709
+ path: file.path,
710
+ mode: "100644",
711
+ type: "blob",
712
+ sha: blob.sha
713
+ });
714
+ }
715
+ try {
716
+ const created = await githubJson(api, `${base}/git/trees`, {
717
+ method: "POST",
718
+ body: { tree }
719
+ });
720
+ const commit = await githubJson(api, `${base}/git/commits`, {
721
+ method: "POST",
722
+ body: {
723
+ message: site.message,
724
+ tree: created.sha,
725
+ parents: []
726
+ }
727
+ });
728
+ await githubRequest(api, `${base}/git/refs/heads/${MAIN}`, {
729
+ method: "PATCH",
730
+ body: {
731
+ sha: commit.sha,
732
+ force: true
733
+ }
734
+ });
735
+ } catch (error) {
736
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", "This sign-in can't write the site's files, or its build setup.");
737
+ throw error;
738
+ }
739
+ }
740
+ /** Stops anyone rewriting or deleting the main branch's history. Not every plan allows it. */
741
+ async function protectMain(repo) {
742
+ return (await githubRequest(api, `/repos/${repoPath(repo.full_name)}/rulesets`, {
743
+ method: "POST",
744
+ body: {
745
+ name: "Protect the published history",
746
+ target: "branch",
747
+ enforcement: "active",
748
+ conditions: { ref_name: {
749
+ include: ["~DEFAULT_BRANCH"],
750
+ exclude: []
751
+ } },
752
+ rules: [{ type: "non_fast_forward" }, { type: "deletion" }]
753
+ },
754
+ allow: [
755
+ 403,
756
+ 404,
757
+ 422
758
+ ]
759
+ })).ok;
760
+ }
761
+ return {
762
+ user: {
763
+ login: user.login,
764
+ name: user.name ?? void 0,
765
+ avatarUrl: user.avatar_url
766
+ },
767
+ owners,
768
+ isAvailable,
769
+ async createSite(site, onStep) {
770
+ const warnings = [];
771
+ onStep?.("repository");
772
+ const repo = await createRepository(site);
773
+ await useMainBranch(repo);
774
+ let siteUrl;
775
+ if (site.pages) {
776
+ onStep?.("pages");
777
+ siteUrl = await turnOnPages(repo);
778
+ if (!siteUrl) warnings.push("pages-unavailable");
779
+ }
780
+ onStep?.("files");
781
+ await writeFiles(repo, site);
782
+ onStep?.("protection");
783
+ if (!await protectMain(repo)) warnings.push("protection-unavailable");
784
+ return {
785
+ repo: repo.full_name,
786
+ webUrl: repo.html_url,
787
+ siteUrl,
788
+ buildsUrl: `${repo.html_url}/actions`,
789
+ pagesUrl: site.pages ? `${repo.html_url}/settings/pages` : void 0,
790
+ warnings
791
+ };
792
+ }
793
+ };
794
+ }
795
+ return {
796
+ name: "GitHub",
797
+ canRedirect: false,
798
+ tokenLinks: [{
799
+ url: links.fineGrained,
800
+ label: "signIn.token.create"
801
+ }, {
802
+ url: links.classic,
803
+ label: "signIn.token.createBroad",
804
+ hint: "signIn.token.createBroadHint"
805
+ }],
806
+ async restore() {
807
+ return null;
808
+ },
809
+ async signInWithToken(token) {
810
+ const trimmed = token.trim();
811
+ if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
812
+ return connect(trimmed);
813
+ },
814
+ async startRedirect() {
815
+ throw new Error("Signing in by redirect needs a sign-in server, which isn't supported for GitHub yet.");
816
+ }
817
+ };
818
+ }
819
+ //#endregion
174
820
  //#region src/index.ts
175
821
  /**
176
822
  * Links to GitHub's token page with everything filled in except the
177
823
  * repository, which GitHub doesn't let links choose.
178
824
  */
179
825
  function githubTokenLinks(repo, webUrl = "https://github.com") {
180
- const [owner = ""] = repo.split("/");
826
+ const [repoOwner = ""] = repo.split("/");
181
827
  const fineGrained = new URLSearchParams({
182
828
  name: "Goodfellow admin",
183
829
  description: `Edit ${repo} in its admin panel`,
184
- target_name: owner,
830
+ target_name: repoOwner,
185
831
  expires_in: "90",
186
832
  contents: "write",
187
- deployments: "read"
833
+ deployments: "read",
834
+ actions: "read"
188
835
  });
189
836
  const classic = new URLSearchParams({
190
837
  scopes: "repo",
191
838
  description: `Goodfellow admin for ${repo}`
192
839
  });
840
+ const owner = new URLSearchParams({
841
+ name: "Goodfellow owner",
842
+ description: `Manage the editors, domain and updates of ${repo}`,
843
+ target_name: repoOwner,
844
+ expires_in: "1",
845
+ administration: "write",
846
+ pages: "write",
847
+ actions: "write"
848
+ });
193
849
  return {
194
850
  fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
195
- classic: `${webUrl}/settings/tokens/new?${classic}`
851
+ classic: `${webUrl}/settings/tokens/new?${classic}`,
852
+ owner: `${webUrl}/settings/personal-access-tokens/new?${owner}`
196
853
  };
197
854
  }
198
855
  /** Sign-in and storage for a site whose repository is on GitHub. */
@@ -225,7 +882,11 @@ function github(options) {
225
882
  name: user.name ?? void 0,
226
883
  avatarUrl: user.avatar_url
227
884
  },
228
- onSignOut: () => storage.clear()
885
+ onSignOut: () => storage.clear(),
886
+ ownerTokenLink: {
887
+ url: links.owner,
888
+ label: "owner.token.create"
889
+ }
229
890
  });
230
891
  }
231
892
  return {
@@ -262,4 +923,4 @@ function github(options) {
262
923
  };
263
924
  }
264
925
  //#endregion
265
- export { GitHubBackend, github, githubTokenLinks };
926
+ export { GitHubBackend, github, githubPagesRecords, githubSetup, githubSetupTokenLinks, githubTokenLinks };