@goodfellow-cms/github 0.0.0-stage → 0.1.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 +79 -0
- package/dist/index.js +265 -0
- package/dist/testing.d.ts +44 -0
- package/dist/testing.js +155 -0
- package/package.json +51 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Goodfellow contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @goodfellow-cms/github
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Edit a Goodfellow site stored on GitHub, straight from the browser: sign-in with an access token, publishing as single commits, conflict checks and deploy status.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { github } from "@goodfellow-cms/github";
|
|
7
|
+
|
|
8
|
+
export default defineConfig({ blocks, backend: github({ repo: "your-name/your-site" }) });
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
[Goodfellow](https://github.com/chartung17/goodfellow-cms) is a website builder for people who aren't developers, with no server or database: pages are files in your own GitHub or GitLab repository, edited in a visual editor built on [Puck](https://puckeditor.com). To start a new site, run `npm create goodfellow@latest my-site`. See the [documentation](https://chartung17.github.io/goodfellow-cms/) for everything else.
|
|
12
|
+
|
|
13
|
+
More in the docs: [Hosts and business sites](https://chartung17.github.io/goodfellow-cms/docs/hosts).
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { DeployStatus, FileChange, GitBackend, GitHost, GitUser, StorageLike, WriteOptions } from "@goodfellow-cms/core";
|
|
2
|
+
//#region src/api.d.ts
|
|
3
|
+
interface ApiOptions {
|
|
4
|
+
apiUrl: string;
|
|
5
|
+
token: string;
|
|
6
|
+
fetch: typeof fetch;
|
|
7
|
+
}
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/backend.d.ts
|
|
10
|
+
interface GitHubBackendOptions {
|
|
11
|
+
api: ApiOptions;
|
|
12
|
+
repo: string;
|
|
13
|
+
branch: string;
|
|
14
|
+
user: GitUser;
|
|
15
|
+
onSignOut: () => void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The site's files on GitHub. Reads come from the commit `revision()` last
|
|
19
|
+
* returned; saves are single commits made with GraphQL's `createCommitOnBranch`,
|
|
20
|
+
* which fails if the branch has moved, so nothing is ever overwritten.
|
|
21
|
+
*/
|
|
22
|
+
export declare class GitHubBackend implements GitBackend {
|
|
23
|
+
readonly user: GitUser;
|
|
24
|
+
private readonly api;
|
|
25
|
+
private readonly repo;
|
|
26
|
+
private readonly branch;
|
|
27
|
+
private readonly onSignOut;
|
|
28
|
+
private pinned?;
|
|
29
|
+
private readonly trees;
|
|
30
|
+
private readonly blobs;
|
|
31
|
+
constructor(options: GitHubBackendOptions);
|
|
32
|
+
revision(): Promise<string>;
|
|
33
|
+
/** Path → blob id for every file at a commit. Commits never change, so this is cached. */
|
|
34
|
+
private tree;
|
|
35
|
+
private currentTree;
|
|
36
|
+
read(path: string): Promise<string | undefined>;
|
|
37
|
+
readBytes(path: string): Promise<Uint8Array | undefined>;
|
|
38
|
+
list(dir: string): Promise<string[]>;
|
|
39
|
+
write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
|
|
40
|
+
revision: string;
|
|
41
|
+
}>;
|
|
42
|
+
changedPaths(from: string, to: string): Promise<string[]>;
|
|
43
|
+
/**
|
|
44
|
+
* Reads the deployment GitHub Pages or Vercel created for a commit. Before
|
|
45
|
+
* one exists, the site counts as building if it has ever been deployed.
|
|
46
|
+
*/
|
|
47
|
+
deployStatus(revision: string): Promise<DeployStatus>;
|
|
48
|
+
signOut(): void;
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/index.d.ts
|
|
52
|
+
export interface GitHubOptions {
|
|
53
|
+
/** The site's repository, as `owner/name`. */
|
|
54
|
+
repo: string;
|
|
55
|
+
/** The branch the live site is built from. Defaults to the repository's default branch. */
|
|
56
|
+
branch?: string;
|
|
57
|
+
/** For GitHub Enterprise. Defaults to `https://api.github.com`. */
|
|
58
|
+
apiUrl?: string;
|
|
59
|
+
/** For GitHub Enterprise. Defaults to `https://github.com`. */
|
|
60
|
+
webUrl?: string;
|
|
61
|
+
/** For tests. */
|
|
62
|
+
fetch?: typeof fetch;
|
|
63
|
+
/** For tests. */
|
|
64
|
+
storage?: {
|
|
65
|
+
local?: StorageLike;
|
|
66
|
+
session?: StorageLike;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Links to GitHub's token page with everything filled in except the
|
|
71
|
+
* repository, which GitHub doesn't let links choose.
|
|
72
|
+
*/
|
|
73
|
+
export declare function githubTokenLinks(repo: string, webUrl?: string): {
|
|
74
|
+
fineGrained: string;
|
|
75
|
+
classic: string;
|
|
76
|
+
};
|
|
77
|
+
/** Sign-in and storage for a site whose repository is on GitHub. */
|
|
78
|
+
export declare function github(options: GitHubOptions): GitHost;
|
|
79
|
+
//#endregion
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { ConflictError, GitApiError, SignInError, credentialStorage, encodeBase64, encodeBase64Bytes } 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/backend.ts
|
|
32
|
+
const COMMIT_MUTATION = `mutation ($input: CreateCommitOnBranchInput!) {
|
|
33
|
+
createCommitOnBranch(input: $input) { commit { oid } }
|
|
34
|
+
}`;
|
|
35
|
+
/**
|
|
36
|
+
* The site's files on GitHub. Reads come from the commit `revision()` last
|
|
37
|
+
* returned; saves are single commits made with GraphQL's `createCommitOnBranch`,
|
|
38
|
+
* which fails if the branch has moved, so nothing is ever overwritten.
|
|
39
|
+
*/
|
|
40
|
+
var GitHubBackend = class {
|
|
41
|
+
user;
|
|
42
|
+
api;
|
|
43
|
+
repo;
|
|
44
|
+
branch;
|
|
45
|
+
onSignOut;
|
|
46
|
+
pinned;
|
|
47
|
+
trees = /* @__PURE__ */ new Map();
|
|
48
|
+
blobs = /* @__PURE__ */ new Map();
|
|
49
|
+
constructor(options) {
|
|
50
|
+
this.api = options.api;
|
|
51
|
+
this.repo = repoPath(options.repo);
|
|
52
|
+
this.branch = options.branch;
|
|
53
|
+
this.user = options.user;
|
|
54
|
+
this.onSignOut = options.onSignOut;
|
|
55
|
+
}
|
|
56
|
+
async revision() {
|
|
57
|
+
const ref = await githubJson(this.api, `/repos/${this.repo}/git/ref/heads/${encodeURIComponent(this.branch)}`);
|
|
58
|
+
this.pinned = ref.object.sha;
|
|
59
|
+
return this.pinned;
|
|
60
|
+
}
|
|
61
|
+
/** Path → blob id for every file at a commit. Commits never change, so this is cached. */
|
|
62
|
+
tree(sha) {
|
|
63
|
+
let tree = this.trees.get(sha);
|
|
64
|
+
if (!tree) {
|
|
65
|
+
tree = githubJson(this.api, `/repos/${this.repo}/git/trees/${sha}?recursive=1`).then((response) => {
|
|
66
|
+
if (response.truncated) throw new GitApiError(413, "The repository has too many files for GitHub to list at once.");
|
|
67
|
+
return new Map(response.tree.filter((entry) => entry.type === "blob").map((entry) => [entry.path, entry.sha]));
|
|
68
|
+
});
|
|
69
|
+
tree.catch(() => this.trees.delete(sha));
|
|
70
|
+
this.trees.set(sha, tree);
|
|
71
|
+
}
|
|
72
|
+
return tree;
|
|
73
|
+
}
|
|
74
|
+
async currentTree() {
|
|
75
|
+
return this.tree(this.pinned ?? await this.revision());
|
|
76
|
+
}
|
|
77
|
+
async read(path) {
|
|
78
|
+
const sha = (await this.currentTree()).get(path);
|
|
79
|
+
if (!sha) return void 0;
|
|
80
|
+
let blob = this.blobs.get(sha);
|
|
81
|
+
if (!blob) {
|
|
82
|
+
blob = githubRequest(this.api, `/repos/${this.repo}/git/blobs/${sha}`, { accept: "application/vnd.github.raw+json" }).then((response) => response.text());
|
|
83
|
+
blob.catch(() => this.blobs.delete(sha));
|
|
84
|
+
this.blobs.set(sha, blob);
|
|
85
|
+
}
|
|
86
|
+
return blob;
|
|
87
|
+
}
|
|
88
|
+
async readBytes(path) {
|
|
89
|
+
const sha = (await this.currentTree()).get(path);
|
|
90
|
+
if (!sha) return void 0;
|
|
91
|
+
const response = await githubRequest(this.api, `/repos/${this.repo}/git/blobs/${sha}`, { accept: "application/vnd.github.raw+json" });
|
|
92
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
93
|
+
}
|
|
94
|
+
async list(dir) {
|
|
95
|
+
const prefix = `${dir}/`;
|
|
96
|
+
return [...(await this.currentTree()).keys()].filter((path) => path.startsWith(prefix)).sort();
|
|
97
|
+
}
|
|
98
|
+
async write(changes, { message, expectedRevision }) {
|
|
99
|
+
const existing = await this.tree(expectedRevision);
|
|
100
|
+
const additions = changes.flatMap((change) => "delete" in change ? [] : [{
|
|
101
|
+
path: change.path,
|
|
102
|
+
contents: "bytes" in change ? encodeBase64Bytes(change.bytes) : encodeBase64(change.content)
|
|
103
|
+
}]);
|
|
104
|
+
const deletions = changes.flatMap((change) => "delete" in change && existing.has(change.path) ? [{ path: change.path }] : []);
|
|
105
|
+
if (additions.length === 0 && deletions.length === 0) return { revision: expectedRevision };
|
|
106
|
+
const response = await githubJson(this.api, "/graphql", {
|
|
107
|
+
method: "POST",
|
|
108
|
+
body: {
|
|
109
|
+
query: COMMIT_MUTATION,
|
|
110
|
+
variables: { input: {
|
|
111
|
+
branch: {
|
|
112
|
+
repositoryNameWithOwner: decodeURIComponent(this.repo),
|
|
113
|
+
branchName: this.branch
|
|
114
|
+
},
|
|
115
|
+
message: { headline: message },
|
|
116
|
+
expectedHeadOid: expectedRevision,
|
|
117
|
+
fileChanges: {
|
|
118
|
+
additions,
|
|
119
|
+
deletions
|
|
120
|
+
}
|
|
121
|
+
} }
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
const oid = response.data?.createCommitOnBranch?.commit?.oid;
|
|
125
|
+
if (oid) {
|
|
126
|
+
this.pinned = oid;
|
|
127
|
+
return { revision: oid };
|
|
128
|
+
}
|
|
129
|
+
if (await this.revision() !== expectedRevision) throw new ConflictError();
|
|
130
|
+
const error = response.errors?.[0];
|
|
131
|
+
if (error?.type === "FORBIDDEN") throw new SignInError("cant-publish", "This GitHub account can't publish to the site.");
|
|
132
|
+
throw new GitApiError(422, `GitHub couldn't save the changes: ${error?.message ?? "unknown error"}`);
|
|
133
|
+
}
|
|
134
|
+
async changedPaths(from, to) {
|
|
135
|
+
const comparison = await githubJson(this.api, `/repos/${this.repo}/compare/${from}...${to}`);
|
|
136
|
+
return [...new Set((comparison.files ?? []).flatMap((file) => [file.filename, file.previous_filename ?? []].flat()))];
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Reads the deployment GitHub Pages or Vercel created for a commit. Before
|
|
140
|
+
* one exists, the site counts as building if it has ever been deployed.
|
|
141
|
+
*/
|
|
142
|
+
async deployStatus(revision) {
|
|
143
|
+
try {
|
|
144
|
+
const latest = (await githubJson(this.api, `/repos/${this.repo}/deployments?sha=${revision}&per_page=5`))[0];
|
|
145
|
+
if (!latest) return { state: (await githubJson(this.api, `/repos/${this.repo}/deployments?per_page=1`)).length > 0 ? "building" : "unknown" };
|
|
146
|
+
const [status] = await githubJson(this.api, `/repos/${this.repo}/deployments/${latest.id}/statuses?per_page=1`);
|
|
147
|
+
const detailsUrl = status?.log_url || status?.target_url || void 0;
|
|
148
|
+
switch (status?.state) {
|
|
149
|
+
case "success":
|
|
150
|
+
case "inactive": return {
|
|
151
|
+
state: "live",
|
|
152
|
+
detailsUrl
|
|
153
|
+
};
|
|
154
|
+
case "failure":
|
|
155
|
+
case "error": return {
|
|
156
|
+
state: "failed",
|
|
157
|
+
detailsUrl
|
|
158
|
+
};
|
|
159
|
+
default: return {
|
|
160
|
+
state: "building",
|
|
161
|
+
detailsUrl
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error instanceof GitApiError) return { state: "unknown" };
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
signOut() {
|
|
170
|
+
this.onSignOut();
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/index.ts
|
|
175
|
+
/**
|
|
176
|
+
* Links to GitHub's token page with everything filled in except the
|
|
177
|
+
* repository, which GitHub doesn't let links choose.
|
|
178
|
+
*/
|
|
179
|
+
function githubTokenLinks(repo, webUrl = "https://github.com") {
|
|
180
|
+
const [owner = ""] = repo.split("/");
|
|
181
|
+
const fineGrained = new URLSearchParams({
|
|
182
|
+
name: "Goodfellow admin",
|
|
183
|
+
description: `Edit ${repo} in its admin panel`,
|
|
184
|
+
target_name: owner,
|
|
185
|
+
expires_in: "90",
|
|
186
|
+
contents: "write",
|
|
187
|
+
deployments: "read"
|
|
188
|
+
});
|
|
189
|
+
const classic = new URLSearchParams({
|
|
190
|
+
scopes: "repo",
|
|
191
|
+
description: `Goodfellow admin for ${repo}`
|
|
192
|
+
});
|
|
193
|
+
return {
|
|
194
|
+
fineGrained: `${webUrl}/settings/personal-access-tokens/new?${fineGrained}`,
|
|
195
|
+
classic: `${webUrl}/settings/tokens/new?${classic}`
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/** Sign-in and storage for a site whose repository is on GitHub. */
|
|
199
|
+
function github(options) {
|
|
200
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(options.repo)) throw new Error(`github({ repo }) must look like "owner/name" (got "${options.repo}").`);
|
|
201
|
+
const apiUrl = (options.apiUrl ?? "https://api.github.com").replace(/\/$/, "");
|
|
202
|
+
const links = githubTokenLinks(options.repo, options.webUrl);
|
|
203
|
+
const storage = credentialStorage(`goodfellow:github:${options.repo}`, options.storage);
|
|
204
|
+
async function connect(token) {
|
|
205
|
+
const api = {
|
|
206
|
+
apiUrl,
|
|
207
|
+
token,
|
|
208
|
+
fetch: options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
209
|
+
};
|
|
210
|
+
const user = await githubJson(api, "/user");
|
|
211
|
+
let repo;
|
|
212
|
+
try {
|
|
213
|
+
repo = await githubJson(api, `/repos/${repoPath(options.repo)}`);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
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}.`);
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
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.`);
|
|
219
|
+
return new GitHubBackend({
|
|
220
|
+
api,
|
|
221
|
+
repo: options.repo,
|
|
222
|
+
branch: options.branch ?? repo.default_branch,
|
|
223
|
+
user: {
|
|
224
|
+
login: user.login,
|
|
225
|
+
name: user.name ?? void 0,
|
|
226
|
+
avatarUrl: user.avatar_url
|
|
227
|
+
},
|
|
228
|
+
onSignOut: () => storage.clear()
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
name: "GitHub",
|
|
233
|
+
canRedirect: false,
|
|
234
|
+
tokenLinks: [{
|
|
235
|
+
url: links.fineGrained,
|
|
236
|
+
label: "signIn.token.create"
|
|
237
|
+
}, {
|
|
238
|
+
url: links.classic,
|
|
239
|
+
label: "signIn.token.createBroad",
|
|
240
|
+
hint: "signIn.token.createBroadHint"
|
|
241
|
+
}],
|
|
242
|
+
async restore() {
|
|
243
|
+
const token = storage.load();
|
|
244
|
+
if (!token) return null;
|
|
245
|
+
try {
|
|
246
|
+
return await connect(token);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (error instanceof SignInError) storage.clear();
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
async signInWithToken(token, remember) {
|
|
253
|
+
const trimmed = token.trim();
|
|
254
|
+
if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
|
|
255
|
+
const backend = await connect(trimmed);
|
|
256
|
+
storage.save(trimmed, remember);
|
|
257
|
+
return backend;
|
|
258
|
+
},
|
|
259
|
+
async startRedirect() {
|
|
260
|
+
throw new Error("Signing in by redirect needs a sign-in server, which isn't supported for GitHub yet.");
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
//#endregion
|
|
265
|
+
export { GitHubBackend, github, githubTokenLinks };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { FileChange } from "@goodfellow-cms/core";
|
|
2
|
+
import { FakeRepo } from "@goodfellow-cms/core/testing";
|
|
3
|
+
//#region src/testing.d.ts
|
|
4
|
+
export interface FakeGitHubUser {
|
|
5
|
+
login: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
/** Whether the user may publish. Defaults to true. */
|
|
8
|
+
push?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface FakeDeploymentStatus {
|
|
11
|
+
state: "queued" | "in_progress" | "success" | "failure" | "error" | "inactive";
|
|
12
|
+
log_url?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface FakeGitHubOptions {
|
|
15
|
+
/** `owner/name`. */
|
|
16
|
+
repo: string;
|
|
17
|
+
files?: Record<string, string>;
|
|
18
|
+
/** Access tokens and the users they belong to. */
|
|
19
|
+
tokens?: Record<string, FakeGitHubUser>;
|
|
20
|
+
/**
|
|
21
|
+
* Simulates GitHub Pages or Vercel: every commit gets a deployment that
|
|
22
|
+
* reports success after being checked this many times. Off by default.
|
|
23
|
+
*/
|
|
24
|
+
deployAfterChecks?: number;
|
|
25
|
+
}
|
|
26
|
+
interface Deployment {
|
|
27
|
+
id: number;
|
|
28
|
+
sha: string;
|
|
29
|
+
statuses: FakeDeploymentStatus[];
|
|
30
|
+
checks: number;
|
|
31
|
+
}
|
|
32
|
+
export declare function fakeGitHub(options: FakeGitHubOptions): {
|
|
33
|
+
repo: FakeRepo;
|
|
34
|
+
deployments: Deployment[];
|
|
35
|
+
/** Every request received, for checking what the backend sent. */
|
|
36
|
+
requests: Request[];
|
|
37
|
+
/** Simulates someone else publishing. */
|
|
38
|
+
commit: (changes: FileChange[], message: string, expectedHead?: string) => string;
|
|
39
|
+
handle: (request: Request) => Promise<Response>;
|
|
40
|
+
/** A `fetch` that answers from the fake instead of the network. */
|
|
41
|
+
fetch: typeof fetch;
|
|
42
|
+
};
|
|
43
|
+
export type FakeGitHub = ReturnType<typeof fakeGitHub>;
|
|
44
|
+
//#endregion
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { decodeBase64, encodeBase64Bytes } from "@goodfellow-cms/core";
|
|
2
|
+
import { FakeConflictError, FakeRepo, fakeFileBytes } from "@goodfellow-cms/core/testing";
|
|
3
|
+
//#region src/testing.ts
|
|
4
|
+
/**
|
|
5
|
+
* A fake of the parts of GitHub's API that `@goodfellow-cms/github` uses, backed by
|
|
6
|
+
* an in-memory repository. For tests only: pass `fake.fetch` as the backend's
|
|
7
|
+
* `fetch`, or route browser requests to `fake.handle` in end-to-end tests.
|
|
8
|
+
*/
|
|
9
|
+
function json(body, status = 200) {
|
|
10
|
+
return new Response(JSON.stringify(body), {
|
|
11
|
+
status,
|
|
12
|
+
headers: { "content-type": "application/json" }
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function fakeGitHub(options) {
|
|
16
|
+
const repo = new FakeRepo(options.files);
|
|
17
|
+
const tokens = options.tokens ?? { "test-token": {
|
|
18
|
+
login: "editor",
|
|
19
|
+
name: "Test Editor"
|
|
20
|
+
} };
|
|
21
|
+
const deployments = [];
|
|
22
|
+
const requests = [];
|
|
23
|
+
let nextDeployment = 1;
|
|
24
|
+
function deploy(sha) {
|
|
25
|
+
if (options.deployAfterChecks === void 0) return;
|
|
26
|
+
deployments.unshift({
|
|
27
|
+
id: nextDeployment++,
|
|
28
|
+
sha,
|
|
29
|
+
statuses: [{ state: "in_progress" }],
|
|
30
|
+
checks: 0
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function commit(changes, message, expectedHead) {
|
|
34
|
+
const sha = repo.commit(changes, message, { expectedHead });
|
|
35
|
+
deploy(sha);
|
|
36
|
+
return sha;
|
|
37
|
+
}
|
|
38
|
+
async function handle(request) {
|
|
39
|
+
requests.push(request.clone());
|
|
40
|
+
const url = new URL(request.url);
|
|
41
|
+
const path = url.pathname;
|
|
42
|
+
const user = tokens[request.headers.get("authorization")?.replace(/^Bearer /, "") ?? ""];
|
|
43
|
+
if (!user) return json({ message: "Bad credentials" }, 401);
|
|
44
|
+
if (path === "/user") return json({
|
|
45
|
+
login: user.login,
|
|
46
|
+
name: user.name ?? null,
|
|
47
|
+
avatar_url: `https://avatars.example/${user.login}`
|
|
48
|
+
});
|
|
49
|
+
if (path === "/graphql" && request.method === "POST") {
|
|
50
|
+
const { variables } = await request.json();
|
|
51
|
+
const { input } = variables;
|
|
52
|
+
if (input.branch.repositoryNameWithOwner !== options.repo) return json({ errors: [{
|
|
53
|
+
type: "NOT_FOUND",
|
|
54
|
+
message: "Could not resolve to a Repository"
|
|
55
|
+
}] });
|
|
56
|
+
if (user.push === false) return json({ errors: [{
|
|
57
|
+
type: "FORBIDDEN",
|
|
58
|
+
message: "Resource not accessible"
|
|
59
|
+
}] });
|
|
60
|
+
const files = repo.files(input.branch.branchName);
|
|
61
|
+
for (const deletion of input.fileChanges.deletions ?? []) if (!files.has(deletion.path)) return json({ errors: [{ message: `No file at ${deletion.path}` }] });
|
|
62
|
+
try {
|
|
63
|
+
return json({ data: { createCommitOnBranch: { commit: { oid: commit([...(input.fileChanges.additions ?? []).map((addition) => ({
|
|
64
|
+
path: addition.path,
|
|
65
|
+
bytes: decodeBase64(addition.contents)
|
|
66
|
+
})), ...(input.fileChanges.deletions ?? []).map((deletion) => ({
|
|
67
|
+
path: deletion.path,
|
|
68
|
+
delete: true
|
|
69
|
+
}))], input.message.headline, input.expectedHeadOid) } } } });
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error instanceof FakeConflictError) return json({
|
|
72
|
+
data: { createCommitOnBranch: null },
|
|
73
|
+
errors: [{
|
|
74
|
+
type: "STALE_DATA",
|
|
75
|
+
message: "Expected branch to point to another commit"
|
|
76
|
+
}]
|
|
77
|
+
});
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const match = path.match(/^\/repos\/([^/]+\/[^/]+)(\/.*)?$/);
|
|
82
|
+
if (!match || decodeURIComponent(match[1] ?? "") !== options.repo) return json({ message: "Not Found" }, 404);
|
|
83
|
+
const rest = match[2] ?? "";
|
|
84
|
+
if (rest === "") return json({
|
|
85
|
+
full_name: options.repo,
|
|
86
|
+
default_branch: repo.defaultBranch,
|
|
87
|
+
permissions: { push: user.push !== false }
|
|
88
|
+
});
|
|
89
|
+
const ref = rest.match(/^\/git\/ref\/heads\/(.+)$/);
|
|
90
|
+
if (ref) {
|
|
91
|
+
const sha = repo.branches.get(decodeURIComponent(ref[1] ?? ""));
|
|
92
|
+
return sha ? json({ object: {
|
|
93
|
+
sha,
|
|
94
|
+
type: "commit"
|
|
95
|
+
} }) : json({ message: "Not Found" }, 404);
|
|
96
|
+
}
|
|
97
|
+
const tree = rest.match(/^\/git\/trees\/([0-9a-f]{40})$/);
|
|
98
|
+
if (tree) {
|
|
99
|
+
const commitSha = tree[1] ?? "";
|
|
100
|
+
if (!repo.commitAt(commitSha)) return json({ message: "Not Found" }, 404);
|
|
101
|
+
return json({
|
|
102
|
+
sha: commitSha,
|
|
103
|
+
tree: [...repo.files(commitSha)].map(([file, content]) => ({
|
|
104
|
+
path: file,
|
|
105
|
+
type: "blob",
|
|
106
|
+
sha: repo.blobSha(content)
|
|
107
|
+
})),
|
|
108
|
+
truncated: false
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const blob = rest.match(/^\/git\/blobs\/([0-9a-f]{40})$/);
|
|
112
|
+
if (blob) {
|
|
113
|
+
const content = repo.blobs.get(blob[1] ?? "");
|
|
114
|
+
if (content === void 0) return json({ message: "Not Found" }, 404);
|
|
115
|
+
return request.headers.get("accept")?.includes("raw") ? new Response(fakeFileBytes(content)) : json({
|
|
116
|
+
content: encodeBase64Bytes(fakeFileBytes(content)),
|
|
117
|
+
encoding: "base64"
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const compare = rest.match(/^\/compare\/([0-9a-f]{40})\.\.\.([0-9a-f]{40})$/);
|
|
121
|
+
if (compare) return json({ files: repo.changedPaths(compare[1] ?? "", compare[2] ?? "").map((filename) => ({ filename })) });
|
|
122
|
+
if (rest === "/deployments") {
|
|
123
|
+
const sha = url.searchParams.get("sha");
|
|
124
|
+
return json(deployments.filter((deployment) => !sha || deployment.sha === sha).slice(0, Number(url.searchParams.get("per_page") ?? 30)).map(({ id, sha }) => ({
|
|
125
|
+
id,
|
|
126
|
+
sha
|
|
127
|
+
})));
|
|
128
|
+
}
|
|
129
|
+
const statuses = rest.match(/^\/deployments\/(\d+)\/statuses$/);
|
|
130
|
+
if (statuses) {
|
|
131
|
+
const deployment = deployments.find((candidate) => candidate.id === Number(statuses[1]));
|
|
132
|
+
if (!deployment) return json({ message: "Not Found" }, 404);
|
|
133
|
+
deployment.checks += 1;
|
|
134
|
+
if (deployment.checks > (options.deployAfterChecks ?? 0) && deployment.statuses[0]?.state === "in_progress") deployment.statuses.unshift({
|
|
135
|
+
state: "success",
|
|
136
|
+
log_url: `https://github.com/${options.repo}/actions/runs/${deployment.id}`
|
|
137
|
+
});
|
|
138
|
+
return json(deployment.statuses);
|
|
139
|
+
}
|
|
140
|
+
return json({ message: "Not Found" }, 404);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
repo,
|
|
144
|
+
deployments,
|
|
145
|
+
/** Every request received, for checking what the backend sent. */
|
|
146
|
+
requests,
|
|
147
|
+
/** Simulates someone else publishing. */
|
|
148
|
+
commit,
|
|
149
|
+
handle,
|
|
150
|
+
/** A `fetch` that answers from the fake instead of the network. */
|
|
151
|
+
fetch: ((input, init) => handle(new Request(input, init)))
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
export { fakeGitHub };
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodfellow-cms/github",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Edit a Goodfellow site stored on GitHub, straight from the browser.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"goodfellow",
|
|
7
|
+
"cms",
|
|
8
|
+
"website-builder",
|
|
9
|
+
"puck",
|
|
10
|
+
"github",
|
|
11
|
+
"git-based-cms"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://chartung17.github.io/goodfellow-cms/docs/hosts",
|
|
14
|
+
"bugs": "https://github.com/chartung17/goodfellow-cms/issues",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/chartung17/goodfellow-cms.git",
|
|
19
|
+
"directory": "packages/github"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./testing": {
|
|
29
|
+
"types": "./dist/testing.d.ts",
|
|
30
|
+
"import": "./dist/testing.js"
|
|
31
|
+
},
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@goodfellow-cms/core": "0.1.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@puckeditor/core": "0.23.0"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsdown",
|
|
51
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
52
|
+
"test": "vitest run"
|
|
53
|
+
}
|
|
6
54
|
}
|