@goodfellow-cms/github 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/deploy-keys-DAj2oX-N.js +108 -0
- package/dist/index.d.ts +35 -10
- package/dist/index.js +274 -42
- package/dist/testing.d.ts +22 -1
- package/dist/testing.js +104 -8
- package/package.json +4 -2
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { encodeBase64Bytes } from "@goodfellow-cms/core";
|
|
2
|
+
import { hsalsa, xsalsa20poly1305 } from "@noble/ciphers/salsa.js";
|
|
3
|
+
import { blake2b } from "@noble/hashes/blake2.js";
|
|
4
|
+
//#region \0rolldown/runtime.js
|
|
5
|
+
var __defProp = Object.defineProperty;
|
|
6
|
+
var __exportAll = (all, no_symbols) => {
|
|
7
|
+
let target = {};
|
|
8
|
+
for (var name in all) __defProp(target, name, {
|
|
9
|
+
get: all[name],
|
|
10
|
+
enumerable: true
|
|
11
|
+
});
|
|
12
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
13
|
+
return target;
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/deploy-keys.ts
|
|
17
|
+
/**
|
|
18
|
+
* Deploy keys and Actions secrets, for connecting a site's private code and
|
|
19
|
+
* content repositories: each workflow reads the other repository with a
|
|
20
|
+
* deploy key, whose private half is a secret of the repository it runs in.
|
|
21
|
+
* Loaded only by the setup page, when it connects them.
|
|
22
|
+
*/
|
|
23
|
+
var deploy_keys_exports = /* @__PURE__ */ __exportAll({
|
|
24
|
+
newDeployKey: () => newDeployKey,
|
|
25
|
+
openSealedSecret: () => openSealedSecret,
|
|
26
|
+
sealSecret: () => sealSecret
|
|
27
|
+
});
|
|
28
|
+
function concat(...parts) {
|
|
29
|
+
const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
|
|
30
|
+
let at = 0;
|
|
31
|
+
for (const part of parts) {
|
|
32
|
+
result.set(part, at);
|
|
33
|
+
at += part.length;
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
/** SSH's encoding of a number: four bytes, most significant first. */
|
|
38
|
+
function uint32(value) {
|
|
39
|
+
const bytes = /* @__PURE__ */ new Uint8Array(4);
|
|
40
|
+
new DataView(bytes.buffer).setUint32(0, value);
|
|
41
|
+
return bytes;
|
|
42
|
+
}
|
|
43
|
+
/** SSH's encoding of a string of bytes: its length, then the bytes. */
|
|
44
|
+
function sshString(value) {
|
|
45
|
+
const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value;
|
|
46
|
+
return concat(uint32(bytes.length), bytes);
|
|
47
|
+
}
|
|
48
|
+
/** A new Ed25519 key pair, made with the browser's own Web Crypto, as `ssh-keygen -t ed25519 -N ""` would make it. */
|
|
49
|
+
async function newDeployKey(comment) {
|
|
50
|
+
const pair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
|
|
51
|
+
const publicKey = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
|
|
52
|
+
const seed = new Uint8Array(await crypto.subtle.exportKey("pkcs8", pair.privateKey)).slice(-32);
|
|
53
|
+
const publicBlob = concat(sshString("ssh-ed25519"), sshString(publicKey));
|
|
54
|
+
const check = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(4));
|
|
55
|
+
let secret = concat(check, check, sshString("ssh-ed25519"), sshString(publicKey), sshString(concat(seed, publicKey)), sshString(comment));
|
|
56
|
+
const padding = Array.from({ length: (8 - secret.length % 8) % 8 }, (_, index) => index + 1);
|
|
57
|
+
secret = concat(secret, new Uint8Array(padding));
|
|
58
|
+
const body = concat(new TextEncoder().encode("openssh-key-v1\0"), sshString("none"), sshString("none"), sshString(""), uint32(1), sshString(publicBlob), sshString(secret));
|
|
59
|
+
const lines = encodeBase64Bytes(body).match(/.{1,70}/g) ?? [];
|
|
60
|
+
seed.fill(0);
|
|
61
|
+
secret.fill(0);
|
|
62
|
+
return {
|
|
63
|
+
publicKey: `ssh-ed25519 ${encodeBase64Bytes(publicBlob)} ${comment}`,
|
|
64
|
+
privateKey: `-----BEGIN OPENSSH PRIVATE KEY-----\n${lines.join("\n")}\n-----END OPENSSH PRIVATE KEY-----\n`
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const SIGMA = new TextEncoder().encode("expand 32-byte k");
|
|
68
|
+
function words(bytes) {
|
|
69
|
+
return new Uint32Array(bytes.slice().buffer);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Encrypts a value for an Actions secret, as libsodium's `crypto_box_seal`
|
|
73
|
+
* does, which is what GitHub expects: an X25519 key of its own, the Web
|
|
74
|
+
* Crypto key agreement with the repository's public key, and XSalsa20-Poly1305.
|
|
75
|
+
*/
|
|
76
|
+
async function sealSecret(value, recipient) {
|
|
77
|
+
const ephemeral = await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]);
|
|
78
|
+
const ephemeralPublic = new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey));
|
|
79
|
+
const recipientKey = await crypto.subtle.importKey("raw", recipient.slice(), { name: "X25519" }, false, []);
|
|
80
|
+
const shared = new Uint8Array(await crypto.subtle.deriveBits({
|
|
81
|
+
name: "X25519",
|
|
82
|
+
public: recipientKey
|
|
83
|
+
}, ephemeral.privateKey, 256));
|
|
84
|
+
const key = /* @__PURE__ */ new Uint32Array(8);
|
|
85
|
+
hsalsa(words(SIGMA), words(shared), /* @__PURE__ */ new Uint32Array(4), key);
|
|
86
|
+
const nonce = blake2b(concat(ephemeralPublic, recipient), { dkLen: 24 });
|
|
87
|
+
const keyBytes = new Uint8Array(key.buffer);
|
|
88
|
+
const sealed = xsalsa20poly1305(keyBytes, nonce).encrypt(new TextEncoder().encode(value));
|
|
89
|
+
shared.fill(0);
|
|
90
|
+
keyBytes.fill(0);
|
|
91
|
+
return concat(ephemeralPublic, sealed);
|
|
92
|
+
}
|
|
93
|
+
/** Opens a sealed value with the recipient's X25519 key pair, as GitHub does with a secret. For the fake. */
|
|
94
|
+
async function openSealedSecret(sealed, recipient) {
|
|
95
|
+
const ephemeralPublic = sealed.slice(0, 32);
|
|
96
|
+
const recipientPublic = new Uint8Array(await crypto.subtle.exportKey("raw", recipient.publicKey));
|
|
97
|
+
const ephemeralKey = await crypto.subtle.importKey("raw", ephemeralPublic, { name: "X25519" }, false, []);
|
|
98
|
+
const shared = new Uint8Array(await crypto.subtle.deriveBits({
|
|
99
|
+
name: "X25519",
|
|
100
|
+
public: ephemeralKey
|
|
101
|
+
}, recipient.privateKey, 256));
|
|
102
|
+
const key = /* @__PURE__ */ new Uint32Array(8);
|
|
103
|
+
hsalsa(words(SIGMA), words(shared), /* @__PURE__ */ new Uint32Array(4), key);
|
|
104
|
+
const nonce = blake2b(concat(ephemeralPublic, recipientPublic), { dkLen: 24 });
|
|
105
|
+
return new TextDecoder().decode(xsalsa20poly1305(new Uint8Array(key.buffer), nonce).decrypt(sealed.slice(32)));
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
export { openSealedSecret as n, deploy_keys_exports as t };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, OwnerAccess, PagesDomains, RedirectOptions, SetupHost, SignInError, SiteDrafts, SiteEditors, SiteUpdates, StorageLike, TokenLink, WriteOptions } from "@goodfellow-cms/core";
|
|
2
2
|
//#region src/sign-in.d.ts
|
|
3
|
-
/** The sign-in worker Goodfellow runs for every site. Sites can run their own and name it in `github({ signIn })`. */
|
|
4
|
-
export declare const GOODFELLOW_SIGN_IN = "https://sign-in.goodfellow-cms.workers.dev";
|
|
5
3
|
/** The page's address and how to change it. Replaceable in tests. */
|
|
6
4
|
interface BrowserLocation {
|
|
7
5
|
href(): string;
|
|
@@ -42,6 +40,19 @@ interface GitHubBackendOptions {
|
|
|
42
40
|
name: string;
|
|
43
41
|
email: string;
|
|
44
42
|
};
|
|
43
|
+
/** The commit reads see until `revision()` is next called, as a full-stack site's server reads what a browser loaded. */
|
|
44
|
+
revision?: string;
|
|
45
|
+
/** The repository holds only the site's content, with its code in a repository of its own (`GitBackend.contentOnly`). */
|
|
46
|
+
contentOnly?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* The code repository, for a repository that holds only content, where the
|
|
49
|
+
* site's updates run: `api` reads it, absent where the sign-in can't.
|
|
50
|
+
*/
|
|
51
|
+
code?: {
|
|
52
|
+
repo: string;
|
|
53
|
+
webUrl: string;
|
|
54
|
+
api?: ApiOptions;
|
|
55
|
+
};
|
|
45
56
|
}
|
|
46
57
|
/**
|
|
47
58
|
* The site's files on GitHub. Reads come from the commit `revision()` last
|
|
@@ -53,8 +64,10 @@ export declare class GitHubBackend implements GitBackend {
|
|
|
53
64
|
readonly pages: PagesDomains;
|
|
54
65
|
readonly editors: SiteEditors;
|
|
55
66
|
readonly ownerAccess: OwnerAccess;
|
|
56
|
-
|
|
67
|
+
/** Absent where the repository holds only content: the update job runs where the code is. */
|
|
68
|
+
readonly updates?: SiteUpdates;
|
|
57
69
|
readonly drafts: SiteDrafts;
|
|
70
|
+
readonly contentOnly: boolean;
|
|
58
71
|
private readonly options;
|
|
59
72
|
private readonly api;
|
|
60
73
|
private readonly repo;
|
|
@@ -82,7 +95,7 @@ export declare class GitHubBackend implements GitBackend {
|
|
|
82
95
|
list(dir: string): Promise<string[]>;
|
|
83
96
|
fileSizes(dir: string): Promise<Map<string, number>>;
|
|
84
97
|
/** Uses an owner's sign-in through the worker for editors and GitHub Pages settings, until it's forgotten. */
|
|
85
|
-
useOwnerSignIn(token: TokenSource, end: () => void): void;
|
|
98
|
+
useOwnerSignIn(token: TokenSource, end: () => void, codeToken?: TokenSource): void;
|
|
86
99
|
/** Remembers why an owner's sign-in through the worker didn't work, which the Editors and Domain screens show. */
|
|
87
100
|
ownerSignInFailed(error: unknown): void;
|
|
88
101
|
write(changes: FileChange[], options: WriteOptions): Promise<{
|
|
@@ -122,7 +135,7 @@ interface GitHubSetupOptions {
|
|
|
122
135
|
apiUrl?: string;
|
|
123
136
|
/** For GitHub Enterprise. Defaults to `https://github.com`. */
|
|
124
137
|
webUrl?: string;
|
|
125
|
-
/** The sign-in worker, for "Sign in with GitHub".
|
|
138
|
+
/** The address of a sign-in worker, for "Sign in with GitHub". Without one, people sign in with a token. */
|
|
126
139
|
signIn?: string | false;
|
|
127
140
|
/** For tests. */
|
|
128
141
|
fetch?: typeof fetch;
|
|
@@ -136,8 +149,9 @@ interface GitHubSetupOptions {
|
|
|
136
149
|
}
|
|
137
150
|
/**
|
|
138
151
|
* Links to GitHub's token pages with what creating a site needs: creating the
|
|
139
|
-
* repository (administration), writing its files and
|
|
140
|
-
* (contents and workflows),
|
|
152
|
+
* repository and adding deploy keys (administration), writing its files and
|
|
153
|
+
* its build setup (contents and workflows), turning on GitHub Pages (pages),
|
|
154
|
+
* and giving a workflow the key to another of the site's repositories (secrets).
|
|
141
155
|
*/
|
|
142
156
|
export declare function githubSetupTokenLinks(webUrl?: string): {
|
|
143
157
|
fineGrained: string;
|
|
@@ -157,11 +171,22 @@ export interface GitHubOptions {
|
|
|
157
171
|
/** For GitHub Enterprise. Defaults to `https://github.com`. */
|
|
158
172
|
webUrl?: string;
|
|
159
173
|
/**
|
|
160
|
-
* The sign-in worker, which lets editors sign in with GitHub
|
|
161
|
-
* pasting a token.
|
|
162
|
-
* address of your own copy, or `false` for token sign-in only.
|
|
174
|
+
* The address of a sign-in worker, which lets editors sign in with GitHub
|
|
175
|
+
* instead of pasting a token. Without one, editors sign in with a token.
|
|
163
176
|
*/
|
|
164
177
|
signIn?: string | false;
|
|
178
|
+
/**
|
|
179
|
+
* The repository holds only the site's content (`content/` and
|
|
180
|
+
* `public/media/`), and its code is in a repository of its own. The admin
|
|
181
|
+
* panel then leaves adding blocks, which changes code, to a developer.
|
|
182
|
+
*/
|
|
183
|
+
contentOnly?: boolean;
|
|
184
|
+
/**
|
|
185
|
+
* With `contentOnly`, the repository the site's code is kept in, as
|
|
186
|
+
* `owner/name`, where its updates run: the Updates screen follows them.
|
|
187
|
+
* Editors' tokens need to reach it too: a sign-in worker's app installed on it, or tokens made for both.
|
|
188
|
+
*/
|
|
189
|
+
code?: string;
|
|
165
190
|
/** For tests. */
|
|
166
191
|
fetch?: typeof fetch;
|
|
167
192
|
/** For tests. */
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConflictError, DomainError, DraftError, EditorsError, GitApiError, SetupError, SignInError, UpdatesError, branchDrafts, buildStepFor, compareReleases, credentialStorage, encodeBase64, encodeBase64Bytes, siteVersion } from "@goodfellow-cms/core";
|
|
1
|
+
import { ConflictError, DomainError, DraftError, EditorsError, GitApiError, REBUILD_JOB, SetupError, SignInError, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, compareReleases, credentialStorage, decodeBase64, encodeBase64, encodeBase64Bytes, siteVersion } from "@goodfellow-cms/core";
|
|
2
2
|
//#region src/api.ts
|
|
3
3
|
/** Calls the GitHub REST or GraphQL API. Treats 401 as an expired sign-in; other errors become `GitApiError`. */
|
|
4
4
|
async function githubRequest({ apiUrl, token, fetch }, path, init = {}) {
|
|
@@ -148,6 +148,8 @@ function githubEditors(read, manage, repo, login) {
|
|
|
148
148
|
*/
|
|
149
149
|
function githubOwnerAccess(base, repo, tokenLink, redirect) {
|
|
150
150
|
let owner;
|
|
151
|
+
/** The owner's access to the site's code repository, where its content is kept in a repository of its own. */
|
|
152
|
+
let ownerCode;
|
|
151
153
|
let endSignIn;
|
|
152
154
|
let problem;
|
|
153
155
|
return {
|
|
@@ -177,21 +179,28 @@ function githubOwnerAccess(base, repo, tokenLink, redirect) {
|
|
|
177
179
|
throw error;
|
|
178
180
|
}
|
|
179
181
|
owner = api;
|
|
182
|
+
ownerCode = api;
|
|
180
183
|
problem = void 0;
|
|
181
184
|
},
|
|
182
185
|
forget() {
|
|
183
186
|
owner = void 0;
|
|
187
|
+
ownerCode = void 0;
|
|
184
188
|
endSignIn?.();
|
|
185
189
|
endSignIn = void 0;
|
|
186
190
|
}
|
|
187
191
|
},
|
|
188
192
|
api: () => owner,
|
|
189
|
-
|
|
190
|
-
|
|
193
|
+
codeApi: () => ownerCode,
|
|
194
|
+
/** Uses an owner's sign-in through the worker, with its token for the code repository if it gave one. `end` runs when it's forgotten. */
|
|
195
|
+
useSignIn(token, end, codeToken) {
|
|
191
196
|
owner = {
|
|
192
197
|
...base,
|
|
193
198
|
token
|
|
194
199
|
};
|
|
200
|
+
ownerCode = codeToken ? {
|
|
201
|
+
...base,
|
|
202
|
+
token: codeToken
|
|
203
|
+
} : void 0;
|
|
195
204
|
endSignIn = end;
|
|
196
205
|
problem = void 0;
|
|
197
206
|
},
|
|
@@ -322,17 +331,19 @@ function githubPages(api, repo) {
|
|
|
322
331
|
//#region src/updates.ts
|
|
323
332
|
/** The starters' GitHub Pages workflow, which runs `goodfellow update` each night and when it's dispatched. */
|
|
324
333
|
const WORKFLOW = "deploy.yml";
|
|
325
|
-
|
|
334
|
+
/** The code repository's workflow, where a site keeps its content in a repository of its own. */
|
|
335
|
+
const CODE_WORKFLOW = "update.yml";
|
|
326
336
|
/**
|
|
327
337
|
* The site's nightly update, which runs in its GitHub Pages workflow. Running
|
|
328
338
|
* it now dispatches the workflow, which needs GitHub's Actions permission
|
|
329
339
|
* with write access: `manage()` gives the owner token when one is in use.
|
|
330
340
|
*/
|
|
331
|
-
function githubUpdates(read, manage, repo, branch, files) {
|
|
332
|
-
const path = `/repos/${repo}/actions/workflows/${
|
|
341
|
+
function githubUpdates(read, manage, repo, branch, files, workflow = WORKFLOW) {
|
|
342
|
+
const path = `/repos/${repo}/actions/workflows/${workflow}`;
|
|
343
|
+
const workflowFile = `.github/workflows/${workflow}`;
|
|
333
344
|
return {
|
|
334
345
|
async setup() {
|
|
335
|
-
return (await files.read(
|
|
346
|
+
return (await files.read(workflowFile))?.includes("update --publish") ? "ready" : "missing";
|
|
336
347
|
},
|
|
337
348
|
async lastRun() {
|
|
338
349
|
const response = await githubRequest(read, `${path}/runs?branch=${encodeURIComponent(branch)}&per_page=20`, { allow: [403, 404] });
|
|
@@ -390,6 +401,73 @@ function githubUpdates(read, manage, repo, branch, files) {
|
|
|
390
401
|
}
|
|
391
402
|
};
|
|
392
403
|
}
|
|
404
|
+
/** Whether an error means the sign-in can't see the code repository, or has no token for it. */
|
|
405
|
+
function noAccess(error) {
|
|
406
|
+
return error instanceof SignInError && error.problem === "no-access" || error instanceof GitApiError && (error.status === 403 || error.status === 404);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* The nightly update of a site whose content is kept in a repository of its
|
|
410
|
+
* own, which runs in the code repository's `update.yml`. The sign-in needs to
|
|
411
|
+
* see the code repository too; where it can't, `setup()` says `no-access`.
|
|
412
|
+
*/
|
|
413
|
+
function githubCodeUpdates(options) {
|
|
414
|
+
const { read } = options;
|
|
415
|
+
const repo = repoPath(options.repo);
|
|
416
|
+
let branch;
|
|
417
|
+
const liveBranch = (api) => {
|
|
418
|
+
branch ??= githubJson(api, `/repos/${repo}`).then((found) => found.default_branch);
|
|
419
|
+
branch.catch(() => {
|
|
420
|
+
branch = void 0;
|
|
421
|
+
});
|
|
422
|
+
return branch;
|
|
423
|
+
};
|
|
424
|
+
/** The code repository at its newest commit, read again each time, since updates change it. */
|
|
425
|
+
async function files() {
|
|
426
|
+
if (!read) throw new SignInError("no-access", `This sign-in has no access to ${options.repo}.`);
|
|
427
|
+
return options.open(read, await liveBranch(read));
|
|
428
|
+
}
|
|
429
|
+
/** A file as it is now: setup files are outside the folders backends list, so they're read at the newest commit. */
|
|
430
|
+
async function readNow(path) {
|
|
431
|
+
const backend = await files();
|
|
432
|
+
return backend.readAt(path, await backend.revision());
|
|
433
|
+
}
|
|
434
|
+
async function updates() {
|
|
435
|
+
if (!read) throw new SignInError("no-access", `This sign-in has no access to ${options.repo}.`);
|
|
436
|
+
const target = await liveBranch(read);
|
|
437
|
+
return githubUpdates(read, options.manage, repo, target, {
|
|
438
|
+
read: readNow,
|
|
439
|
+
readAt: async (path, revision) => (await files()).readAt(path, revision)
|
|
440
|
+
}, CODE_WORKFLOW);
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
code: {
|
|
444
|
+
repo: options.repo,
|
|
445
|
+
webUrl: `${options.webUrl}/${options.repo}`,
|
|
446
|
+
read: readNow,
|
|
447
|
+
history: async (path, history) => (await files()).history(path, history),
|
|
448
|
+
readAt: async (path, revision) => (await files()).readAt(path, revision)
|
|
449
|
+
},
|
|
450
|
+
async setup() {
|
|
451
|
+
try {
|
|
452
|
+
return await (await updates()).setup();
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (noAccess(error)) return "no-access";
|
|
455
|
+
throw error;
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
async lastRun() {
|
|
459
|
+
try {
|
|
460
|
+
return await (await updates()).lastRun();
|
|
461
|
+
} catch (error) {
|
|
462
|
+
if (noAccess(error)) return void 0;
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
async start(target) {
|
|
467
|
+
await (await updates()).start(target);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
}
|
|
393
471
|
//#endregion
|
|
394
472
|
//#region src/backend.ts
|
|
395
473
|
/** How many files one GraphQL request reads. */
|
|
@@ -407,8 +485,10 @@ var GitHubBackend = class GitHubBackend {
|
|
|
407
485
|
pages;
|
|
408
486
|
editors;
|
|
409
487
|
ownerAccess;
|
|
488
|
+
/** Absent where the repository holds only content: the update job runs where the code is. */
|
|
410
489
|
updates;
|
|
411
490
|
drafts;
|
|
491
|
+
contentOnly;
|
|
412
492
|
options;
|
|
413
493
|
api;
|
|
414
494
|
repo;
|
|
@@ -429,12 +509,28 @@ var GitHubBackend = class GitHubBackend {
|
|
|
429
509
|
this.user = options.user;
|
|
430
510
|
this.onSignOut = options.onSignOut;
|
|
431
511
|
this.author = options.author;
|
|
512
|
+
this.pinned = options.revision;
|
|
432
513
|
const owner = githubOwnerAccess(this.api, this.repo, options.ownerTokenLink, options.ownerRedirect);
|
|
433
514
|
this.owner = owner;
|
|
434
515
|
this.ownerAccess = owner.access;
|
|
435
516
|
this.pages = githubPages(() => owner.api() ?? this.api, this.repo);
|
|
436
517
|
this.editors = githubEditors(this.api, owner.api, this.repo, this.user.login);
|
|
437
|
-
this.
|
|
518
|
+
this.contentOnly = options.contentOnly === true;
|
|
519
|
+
if (!this.contentOnly) this.updates = githubUpdates(this.api, owner.api, this.repo, this.branch, this);
|
|
520
|
+
else if (options.code) this.updates = githubCodeUpdates({
|
|
521
|
+
repo: options.code.repo,
|
|
522
|
+
webUrl: options.code.webUrl,
|
|
523
|
+
read: options.code.api,
|
|
524
|
+
manage: owner.codeApi,
|
|
525
|
+
open: (api, branch) => new GitHubBackend({
|
|
526
|
+
api,
|
|
527
|
+
repo: options.code?.repo ?? "",
|
|
528
|
+
branch,
|
|
529
|
+
user: this.user,
|
|
530
|
+
onSignOut: () => {},
|
|
531
|
+
ownerTokenLink: options.ownerTokenLink
|
|
532
|
+
})
|
|
533
|
+
});
|
|
438
534
|
this.drafts = branchDrafts(this, this.draftHost());
|
|
439
535
|
}
|
|
440
536
|
/** Drafts on branches of the repository, with pull requests for review. */
|
|
@@ -584,14 +680,15 @@ var GitHubBackend = class GitHubBackend {
|
|
|
584
680
|
return new Map([...sizes].filter(([path]) => path.startsWith(prefix)));
|
|
585
681
|
}
|
|
586
682
|
/** 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);
|
|
683
|
+
useOwnerSignIn(token, end, codeToken) {
|
|
684
|
+
this.owner.useSignIn(token, end, codeToken);
|
|
589
685
|
}
|
|
590
686
|
/** Remembers why an owner's sign-in through the worker didn't work, which the Editors and Domain screens show. */
|
|
591
687
|
ownerSignInFailed(error) {
|
|
592
688
|
this.owner.failed(error);
|
|
593
689
|
}
|
|
594
690
|
async write(changes, options) {
|
|
691
|
+
if (this.contentOnly) assertContentChanges(changes);
|
|
595
692
|
return this.author ? this.writeCommit(changes, options, this.author) : this.writeGraphQL(changes, options);
|
|
596
693
|
}
|
|
597
694
|
/**
|
|
@@ -781,7 +878,8 @@ var GitHubBackend = class GitHubBackend {
|
|
|
781
878
|
const response = await githubRequest(this.api, `/repos/${this.repo}/actions/runs?head_sha=${revision}&per_page=20`, { allow: [403, 404] });
|
|
782
879
|
if (!response.ok) return void 0;
|
|
783
880
|
const { workflow_runs: runs = [] } = await response.json();
|
|
784
|
-
|
|
881
|
+
const builds = runs.filter((run) => !run.path?.endsWith(`/${REBUILD_JOB}.yml`));
|
|
882
|
+
return builds.find((run) => /deploy|pages/i.test(`${run.path ?? ""} ${run.name ?? ""}`)) ?? builds[0];
|
|
785
883
|
}
|
|
786
884
|
/** Which step of a failed run failed. */
|
|
787
885
|
async runProblem(run) {
|
|
@@ -814,8 +912,6 @@ var GitHubBackend = class GitHubBackend {
|
|
|
814
912
|
* repository, lasting an hour. Requests are sent as plain text, so browsers
|
|
815
913
|
* send them straight away without asking the worker first.
|
|
816
914
|
*/
|
|
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
915
|
/** The worker's problems, as the admin panel's sign-in problems. */
|
|
820
916
|
const PROBLEMS = {
|
|
821
917
|
invalid: "invalid",
|
|
@@ -935,32 +1031,46 @@ var SignInService = class {
|
|
|
935
1031
|
});
|
|
936
1032
|
return `${this.url}/github/start?${query}`;
|
|
937
1033
|
}
|
|
938
|
-
/**
|
|
939
|
-
|
|
1034
|
+
/**
|
|
1035
|
+
* Exchanges the grant the worker came back with, and this tab's verifier,
|
|
1036
|
+
* for a session (and for a site, its first token, and with `code`, one for
|
|
1037
|
+
* its code repository).
|
|
1038
|
+
*/
|
|
1039
|
+
session(grant, verifier, code) {
|
|
940
1040
|
return this.post("/github/session", {
|
|
941
1041
|
grant,
|
|
942
|
-
verifier
|
|
1042
|
+
verifier,
|
|
1043
|
+
...code && { code }
|
|
943
1044
|
});
|
|
944
1045
|
}
|
|
945
|
-
token(session) {
|
|
946
|
-
return this.post("/github/token", {
|
|
1046
|
+
token(session, code) {
|
|
1047
|
+
return this.post("/github/token", {
|
|
1048
|
+
session,
|
|
1049
|
+
...code && { code }
|
|
1050
|
+
});
|
|
947
1051
|
}
|
|
948
1052
|
};
|
|
949
1053
|
/** A token that renews itself through the worker before it expires, or when GitHub stops accepting it. */
|
|
950
|
-
function renewingToken(service, first, onRenew) {
|
|
1054
|
+
function renewingToken(service, first, onRenew, code) {
|
|
951
1055
|
let current = first;
|
|
952
1056
|
let renewing;
|
|
1057
|
+
async function token({ refresh = false } = {}) {
|
|
1058
|
+
if (refresh || Date.parse(current.expiresAt) - 3e5 < Date.now()) {
|
|
1059
|
+
renewing ??= service.token(current.session, code).finally(() => {
|
|
1060
|
+
renewing = void 0;
|
|
1061
|
+
});
|
|
1062
|
+
current = await renewing;
|
|
1063
|
+
onRenew(current);
|
|
1064
|
+
}
|
|
1065
|
+
return current.token;
|
|
1066
|
+
}
|
|
953
1067
|
return {
|
|
954
1068
|
current: () => current,
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
current = await renewing;
|
|
961
|
-
onRenew(current);
|
|
962
|
-
}
|
|
963
|
-
return current.token;
|
|
1069
|
+
token,
|
|
1070
|
+
async codeToken(options) {
|
|
1071
|
+
await token(options);
|
|
1072
|
+
if (!current.code) throw new SignInError("no-access", "The sign-in service gave no token for the code repository.");
|
|
1073
|
+
return current.code.token;
|
|
964
1074
|
}
|
|
965
1075
|
};
|
|
966
1076
|
}
|
|
@@ -970,8 +1080,9 @@ function renewingToken(service, first, onRenew) {
|
|
|
970
1080
|
const MAIN = "main";
|
|
971
1081
|
/**
|
|
972
1082
|
* Links to GitHub's token pages with what creating a site needs: creating the
|
|
973
|
-
* repository (administration), writing its files and
|
|
974
|
-
* (contents and workflows),
|
|
1083
|
+
* repository and adding deploy keys (administration), writing its files and
|
|
1084
|
+
* its build setup (contents and workflows), turning on GitHub Pages (pages),
|
|
1085
|
+
* and giving a workflow the key to another of the site's repositories (secrets).
|
|
975
1086
|
*/
|
|
976
1087
|
function githubSetupTokenLinks(webUrl = "https://github.com") {
|
|
977
1088
|
const fineGrained = new URLSearchParams({
|
|
@@ -981,7 +1092,8 @@ function githubSetupTokenLinks(webUrl = "https://github.com") {
|
|
|
981
1092
|
administration: "write",
|
|
982
1093
|
contents: "write",
|
|
983
1094
|
workflows: "write",
|
|
984
|
-
pages: "write"
|
|
1095
|
+
pages: "write",
|
|
1096
|
+
secrets: "write"
|
|
985
1097
|
});
|
|
986
1098
|
const classic = new URLSearchParams({
|
|
987
1099
|
scopes: "repo,workflow",
|
|
@@ -994,6 +1106,8 @@ function githubSetupTokenLinks(webUrl = "https://github.com") {
|
|
|
994
1106
|
}
|
|
995
1107
|
/** The steps after the repository exists, which are the same however it was created. */
|
|
996
1108
|
function setupAccount(creator) {
|
|
1109
|
+
/** Each repository created, and the token for setting it up, for connecting it to another one later. */
|
|
1110
|
+
const createdApis = /* @__PURE__ */ new Map();
|
|
997
1111
|
/** Makes `main` the default branch, which the deploy setups build from. */
|
|
998
1112
|
async function useMainBranch(api, repo) {
|
|
999
1113
|
if (repo.default_branch === MAIN) return;
|
|
@@ -1108,6 +1222,53 @@ function setupAccount(creator) {
|
|
|
1108
1222
|
]
|
|
1109
1223
|
})).ok;
|
|
1110
1224
|
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Lets each private repository's workflows read the other, as their setup
|
|
1227
|
+
* files say: a read-only deploy key on the one read, with its private half
|
|
1228
|
+
* a secret of the one reading. Public repositories need nothing. Returns
|
|
1229
|
+
* whether it worked: an older sign-in may not be allowed to set secrets.
|
|
1230
|
+
*/
|
|
1231
|
+
async function connectRepositories(link) {
|
|
1232
|
+
if (!link.private) return true;
|
|
1233
|
+
const { newDeployKey, sealSecret } = await import("./deploy-keys-DAj2oX-N.js").then((n) => n.t);
|
|
1234
|
+
try {
|
|
1235
|
+
for (const { reader, read, secret } of link.links.reads) {
|
|
1236
|
+
if (!secret) continue;
|
|
1237
|
+
const readerRepo = link[reader];
|
|
1238
|
+
const readRepo = link[read];
|
|
1239
|
+
const readerApi = createdApis.get(readerRepo.toLowerCase());
|
|
1240
|
+
const readApi = createdApis.get(readRepo.toLowerCase());
|
|
1241
|
+
if (!readerApi || !readApi) throw new Error(`${readerRepo} and ${readRepo} have to be created first.`);
|
|
1242
|
+
const key = await newDeployKey(`goodfellow-${secret.toLowerCase()}`);
|
|
1243
|
+
await githubRequest(readApi, `/repos/${repoPath(readRepo)}/keys`, {
|
|
1244
|
+
method: "POST",
|
|
1245
|
+
body: {
|
|
1246
|
+
title: `${readerRepo}'s builds`,
|
|
1247
|
+
key: key.publicKey,
|
|
1248
|
+
read_only: true
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
const base = `/repos/${repoPath(readerRepo)}/actions/secrets`;
|
|
1252
|
+
const publicKey = await githubJson(readerApi, `${base}/public-key`);
|
|
1253
|
+
const sealed = await sealSecret(key.privateKey, decodeBase64(publicKey.key));
|
|
1254
|
+
await githubRequest(readerApi, `${base}/${secret}`, {
|
|
1255
|
+
method: "PUT",
|
|
1256
|
+
body: {
|
|
1257
|
+
encrypted_value: encodeBase64Bytes(sealed),
|
|
1258
|
+
key_id: publicKey.key_id
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
return true;
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
if (error instanceof GitApiError && [
|
|
1265
|
+
403,
|
|
1266
|
+
404,
|
|
1267
|
+
422
|
|
1268
|
+
].includes(error.status)) return false;
|
|
1269
|
+
throw error;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1111
1272
|
return {
|
|
1112
1273
|
user: creator.user,
|
|
1113
1274
|
owners: () => creator.owners(),
|
|
@@ -1116,6 +1277,10 @@ function setupAccount(creator) {
|
|
|
1116
1277
|
const warnings = [];
|
|
1117
1278
|
onStep?.("repository");
|
|
1118
1279
|
const { repo, api, signInInstallUrl } = await creator.create(site);
|
|
1280
|
+
const connect = async (link) => {
|
|
1281
|
+
onStep?.("connect");
|
|
1282
|
+
return connectRepositories(link);
|
|
1283
|
+
};
|
|
1119
1284
|
await useMainBranch(api, repo);
|
|
1120
1285
|
let siteUrl;
|
|
1121
1286
|
if (site.pages) {
|
|
@@ -1123,6 +1288,8 @@ function setupAccount(creator) {
|
|
|
1123
1288
|
siteUrl = await turnOnPages(api, repo);
|
|
1124
1289
|
if (!siteUrl) warnings.push("pages-unavailable");
|
|
1125
1290
|
}
|
|
1291
|
+
createdApis.set(repo.full_name.toLowerCase(), api);
|
|
1292
|
+
if (site.link && !await connect(site.link)) warnings.push("connect-unavailable");
|
|
1126
1293
|
onStep?.("files");
|
|
1127
1294
|
await writeFiles(api, repo, site);
|
|
1128
1295
|
onStep?.("protection");
|
|
@@ -1233,7 +1400,8 @@ function workerCreator(service, apiUrl, fetcher, signedIn) {
|
|
|
1233
1400
|
owner: site.owner.path,
|
|
1234
1401
|
name: site.name,
|
|
1235
1402
|
private: site.private,
|
|
1236
|
-
description: site.description
|
|
1403
|
+
description: site.description,
|
|
1404
|
+
...site.more && { more: true }
|
|
1237
1405
|
});
|
|
1238
1406
|
if (!created.repo || !created.token) throw new SignInError("unavailable", "The sign-in service didn't answer.");
|
|
1239
1407
|
return {
|
|
@@ -1264,7 +1432,7 @@ function githubSetup(options = {}) {
|
|
|
1264
1432
|
const links = githubSetupTokenLinks(options.webUrl);
|
|
1265
1433
|
const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
1266
1434
|
const location = options.location ?? browserLocation();
|
|
1267
|
-
const signInUrl = options.signIn
|
|
1435
|
+
const signInUrl = options.signIn;
|
|
1268
1436
|
const service = signInUrl && location && !isLocalAddress(location.href()) ? new SignInService(signInUrl, fetcher) : void 0;
|
|
1269
1437
|
const pending = credentialStorage("goodfellow:github:setup:pending", options.storage);
|
|
1270
1438
|
return {
|
|
@@ -1360,14 +1528,24 @@ function githubTokenLinks(repo, webUrl = "https://github.com") {
|
|
|
1360
1528
|
/** Sign-in and storage for a site whose repository is on GitHub. */
|
|
1361
1529
|
function github(options) {
|
|
1362
1530
|
if (!/^[\w.-]+\/[\w.-]+$/.test(options.repo)) throw new Error(`github({ repo }) must look like "owner/name" (got "${options.repo}").`);
|
|
1531
|
+
if (options.code !== void 0 && !/^[\w.-]+\/[\w.-]+$/.test(options.code)) throw new Error(`github({ code }) must look like "owner/name" (got "${options.code}").`);
|
|
1363
1532
|
const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
|
|
1533
|
+
const webUrl = (options.webUrl ?? "https://github.com").replace(/\/$/, "");
|
|
1534
|
+
/** The code repository, where the content is in a repository of its own, read with `api`. */
|
|
1535
|
+
const codeRepository = (api) => options.contentOnly && options.code ? { code: {
|
|
1536
|
+
repo: options.code,
|
|
1537
|
+
webUrl,
|
|
1538
|
+
...api && { api }
|
|
1539
|
+
} } : {};
|
|
1364
1540
|
const links = githubTokenLinks(options.repo, options.webUrl);
|
|
1365
1541
|
const storage = credentialStorage(`goodfellow:github:${options.repo}`, options.storage);
|
|
1366
1542
|
const pending = credentialStorage(`goodfellow:github:pending:${options.repo}`, options.storage);
|
|
1367
1543
|
const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
1368
1544
|
const location = options.location ?? browserLocation();
|
|
1369
|
-
const signInUrl = options.signIn
|
|
1545
|
+
const signInUrl = options.signIn;
|
|
1370
1546
|
const service = signInUrl && location && !isLocalAddress(location.href()) ? new SignInService(signInUrl, fetcher) : void 0;
|
|
1547
|
+
/** The code repository to ask the worker for a token for, where the Updates screen follows it. */
|
|
1548
|
+
const codeName = () => options.contentOnly ? options.code : void 0;
|
|
1371
1549
|
function save(value, remember) {
|
|
1372
1550
|
storage.save(value.type === "token" ? value.token : JSON.stringify(value), remember);
|
|
1373
1551
|
}
|
|
@@ -1410,7 +1588,7 @@ function github(options) {
|
|
|
1410
1588
|
const source = renewingToken(service, issued, (renewed) => save({
|
|
1411
1589
|
type: "worker",
|
|
1412
1590
|
session: renewed.session
|
|
1413
|
-
}, remember));
|
|
1591
|
+
}, remember), options.code);
|
|
1414
1592
|
save({
|
|
1415
1593
|
type: "worker",
|
|
1416
1594
|
session: issued.session
|
|
@@ -1426,6 +1604,12 @@ function github(options) {
|
|
|
1426
1604
|
api,
|
|
1427
1605
|
repo: options.repo,
|
|
1428
1606
|
branch: options.branch ?? repo.default_branch,
|
|
1607
|
+
...options.contentOnly && { contentOnly: true },
|
|
1608
|
+
...codeRepository({
|
|
1609
|
+
apiUrl,
|
|
1610
|
+
token: source.codeToken,
|
|
1611
|
+
fetch: fetcher
|
|
1612
|
+
}),
|
|
1429
1613
|
user: {
|
|
1430
1614
|
login: user.login,
|
|
1431
1615
|
name: user.name,
|
|
@@ -1437,7 +1621,7 @@ function github(options) {
|
|
|
1437
1621
|
},
|
|
1438
1622
|
onSignOut: () => {
|
|
1439
1623
|
storage.clear();
|
|
1440
|
-
|
|
1624
|
+
revokeIssued(source.current());
|
|
1441
1625
|
},
|
|
1442
1626
|
ownerTokenLink: {
|
|
1443
1627
|
url: links.owner,
|
|
@@ -1454,12 +1638,17 @@ function github(options) {
|
|
|
1454
1638
|
fetch: fetcher
|
|
1455
1639
|
}, "/installation/token", { method: "DELETE" }).catch(() => {});
|
|
1456
1640
|
}
|
|
1641
|
+
/** Revokes the tokens the worker gave: the site's, and the code repository's. */
|
|
1642
|
+
function revokeIssued(issued) {
|
|
1643
|
+
revokeToken(issued.token);
|
|
1644
|
+
if (issued.code) revokeToken(issued.code.token);
|
|
1645
|
+
}
|
|
1457
1646
|
/** Gives the backend an owner's sign-in that has just come back from GitHub. It's kept in memory only. */
|
|
1458
1647
|
async function useOwner(backend, grant, verifier) {
|
|
1459
1648
|
const worker = service;
|
|
1460
1649
|
try {
|
|
1461
|
-
const source = renewingToken(worker, await worker.session(grant, verifier), () => {});
|
|
1462
|
-
backend.useOwnerSignIn(source.token, () =>
|
|
1650
|
+
const source = renewingToken(worker, await worker.session(grant, verifier, codeName()), () => {}, codeName());
|
|
1651
|
+
backend.useOwnerSignIn(source.token, () => revokeIssued(source.current()), codeName() ? source.codeToken : void 0);
|
|
1463
1652
|
} catch (error) {
|
|
1464
1653
|
backend.ownerSignInFailed(error);
|
|
1465
1654
|
}
|
|
@@ -1486,7 +1675,7 @@ function github(options) {
|
|
|
1486
1675
|
return backend;
|
|
1487
1676
|
}
|
|
1488
1677
|
if (failed || !grant) throw signInProblem({ problem: failed });
|
|
1489
|
-
return connectWorker(await service.session(grant, redirect.verifier), redirect.remember);
|
|
1678
|
+
return connectWorker(await service.session(grant, redirect.verifier, codeName()), redirect.remember);
|
|
1490
1679
|
}
|
|
1491
1680
|
async function restoreSaved() {
|
|
1492
1681
|
const saved = loadSaved();
|
|
@@ -1497,7 +1686,7 @@ function github(options) {
|
|
|
1497
1686
|
storage.clear();
|
|
1498
1687
|
return null;
|
|
1499
1688
|
}
|
|
1500
|
-
return await connectWorker(await service.token(saved.session), storage.remembered());
|
|
1689
|
+
return await connectWorker(await service.token(saved.session, codeName()), storage.remembered());
|
|
1501
1690
|
} catch (error) {
|
|
1502
1691
|
if (forgets(error)) storage.clear();
|
|
1503
1692
|
throw error;
|
|
@@ -1522,6 +1711,8 @@ function github(options) {
|
|
|
1522
1711
|
api,
|
|
1523
1712
|
repo: options.repo,
|
|
1524
1713
|
branch: options.branch ?? repo.default_branch,
|
|
1714
|
+
...options.contentOnly && { contentOnly: true },
|
|
1715
|
+
...codeRepository(api),
|
|
1525
1716
|
user: {
|
|
1526
1717
|
login: user.login,
|
|
1527
1718
|
name: user.name ?? void 0,
|
|
@@ -1535,6 +1726,46 @@ function github(options) {
|
|
|
1535
1726
|
...service && { ownerRedirect: (redirectOptions) => redirect("owner", false, redirectOptions) }
|
|
1536
1727
|
});
|
|
1537
1728
|
}
|
|
1729
|
+
/** The repository through a token of the site's own, on a full-stack site's server. */
|
|
1730
|
+
function server({ token }) {
|
|
1731
|
+
const api = {
|
|
1732
|
+
apiUrl,
|
|
1733
|
+
token: typeof token === "string" ? token : () => token(),
|
|
1734
|
+
fetch: fetcher
|
|
1735
|
+
};
|
|
1736
|
+
let branch;
|
|
1737
|
+
const liveBranch = () => {
|
|
1738
|
+
branch ??= options.branch ? Promise.resolve(options.branch) : githubJson(api, `/repos/${repoPath(options.repo)}`).then((repo) => repo.default_branch);
|
|
1739
|
+
branch.catch(() => {
|
|
1740
|
+
branch = void 0;
|
|
1741
|
+
});
|
|
1742
|
+
return branch;
|
|
1743
|
+
};
|
|
1744
|
+
const backend = async (revision, author) => new GitHubBackend({
|
|
1745
|
+
api,
|
|
1746
|
+
repo: options.repo,
|
|
1747
|
+
branch: await liveBranch(),
|
|
1748
|
+
...options.contentOnly && { contentOnly: true },
|
|
1749
|
+
user: {
|
|
1750
|
+
login: author?.name ?? options.repo,
|
|
1751
|
+
...author && { name: author.name }
|
|
1752
|
+
},
|
|
1753
|
+
onSignOut: () => {},
|
|
1754
|
+
ownerTokenLink: {
|
|
1755
|
+
url: links.owner,
|
|
1756
|
+
label: "owner.token.create"
|
|
1757
|
+
},
|
|
1758
|
+
...revision && { revision },
|
|
1759
|
+
...author && { author }
|
|
1760
|
+
});
|
|
1761
|
+
return {
|
|
1762
|
+
...options.contentOnly && { contentOnly: true },
|
|
1763
|
+
async revision() {
|
|
1764
|
+
return (await backend()).revision();
|
|
1765
|
+
},
|
|
1766
|
+
at: backend
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1538
1769
|
return {
|
|
1539
1770
|
name: "GitHub",
|
|
1540
1771
|
canRedirect: service !== void 0,
|
|
@@ -1563,8 +1794,9 @@ function github(options) {
|
|
|
1563
1794
|
},
|
|
1564
1795
|
startRedirect(remember, redirectOptions) {
|
|
1565
1796
|
return redirect("edit", remember, redirectOptions);
|
|
1566
|
-
}
|
|
1797
|
+
},
|
|
1798
|
+
server
|
|
1567
1799
|
};
|
|
1568
1800
|
}
|
|
1569
1801
|
//#endregion
|
|
1570
|
-
export {
|
|
1802
|
+
export { GitHubBackend, github, githubPagesRecords, githubSetup, githubSetupTokenLinks, githubTokenLinks };
|
package/dist/testing.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface FakeGitHubUser {
|
|
|
20
20
|
actions?: boolean;
|
|
21
21
|
/** Whether the token may run workflows, as owner tokens can. Defaults to the `administration` setting. */
|
|
22
22
|
actionsWrite?: boolean;
|
|
23
|
+
/** Whether the token may set Actions secrets and add deploy keys. Defaults to true. */
|
|
24
|
+
secrets?: boolean;
|
|
23
25
|
}
|
|
24
26
|
/** A GitHub Actions workflow run, with its jobs and their steps. */
|
|
25
27
|
export interface FakeRun {
|
|
@@ -32,6 +34,8 @@ export interface FakeRun {
|
|
|
32
34
|
createdAt?: string;
|
|
33
35
|
status: "queued" | "in_progress" | "completed";
|
|
34
36
|
conclusion: "success" | "failure" | "startup_failure" | "cancelled" | null;
|
|
37
|
+
/** The workflow's file, `.github/workflows/deploy.yml` by default. */
|
|
38
|
+
path?: string;
|
|
35
39
|
jobs: Array<{
|
|
36
40
|
name: string;
|
|
37
41
|
conclusion: string | null;
|
|
@@ -127,6 +131,18 @@ export interface FakeCreatedRepo {
|
|
|
127
131
|
build_type: string;
|
|
128
132
|
};
|
|
129
133
|
rulesets: unknown[];
|
|
134
|
+
/** Deploy keys added to it, by their public key. */
|
|
135
|
+
deployKeys: Array<{
|
|
136
|
+
title: string;
|
|
137
|
+
key: string;
|
|
138
|
+
read_only: boolean;
|
|
139
|
+
}>;
|
|
140
|
+
/** Its Actions secrets, opened. */
|
|
141
|
+
secrets: Map<string, string>;
|
|
142
|
+
/** The key pair GitHub seals secrets with. */
|
|
143
|
+
secretKey?: CryptoKeyPair;
|
|
144
|
+
/** People's permission on it, by login, as `collaborators/{login}/permission` says; others have none. */
|
|
145
|
+
collaborators?: Map<string, "admin" | "write" | "read">;
|
|
130
146
|
}
|
|
131
147
|
interface Deployment {
|
|
132
148
|
id: number;
|
|
@@ -150,8 +166,13 @@ export declare function fakeGitHub(options: FakeGitHubOptions): {
|
|
|
150
166
|
code: string;
|
|
151
167
|
installation: number;
|
|
152
168
|
};
|
|
153
|
-
/** Repositories created through the API, by `owner/name`. */
|
|
169
|
+
/** Repositories created through the API, or added with `addRepo()`, by `owner/name`. */
|
|
154
170
|
created: Map<string, FakeCreatedRepo>;
|
|
171
|
+
addRepo: (fullName: string, repoOptions?: {
|
|
172
|
+
files?: Record<string, string>;
|
|
173
|
+
private?: boolean;
|
|
174
|
+
collaborators?: Record<string, "admin" | "write" | "read">;
|
|
175
|
+
}) => FakeCreatedRepo;
|
|
155
176
|
/** The site's GitHub Pages settings, which tests can change, such as to issue the certificate. */
|
|
156
177
|
pages: {
|
|
157
178
|
current?: FakePages;
|
package/dist/testing.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { n as openSealedSecret } from "./deploy-keys-DAj2oX-N.js";
|
|
1
2
|
import { decodeBase64, encodeBase64Bytes } from "@goodfellow-cms/core";
|
|
2
3
|
import { FakeConflictError, FakeRepo, fakeFileBytes, fakeFileFromBytes } from "@goodfellow-cms/core/testing";
|
|
3
4
|
//#region src/testing.ts
|
|
@@ -192,6 +193,21 @@ function fakeGitHub(options) {
|
|
|
192
193
|
const [invitation] = index === -1 ? [] : invitations.splice(index, 1);
|
|
193
194
|
if (invitation) collaborators.set(login, invitation.permission === "admin" ? "admin" : "push");
|
|
194
195
|
}
|
|
196
|
+
/** Adds another repository, as if someone had made it, such as a site's code repository beside its content's. */
|
|
197
|
+
function addRepo(fullName, repoOptions = {}) {
|
|
198
|
+
const added = {
|
|
199
|
+
id: nextRepoId++,
|
|
200
|
+
fullName,
|
|
201
|
+
private: repoOptions.private === true,
|
|
202
|
+
repo: new FakeRepo(repoOptions.files ?? {}, "main"),
|
|
203
|
+
rulesets: [],
|
|
204
|
+
deployKeys: [],
|
|
205
|
+
secrets: /* @__PURE__ */ new Map(),
|
|
206
|
+
collaborators: new Map(Object.entries(repoOptions.collaborators ?? {}))
|
|
207
|
+
};
|
|
208
|
+
created.set(fullName, added);
|
|
209
|
+
return added;
|
|
210
|
+
}
|
|
195
211
|
/** Repository creation and the Git data API, for repositories created through the fake. */
|
|
196
212
|
async function handleCreated(request, path, principal) {
|
|
197
213
|
const login = principal.login;
|
|
@@ -215,7 +231,9 @@ function fakeGitHub(options) {
|
|
|
215
231
|
private: body.private === true,
|
|
216
232
|
description: body.description,
|
|
217
233
|
repo,
|
|
218
|
-
rulesets: []
|
|
234
|
+
rulesets: [],
|
|
235
|
+
deployKeys: [],
|
|
236
|
+
secrets: /* @__PURE__ */ new Map()
|
|
219
237
|
});
|
|
220
238
|
return json({
|
|
221
239
|
id,
|
|
@@ -234,12 +252,48 @@ function fakeGitHub(options) {
|
|
|
234
252
|
if (rest === "" && method === "GET") return json({
|
|
235
253
|
id: site.id,
|
|
236
254
|
full_name: site.fullName,
|
|
255
|
+
private: site.private,
|
|
237
256
|
default_branch: repo.defaultBranch,
|
|
238
257
|
permissions: {
|
|
239
258
|
push: true,
|
|
240
259
|
admin: true
|
|
241
260
|
}
|
|
242
261
|
});
|
|
262
|
+
const tree = rest.match(/^\/git\/trees\/([0-9a-f]{40})$/);
|
|
263
|
+
if (tree && method === "GET" && repo.commitAt(tree[1] ?? "")) {
|
|
264
|
+
const entries = [...repo.files(tree[1] ?? "")].map(([file, content]) => ({
|
|
265
|
+
path: file,
|
|
266
|
+
type: "blob",
|
|
267
|
+
sha: repo.blobSha(content),
|
|
268
|
+
size: fakeFileBytes(content).length
|
|
269
|
+
}));
|
|
270
|
+
return json({
|
|
271
|
+
sha: tree[1],
|
|
272
|
+
tree: entries,
|
|
273
|
+
truncated: false
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const blob = rest.match(/^\/git\/blobs\/([0-9a-f]{40})$/);
|
|
277
|
+
if (blob && method === "GET") {
|
|
278
|
+
const content = repo.blobs.get(blob[1] ?? "");
|
|
279
|
+
if (content === void 0) return json({ message: "Not Found" }, 404);
|
|
280
|
+
return new Response(fakeFileBytes(content));
|
|
281
|
+
}
|
|
282
|
+
const contents = rest.match(/^\/contents\/(.+)$/);
|
|
283
|
+
if (contents && method === "GET") {
|
|
284
|
+
if (principal.app && !principal.permissions?.contents) return json({ message: "Resource not accessible" }, 403);
|
|
285
|
+
const file = repo.files().get(decodeURIComponent(contents[1] ?? ""));
|
|
286
|
+
if (file === void 0) return json({ message: "Not Found" }, 404);
|
|
287
|
+
return new Response(typeof file === "string" ? file : fakeFileBytes(file));
|
|
288
|
+
}
|
|
289
|
+
const permission = rest.match(/^\/collaborators\/([^/]+)\/permission$/);
|
|
290
|
+
if (permission && method === "GET") {
|
|
291
|
+
const login = decodeURIComponent(permission[1] ?? "");
|
|
292
|
+
return json({
|
|
293
|
+
permission: site.collaborators?.get(login) ?? "none",
|
|
294
|
+
user: { login }
|
|
295
|
+
});
|
|
296
|
+
}
|
|
243
297
|
if (rest === "" && method === "PATCH") {
|
|
244
298
|
const body = await request.json();
|
|
245
299
|
if (body.default_branch) {
|
|
@@ -279,6 +333,42 @@ function fakeGitHub(options) {
|
|
|
279
333
|
build_type: site.pages.build_type
|
|
280
334
|
}, 201);
|
|
281
335
|
}
|
|
336
|
+
const notAllowed = (permission) => principal.app ? principal.permissions?.[permission] !== "write" : principal.secrets === false;
|
|
337
|
+
if (rest === "/keys" && method === "POST") {
|
|
338
|
+
if (notAllowed("administration")) return json({ message: "Resource not accessible by integration" }, 403);
|
|
339
|
+
const body = await request.json();
|
|
340
|
+
if (!/^ssh-ed25519 [A-Za-z0-9+/=]+( |$)/.test(body.key)) return json({ message: "key is invalid" }, 422);
|
|
341
|
+
site.deployKeys.push({
|
|
342
|
+
title: body.title,
|
|
343
|
+
key: body.key,
|
|
344
|
+
read_only: body.read_only === true
|
|
345
|
+
});
|
|
346
|
+
return json({
|
|
347
|
+
id: site.deployKeys.length,
|
|
348
|
+
...body
|
|
349
|
+
}, 201);
|
|
350
|
+
}
|
|
351
|
+
if (rest === "/actions/secrets/public-key" && method === "GET") {
|
|
352
|
+
if (notAllowed("secrets")) return json({ message: "Resource not accessible by integration" }, 403);
|
|
353
|
+
site.secretKey ??= await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]);
|
|
354
|
+
const raw = new Uint8Array(await crypto.subtle.exportKey("raw", site.secretKey.publicKey));
|
|
355
|
+
return json({
|
|
356
|
+
key_id: `key-${site.id}`,
|
|
357
|
+
key: encodeBase64Bytes(raw)
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
const secret = rest.match(/^\/actions\/secrets\/([A-Z0-9_]+)$/);
|
|
361
|
+
if (secret && method === "PUT") {
|
|
362
|
+
if (notAllowed("secrets")) return json({ message: "Resource not accessible by integration" }, 403);
|
|
363
|
+
const body = await request.json();
|
|
364
|
+
if (!site.secretKey || body.key_id !== `key-${site.id}`) return json({ message: "Bad key id" }, 422);
|
|
365
|
+
try {
|
|
366
|
+
site.secrets.set(secret[1] ?? "", await openSealedSecret(decodeBase64(body.encrypted_value), site.secretKey));
|
|
367
|
+
} catch {
|
|
368
|
+
return json({ message: "Bad encrypted value" }, 422);
|
|
369
|
+
}
|
|
370
|
+
return new Response(null, { status: 201 });
|
|
371
|
+
}
|
|
282
372
|
if (rest === "/rulesets" && method === "POST") {
|
|
283
373
|
if (site.private && !options.paidPlan) return json({ message: "Upgrade to GitHub Pro" }, 403);
|
|
284
374
|
site.rulesets.push(await request.json());
|
|
@@ -814,7 +904,10 @@ function fakeGitHub(options) {
|
|
|
814
904
|
author: null
|
|
815
905
|
})));
|
|
816
906
|
}
|
|
817
|
-
|
|
907
|
+
const workflowFile = rest.match(/^\/actions\/workflows\/([\w.-]+)\/(?:dispatches|runs)$/)?.[1];
|
|
908
|
+
const workflowPath = `.github/workflows/${workflowFile}`;
|
|
909
|
+
const ofWorkflow = (run) => (run.path ?? ".github/workflows/deploy.yml") === workflowPath;
|
|
910
|
+
if (workflowFile && rest.endsWith("/dispatches") && request.method === "POST") {
|
|
818
911
|
if (!(user.actionsWrite ?? user.administration)) return json({ message: "Resource not accessible by personal access token" }, 403);
|
|
819
912
|
const body = await request.json();
|
|
820
913
|
runs.push({
|
|
@@ -824,15 +917,17 @@ function fakeGitHub(options) {
|
|
|
824
917
|
inputs: body.inputs,
|
|
825
918
|
status: "queued",
|
|
826
919
|
conclusion: null,
|
|
920
|
+
path: workflowPath,
|
|
827
921
|
jobs: []
|
|
828
922
|
});
|
|
829
923
|
return new Response(null, { status: 204 });
|
|
830
924
|
}
|
|
831
|
-
if (
|
|
925
|
+
if (workflowFile && rest.endsWith("/runs")) {
|
|
832
926
|
if (user.actions === false) return json({ message: "Resource not accessible by personal access token" }, 403);
|
|
927
|
+
const found = runs.filter(ofWorkflow);
|
|
833
928
|
return json({
|
|
834
|
-
total_count:
|
|
835
|
-
workflow_runs: [...
|
|
929
|
+
total_count: found.length,
|
|
930
|
+
workflow_runs: [...found].reverse().map((run) => ({
|
|
836
931
|
id: run.id,
|
|
837
932
|
event: run.event ?? "push",
|
|
838
933
|
head_sha: run.sha,
|
|
@@ -856,8 +951,8 @@ function fakeGitHub(options) {
|
|
|
856
951
|
head_sha: run.sha,
|
|
857
952
|
status: run.status,
|
|
858
953
|
conclusion: run.conclusion,
|
|
859
|
-
name: "Deploy to GitHub Pages",
|
|
860
|
-
path: ".github/workflows/deploy.yml",
|
|
954
|
+
name: run.path ? "Rebuild on Vercel" : "Deploy to GitHub Pages",
|
|
955
|
+
path: run.path ?? ".github/workflows/deploy.yml",
|
|
861
956
|
html_url: htmlUrl(run)
|
|
862
957
|
}))
|
|
863
958
|
});
|
|
@@ -913,8 +1008,9 @@ function fakeGitHub(options) {
|
|
|
913
1008
|
commitAuthors,
|
|
914
1009
|
authorize,
|
|
915
1010
|
install,
|
|
916
|
-
/** Repositories created through the API, by `owner/name`. */
|
|
1011
|
+
/** Repositories created through the API, or added with `addRepo()`, by `owner/name`. */
|
|
917
1012
|
created,
|
|
1013
|
+
addRepo,
|
|
918
1014
|
/** The site's GitHub Pages settings, which tests can change, such as to issue the certificate. */
|
|
919
1015
|
pages,
|
|
920
1016
|
/** The repository's collaborators, by login, and invitations waiting to be accepted. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodfellow-cms/github",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Edit a Goodfellow site stored on GitHub, straight from the browser.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"goodfellow",
|
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
"dist"
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@
|
|
38
|
+
"@noble/ciphers": "2.4.0",
|
|
39
|
+
"@noble/hashes": "2.4.0",
|
|
40
|
+
"@goodfellow-cms/core": "0.4.0"
|
|
39
41
|
},
|
|
40
42
|
"devDependencies": {
|
|
41
43
|
"@puckeditor/core": "0.23.0"
|