@goodfellow-cms/github 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +16 -2
- package/dist/index.d.ts +121 -0
- package/dist/index.js +926 -0
- package/dist/testing.d.ts +126 -0
- package/dist/testing.js +520 -0
- package/package.json +51 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
import { ConflictError, DomainError, EditorsError, GitApiError, SetupError, SignInError, UpdatesError, buildStepFor, compareReleases, credentialStorage, encodeBase64, encodeBase64Bytes, siteVersion } from "@goodfellow-cms/core";
|
|
2
|
+
//#region src/api.ts
|
|
3
|
+
/** Calls the GitHub REST or GraphQL API. Treats 401 as an expired sign-in; other errors become `GitApiError`. */
|
|
4
|
+
async function githubRequest({ apiUrl, token, fetch }, path, init = {}) {
|
|
5
|
+
const response = await fetch(`${apiUrl}${path}`, {
|
|
6
|
+
method: init.method ?? "GET",
|
|
7
|
+
headers: {
|
|
8
|
+
accept: init.accept ?? "application/vnd.github+json",
|
|
9
|
+
authorization: `Bearer ${token}`,
|
|
10
|
+
"x-github-api-version": "2022-11-28",
|
|
11
|
+
...init.body !== void 0 && { "content-type": "application/json" }
|
|
12
|
+
},
|
|
13
|
+
body: init.body === void 0 ? void 0 : JSON.stringify(init.body),
|
|
14
|
+
cache: "no-store"
|
|
15
|
+
});
|
|
16
|
+
if (response.status === 401) throw new SignInError("invalid", "GitHub didn't accept the sign-in. It may have expired.");
|
|
17
|
+
if (!response.ok && !init.allow?.includes(response.status)) {
|
|
18
|
+
const body = await response.json().catch(() => ({}));
|
|
19
|
+
throw new GitApiError(response.status, `GitHub answered ${response.status}: ${body.message ?? response.statusText}`);
|
|
20
|
+
}
|
|
21
|
+
return response;
|
|
22
|
+
}
|
|
23
|
+
async function githubJson(options, path, init) {
|
|
24
|
+
return await (await githubRequest(options, path, init)).json();
|
|
25
|
+
}
|
|
26
|
+
/** Path segments of a repository name, encoded for URLs: `owner/name` stays `owner/name`. */
|
|
27
|
+
function repoPath(repo) {
|
|
28
|
+
return repo.split("/").map((part) => encodeURIComponent(part)).join("/");
|
|
29
|
+
}
|
|
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
|
|
363
|
+
//#region src/backend.ts
|
|
364
|
+
const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
|
|
365
|
+
createCommitOnBranch(input: $input) { commit { oid } }
|
|
366
|
+
}`;
|
|
367
|
+
/**
|
|
368
|
+
* The site's files on GitHub. Reads come from the commit `revision()` last
|
|
369
|
+
* returned; saves are single commits made with GraphQL's `createCommitOnBranch`,
|
|
370
|
+
* which fails if the branch has moved, so nothing is ever overwritten.
|
|
371
|
+
*/
|
|
372
|
+
var GitHubBackend = class {
|
|
373
|
+
user;
|
|
374
|
+
pages;
|
|
375
|
+
editors;
|
|
376
|
+
ownerAccess;
|
|
377
|
+
updates;
|
|
378
|
+
api;
|
|
379
|
+
repo;
|
|
380
|
+
branch;
|
|
381
|
+
onSignOut;
|
|
382
|
+
pinned;
|
|
383
|
+
trees = /* @__PURE__ */ new Map();
|
|
384
|
+
blobs = /* @__PURE__ */ new Map();
|
|
385
|
+
constructor(options) {
|
|
386
|
+
this.api = options.api;
|
|
387
|
+
this.repo = repoPath(options.repo);
|
|
388
|
+
this.branch = options.branch;
|
|
389
|
+
this.user = options.user;
|
|
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);
|
|
396
|
+
}
|
|
397
|
+
async revision() {
|
|
398
|
+
const ref = await githubJson(this.api, `/repos/${this.repo}/git/ref/heads/${encodeURIComponent(this.branch)}`);
|
|
399
|
+
this.pinned = ref.object.sha;
|
|
400
|
+
return this.pinned;
|
|
401
|
+
}
|
|
402
|
+
/** Path → blob id for every file at a commit. Commits never change, so this is cached. */
|
|
403
|
+
tree(sha) {
|
|
404
|
+
let tree = this.trees.get(sha);
|
|
405
|
+
if (!tree) {
|
|
406
|
+
tree = githubJson(this.api, `/repos/${this.repo}/git/trees/${sha}?recursive=1`).then((response) => {
|
|
407
|
+
if (response.truncated) throw new GitApiError(413, "The repository has too many files for GitHub to list at once.");
|
|
408
|
+
return new Map(response.tree.filter((entry) => entry.type === "blob").map((entry) => [entry.path, entry.sha]));
|
|
409
|
+
});
|
|
410
|
+
tree.catch(() => this.trees.delete(sha));
|
|
411
|
+
this.trees.set(sha, tree);
|
|
412
|
+
}
|
|
413
|
+
return tree;
|
|
414
|
+
}
|
|
415
|
+
async currentTree() {
|
|
416
|
+
return this.tree(this.pinned ?? await this.revision());
|
|
417
|
+
}
|
|
418
|
+
async read(path) {
|
|
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);
|
|
424
|
+
let blob = this.blobs.get(sha);
|
|
425
|
+
if (!blob) {
|
|
426
|
+
blob = githubRequest(this.api, `/repos/${this.repo}/git/blobs/${sha}`, { accept: "application/vnd.github.raw+json" }).then((response) => response.text());
|
|
427
|
+
blob.catch(() => this.blobs.delete(sha));
|
|
428
|
+
this.blobs.set(sha, blob);
|
|
429
|
+
}
|
|
430
|
+
return blob;
|
|
431
|
+
}
|
|
432
|
+
async readBytes(path) {
|
|
433
|
+
const sha = (await this.currentTree()).get(path);
|
|
434
|
+
if (!sha) return void 0;
|
|
435
|
+
const response = await githubRequest(this.api, `/repos/${this.repo}/git/blobs/${sha}`, { accept: "application/vnd.github.raw+json" });
|
|
436
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
437
|
+
}
|
|
438
|
+
async list(dir) {
|
|
439
|
+
const prefix = `${dir}/`;
|
|
440
|
+
return [...(await this.currentTree()).keys()].filter((path) => path.startsWith(prefix)).sort();
|
|
441
|
+
}
|
|
442
|
+
async write(changes, { message, expectedRevision }) {
|
|
443
|
+
const existing = await this.tree(expectedRevision);
|
|
444
|
+
const additions = changes.flatMap((change) => "delete" in change ? [] : [{
|
|
445
|
+
path: change.path,
|
|
446
|
+
contents: "bytes" in change ? encodeBase64Bytes(change.bytes) : encodeBase64(change.content)
|
|
447
|
+
}]);
|
|
448
|
+
const deletions = changes.flatMap((change) => "delete" in change && existing.has(change.path) ? [{ path: change.path }] : []);
|
|
449
|
+
if (additions.length === 0 && deletions.length === 0) return { revision: expectedRevision };
|
|
450
|
+
const response = await githubJson(this.api, "/graphql", {
|
|
451
|
+
method: "POST",
|
|
452
|
+
body: {
|
|
453
|
+
query: COMMIT_MUTATION,
|
|
454
|
+
variables: { input: {
|
|
455
|
+
branch: {
|
|
456
|
+
repositoryNameWithOwner: decodeURIComponent(this.repo),
|
|
457
|
+
branchName: this.branch
|
|
458
|
+
},
|
|
459
|
+
message: { headline: message },
|
|
460
|
+
expectedHeadOid: expectedRevision,
|
|
461
|
+
fileChanges: {
|
|
462
|
+
additions,
|
|
463
|
+
deletions
|
|
464
|
+
}
|
|
465
|
+
} }
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
const oid = response.data?.createCommitOnBranch?.commit?.oid;
|
|
469
|
+
if (oid) {
|
|
470
|
+
this.pinned = oid;
|
|
471
|
+
return { revision: oid };
|
|
472
|
+
}
|
|
473
|
+
if (await this.revision() !== expectedRevision) throw new ConflictError();
|
|
474
|
+
const error = response.errors?.[0];
|
|
475
|
+
if (error?.type === "FORBIDDEN") throw new SignInError("cant-publish", "This GitHub account can't publish to the site.");
|
|
476
|
+
throw new GitApiError(422, `GitHub couldn't save the changes: ${error?.message ?? "unknown error"}`);
|
|
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
|
+
}
|
|
495
|
+
async changedPaths(from, to) {
|
|
496
|
+
const comparison = await githubJson(this.api, `/repos/${this.repo}/compare/${from}...${to}`);
|
|
497
|
+
return [...new Set((comparison.files ?? []).flatMap((file) => [file.filename, file.previous_filename ?? []].flat()))];
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
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.
|
|
504
|
+
*/
|
|
505
|
+
async deployStatus(revision) {
|
|
506
|
+
try {
|
|
507
|
+
const latest = (await githubJson(this.api, `/repos/${this.repo}/deployments?sha=${revision}&per_page=5`))[0];
|
|
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
|
+
}
|
|
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" };
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (error instanceof GitApiError) return { state: "unknown" };
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
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
|
+
}
|
|
573
|
+
signOut() {
|
|
574
|
+
this.ownerAccess.forget();
|
|
575
|
+
this.onSignOut();
|
|
576
|
+
}
|
|
577
|
+
};
|
|
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
|
|
820
|
+
//#region src/index.ts
|
|
821
|
+
/**
|
|
822
|
+
* Links to GitHub's token page with everything filled in except the
|
|
823
|
+
* repository, which GitHub doesn't let links choose.
|
|
824
|
+
*/
|
|
825
|
+
function githubTokenLinks(repo, webUrl = "https://github.com") {
|
|
826
|
+
const [repoOwner = ""] = repo.split("/");
|
|
827
|
+
const fineGrained = new URLSearchParams({
|
|
828
|
+
name: "Goodfellow admin",
|
|
829
|
+
description: `Edit ${repo} in its admin panel`,
|
|
830
|
+
target_name: repoOwner,
|
|
831
|
+
expires_in: "90",
|
|
832
|
+
contents: "write",
|
|
833
|
+
deployments: "read",
|
|
834
|
+
actions: "read"
|
|
835
|
+
});
|
|
836
|
+
const classic = new URLSearchParams({
|
|
837
|
+
scopes: "repo",
|
|
838
|
+
description: `Goodfellow admin for ${repo}`
|
|
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
|
+
});
|
|
849
|
+
return {
|
|
850
|
+
fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
|
|
851
|
+
classic: `${webUrl}/settings/tokens/new?${classic}`,
|
|
852
|
+
owner: `${webUrl}/settings/personal-access-tokens/new?${owner}`
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
/** Sign-in and storage for a site whose repository is on GitHub. */
|
|
856
|
+
function github(options) {
|
|
857
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(options.repo)) throw new Error(`github({ repo }) must look like "owner/name" (got "${options.repo}").`);
|
|
858
|
+
const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
|
|
859
|
+
const links = githubTokenLinks(options.repo, options.webUrl);
|
|
860
|
+
const storage = credentialStorage(`goodfellow:github:${options.repo}`, options.storage);
|
|
861
|
+
async function connect(token) {
|
|
862
|
+
const api = {
|
|
863
|
+
apiUrl,
|
|
864
|
+
token,
|
|
865
|
+
fetch: options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
866
|
+
};
|
|
867
|
+
const user = await githubJson(api, "/user");
|
|
868
|
+
let repo;
|
|
869
|
+
try {
|
|
870
|
+
repo = await githubJson(api, `/repos/${repoPath(options.repo)}`);
|
|
871
|
+
} catch (error) {
|
|
872
|
+
if (error instanceof GitApiError && (error.status === 404 || error.status === 403)) throw new SignInError("no-access", `The GitHub account ${user.login} can't see ${options.repo}.`);
|
|
873
|
+
throw error;
|
|
874
|
+
}
|
|
875
|
+
if (repo.permissions?.push === false) throw new SignInError("cant-publish", `The GitHub account ${user.login} can see ${options.repo} but can't publish to it.`);
|
|
876
|
+
return new GitHubBackend({
|
|
877
|
+
api,
|
|
878
|
+
repo: options.repo,
|
|
879
|
+
branch: options.branch ?? repo.default_branch,
|
|
880
|
+
user: {
|
|
881
|
+
login: user.login,
|
|
882
|
+
name: user.name ?? void 0,
|
|
883
|
+
avatarUrl: user.avatar_url
|
|
884
|
+
},
|
|
885
|
+
onSignOut: () => storage.clear(),
|
|
886
|
+
ownerTokenLink: {
|
|
887
|
+
url: links.owner,
|
|
888
|
+
label: "owner.token.create"
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
return {
|
|
893
|
+
name: "GitHub",
|
|
894
|
+
canRedirect: false,
|
|
895
|
+
tokenLinks: [{
|
|
896
|
+
url: links.fineGrained,
|
|
897
|
+
label: "signIn.token.create"
|
|
898
|
+
}, {
|
|
899
|
+
url: links.classic,
|
|
900
|
+
label: "signIn.token.createBroad",
|
|
901
|
+
hint: "signIn.token.createBroadHint"
|
|
902
|
+
}],
|
|
903
|
+
async restore() {
|
|
904
|
+
const token = storage.load();
|
|
905
|
+
if (!token) return null;
|
|
906
|
+
try {
|
|
907
|
+
return await connect(token);
|
|
908
|
+
} catch (error) {
|
|
909
|
+
if (error instanceof SignInError) storage.clear();
|
|
910
|
+
throw error;
|
|
911
|
+
}
|
|
912
|
+
},
|
|
913
|
+
async signInWithToken(token, remember) {
|
|
914
|
+
const trimmed = token.trim();
|
|
915
|
+
if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
|
|
916
|
+
const backend = await connect(trimmed);
|
|
917
|
+
storage.save(trimmed, remember);
|
|
918
|
+
return backend;
|
|
919
|
+
},
|
|
920
|
+
async startRedirect() {
|
|
921
|
+
throw new Error("Signing in by redirect needs a sign-in server, which isn't supported for GitHub yet.");
|
|
922
|
+
}
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
//#endregion
|
|
926
|
+
export { GitHubBackend, github, githubPagesRecords, githubSetup, githubSetupTokenLinks, githubTokenLinks };
|