@goodfellow-cms/gitlab 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 +99 -0
- package/dist/index.js +381 -0
- package/dist/testing.d.ts +34 -0
- package/dist/testing.js +200 -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/gitlab
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Edit a Goodfellow site stored on GitLab, straight from the browser: sign-in with an access token, publishing as single commits, conflict checks and deploy status.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { gitlab } from "@goodfellow-cms/gitlab";
|
|
7
|
+
|
|
8
|
+
export default defineConfig({ blocks, backend: gitlab({ project: "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,99 @@
|
|
|
1
|
+
import { DeployStatus, FileChange, GitBackend, GitHost, GitUser, StorageLike, WriteOptions } from "@goodfellow-cms/core";
|
|
2
|
+
//#region src/api.d.ts
|
|
3
|
+
interface ApiOptions {
|
|
4
|
+
/** The API root, such as `https://gitlab.com/api/v4`. */
|
|
5
|
+
apiUrl: string;
|
|
6
|
+
/** Returns a current access token, refreshing it if needed. */
|
|
7
|
+
token: (options?: {
|
|
8
|
+
refresh?: boolean;
|
|
9
|
+
}) => Promise<string>;
|
|
10
|
+
/** Whether the token can be refreshed after a 401. */
|
|
11
|
+
canRefresh: boolean;
|
|
12
|
+
fetch: typeof fetch;
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/backend.d.ts
|
|
16
|
+
interface GitLabBackendOptions {
|
|
17
|
+
api: ApiOptions;
|
|
18
|
+
project: string;
|
|
19
|
+
branch: string;
|
|
20
|
+
user: GitUser;
|
|
21
|
+
onSignOut: () => void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The site's files on GitLab. Reads come from the commit `revision()` last
|
|
25
|
+
* returned. Saves are single commits; each changed file carries the commit it
|
|
26
|
+
* was last changed in, so GitLab refuses to overwrite anyone else's edits.
|
|
27
|
+
*/
|
|
28
|
+
export declare class GitLabBackend implements GitBackend {
|
|
29
|
+
readonly user: GitUser;
|
|
30
|
+
private readonly api;
|
|
31
|
+
private readonly project;
|
|
32
|
+
private readonly branch;
|
|
33
|
+
private readonly onSignOut;
|
|
34
|
+
private pinned?;
|
|
35
|
+
private readonly trees;
|
|
36
|
+
private readonly blobs;
|
|
37
|
+
constructor(options: GitLabBackendOptions);
|
|
38
|
+
private repo;
|
|
39
|
+
revision(): Promise<string>;
|
|
40
|
+
/** Every file in one folder at a commit, page by page. */
|
|
41
|
+
private listFolder;
|
|
42
|
+
/** Path → blob id for the editable files at a commit. Commits never change, so this is cached. */
|
|
43
|
+
private tree;
|
|
44
|
+
private currentTree;
|
|
45
|
+
read(path: string): Promise<string | undefined>;
|
|
46
|
+
readBytes(path: string): Promise<Uint8Array | undefined>;
|
|
47
|
+
list(dir: string): Promise<string[]>;
|
|
48
|
+
private lastCommitFor;
|
|
49
|
+
write(changes: FileChange[], { message, expectedRevision }: WriteOptions): Promise<{
|
|
50
|
+
revision: string;
|
|
51
|
+
}>;
|
|
52
|
+
changedPaths(from: string, to: string): Promise<string[]>;
|
|
53
|
+
/**
|
|
54
|
+
* Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
|
|
55
|
+
* Vercel). Before any exist, the site counts as building if it has pipelines.
|
|
56
|
+
*/
|
|
57
|
+
deployStatus(revision: string): Promise<DeployStatus>;
|
|
58
|
+
signOut(): void;
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/oauth.d.ts
|
|
62
|
+
/** The admin panel's address, with a trailing slash so it always matches the redirect URI registered with GitLab. */
|
|
63
|
+
export declare function redirectUri(href: string): string;
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/index.d.ts
|
|
66
|
+
/** The page's address and how to change it. Replaceable in tests. */
|
|
67
|
+
export interface BrowserLocation {
|
|
68
|
+
href(): string;
|
|
69
|
+
/** Navigates away, for example to GitLab's sign-in page. */
|
|
70
|
+
assign(url: string): void;
|
|
71
|
+
/** Changes the address without reloading, for example to remove `?code=…` after signing in. */
|
|
72
|
+
replace(url: string): void;
|
|
73
|
+
}
|
|
74
|
+
export interface GitLabOptions {
|
|
75
|
+
/** The site's project path, such as `group/site` or `group/subgroup/site`. */
|
|
76
|
+
project: string;
|
|
77
|
+
/** The branch the live site is built from. Defaults to the project's default branch. */
|
|
78
|
+
branch?: string;
|
|
79
|
+
/** For self-managed GitLab. Defaults to `https://gitlab.com`. */
|
|
80
|
+
url?: string;
|
|
81
|
+
/**
|
|
82
|
+
* The Application ID of an OAuth application registered for the site (not
|
|
83
|
+
* confidential, scope `api`, redirect URI `https://your-site/admin/`). Enables
|
|
84
|
+
* one-click "Sign in with GitLab". Without it, people sign in with a token.
|
|
85
|
+
*/
|
|
86
|
+
clientId?: string;
|
|
87
|
+
/** For tests. */
|
|
88
|
+
fetch?: typeof fetch;
|
|
89
|
+
/** For tests. */
|
|
90
|
+
storage?: {
|
|
91
|
+
local?: StorageLike;
|
|
92
|
+
session?: StorageLike;
|
|
93
|
+
};
|
|
94
|
+
/** For tests. */
|
|
95
|
+
location?: BrowserLocation;
|
|
96
|
+
}
|
|
97
|
+
/** Sign-in and storage for a site whose repository is on GitLab. */
|
|
98
|
+
export declare function gitlab(options: GitLabOptions): GitHost;
|
|
99
|
+
//#endregion
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { ConflictError, EDITABLE_FOLDERS, GitApiError, SignInError, credentialStorage, encodeBase64Bytes } from "@goodfellow-cms/core";
|
|
2
|
+
//#region src/api.ts
|
|
3
|
+
/** Calls the GitLab API. A 401 refreshes the token once; after that it means the sign-in has expired. */
|
|
4
|
+
async function gitlabRequest(api, path, init = {}) {
|
|
5
|
+
const send = async (refresh) => api.fetch(`${api.apiUrl}${path}`, {
|
|
6
|
+
method: init.method ?? "GET",
|
|
7
|
+
headers: {
|
|
8
|
+
authorization: `Bearer ${await api.token({ refresh })}`,
|
|
9
|
+
...init.body !== void 0 && { "content-type": "application/json" }
|
|
10
|
+
},
|
|
11
|
+
body: init.body === void 0 ? void 0 : JSON.stringify(init.body),
|
|
12
|
+
cache: "no-store"
|
|
13
|
+
});
|
|
14
|
+
let response = await send(false);
|
|
15
|
+
if (response.status === 401 && api.canRefresh) response = await send(true);
|
|
16
|
+
if (response.status === 401) throw new SignInError("invalid", "GitLab 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
|
+
const message = typeof body.message === "string" ? body.message : JSON.stringify(body.message ?? body.error ?? "");
|
|
20
|
+
throw new GitApiError(response.status, `GitLab answered ${response.status}: ${message || response.statusText}`);
|
|
21
|
+
}
|
|
22
|
+
return response;
|
|
23
|
+
}
|
|
24
|
+
async function gitlabJson(api, path, init) {
|
|
25
|
+
return await (await gitlabRequest(api, path, init)).json();
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/backend.ts
|
|
29
|
+
const PAGE_SIZE = 100;
|
|
30
|
+
/**
|
|
31
|
+
* The site's files on GitLab. Reads come from the commit `revision()` last
|
|
32
|
+
* returned. Saves are single commits; each changed file carries the commit it
|
|
33
|
+
* was last changed in, so GitLab refuses to overwrite anyone else's edits.
|
|
34
|
+
*/
|
|
35
|
+
var GitLabBackend = class {
|
|
36
|
+
user;
|
|
37
|
+
api;
|
|
38
|
+
project;
|
|
39
|
+
branch;
|
|
40
|
+
onSignOut;
|
|
41
|
+
pinned;
|
|
42
|
+
trees = /* @__PURE__ */ new Map();
|
|
43
|
+
blobs = /* @__PURE__ */ new Map();
|
|
44
|
+
constructor(options) {
|
|
45
|
+
this.api = options.api;
|
|
46
|
+
this.project = encodeURIComponent(options.project);
|
|
47
|
+
this.branch = options.branch;
|
|
48
|
+
this.user = options.user;
|
|
49
|
+
this.onSignOut = options.onSignOut;
|
|
50
|
+
}
|
|
51
|
+
repo(path) {
|
|
52
|
+
return `/projects/${this.project}/repository${path}`;
|
|
53
|
+
}
|
|
54
|
+
async revision() {
|
|
55
|
+
const branch = await gitlabJson(this.api, this.repo(`/branches/${encodeURIComponent(this.branch)}`));
|
|
56
|
+
this.pinned = branch.commit.id;
|
|
57
|
+
return this.pinned;
|
|
58
|
+
}
|
|
59
|
+
/** Every file in one folder at a commit, page by page. */
|
|
60
|
+
async listFolder(sha, dir) {
|
|
61
|
+
const files = [];
|
|
62
|
+
for (let page = 1;; page++) {
|
|
63
|
+
const query = new URLSearchParams({
|
|
64
|
+
ref: sha,
|
|
65
|
+
path: dir,
|
|
66
|
+
recursive: "true",
|
|
67
|
+
per_page: String(PAGE_SIZE),
|
|
68
|
+
page: String(page)
|
|
69
|
+
});
|
|
70
|
+
const response = await gitlabRequest(this.api, this.repo(`/tree?${query}`), { allow: [404] });
|
|
71
|
+
if (response.status === 404) return files;
|
|
72
|
+
const entries = await response.json();
|
|
73
|
+
for (const entry of entries) if (entry.type === "blob") files.push([entry.path, entry.id]);
|
|
74
|
+
if (entries.length < PAGE_SIZE) return files;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Path → blob id for the editable files at a commit. Commits never change, so this is cached. */
|
|
78
|
+
tree(sha) {
|
|
79
|
+
let tree = this.trees.get(sha);
|
|
80
|
+
if (!tree) {
|
|
81
|
+
tree = Promise.all(EDITABLE_FOLDERS.map((folder) => this.listFolder(sha, folder))).then((folders) => new Map(folders.flat()));
|
|
82
|
+
tree.catch(() => this.trees.delete(sha));
|
|
83
|
+
this.trees.set(sha, tree);
|
|
84
|
+
}
|
|
85
|
+
return tree;
|
|
86
|
+
}
|
|
87
|
+
async currentTree() {
|
|
88
|
+
return this.tree(this.pinned ?? await this.revision());
|
|
89
|
+
}
|
|
90
|
+
async read(path) {
|
|
91
|
+
const sha = (await this.currentTree()).get(path);
|
|
92
|
+
if (!sha) return void 0;
|
|
93
|
+
let blob = this.blobs.get(sha);
|
|
94
|
+
if (!blob) {
|
|
95
|
+
blob = gitlabRequest(this.api, this.repo(`/blobs/${sha}/raw`)).then((response) => response.text());
|
|
96
|
+
blob.catch(() => this.blobs.delete(sha));
|
|
97
|
+
this.blobs.set(sha, blob);
|
|
98
|
+
}
|
|
99
|
+
return blob;
|
|
100
|
+
}
|
|
101
|
+
async readBytes(path) {
|
|
102
|
+
const sha = (await this.currentTree()).get(path);
|
|
103
|
+
if (!sha) return void 0;
|
|
104
|
+
const response = await gitlabRequest(this.api, this.repo(`/blobs/${sha}/raw`));
|
|
105
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
106
|
+
}
|
|
107
|
+
async list(dir) {
|
|
108
|
+
const prefix = `${dir}/`;
|
|
109
|
+
return [...(await this.currentTree()).keys()].filter((path) => path.startsWith(prefix)).sort();
|
|
110
|
+
}
|
|
111
|
+
async lastCommitFor(path, ref) {
|
|
112
|
+
return (await gitlabJson(this.api, this.repo(`/files/${encodeURIComponent(path)}?ref=${ref}`))).last_commit_id;
|
|
113
|
+
}
|
|
114
|
+
async write(changes, { message, expectedRevision }) {
|
|
115
|
+
if (await this.revision() !== expectedRevision) throw new ConflictError();
|
|
116
|
+
const existing = await this.tree(expectedRevision);
|
|
117
|
+
const toApply = (await Promise.all(changes.map(async (change) => {
|
|
118
|
+
const exists = existing.has(change.path);
|
|
119
|
+
if ("delete" in change) return exists ? {
|
|
120
|
+
action: "delete",
|
|
121
|
+
file_path: change.path,
|
|
122
|
+
last_commit_id: await this.lastCommitFor(change.path, expectedRevision)
|
|
123
|
+
} : void 0;
|
|
124
|
+
const content = "bytes" in change ? {
|
|
125
|
+
content: encodeBase64Bytes(change.bytes),
|
|
126
|
+
encoding: "base64"
|
|
127
|
+
} : { content: change.content };
|
|
128
|
+
return exists ? {
|
|
129
|
+
action: "update",
|
|
130
|
+
file_path: change.path,
|
|
131
|
+
...content,
|
|
132
|
+
last_commit_id: await this.lastCommitFor(change.path, expectedRevision)
|
|
133
|
+
} : {
|
|
134
|
+
action: "create",
|
|
135
|
+
file_path: change.path,
|
|
136
|
+
...content
|
|
137
|
+
};
|
|
138
|
+
}))).filter((action) => action !== void 0);
|
|
139
|
+
if (toApply.length === 0) return { revision: expectedRevision };
|
|
140
|
+
try {
|
|
141
|
+
const commit = await gitlabJson(this.api, this.repo("/commits"), {
|
|
142
|
+
method: "POST",
|
|
143
|
+
body: {
|
|
144
|
+
branch: this.branch,
|
|
145
|
+
commit_message: message,
|
|
146
|
+
actions: toApply
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
this.pinned = commit.id;
|
|
150
|
+
return { revision: commit.id };
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (!(error instanceof GitApiError)) throw error;
|
|
153
|
+
if (error.status === 403) throw new SignInError("cant-publish", "This GitLab account can't publish to the site.");
|
|
154
|
+
if (error.status === 400 && /already exists|changed since/i.test(error.message) || await this.revision() !== expectedRevision) throw new ConflictError();
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async changedPaths(from, to) {
|
|
159
|
+
const comparison = await gitlabJson(this.api, this.repo(`/compare?${new URLSearchParams({
|
|
160
|
+
from,
|
|
161
|
+
to,
|
|
162
|
+
straight: "true"
|
|
163
|
+
})}`));
|
|
164
|
+
return [...new Set((comparison.diffs ?? []).flatMap((diff) => [diff.old_path, diff.new_path]))];
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
|
|
168
|
+
* Vercel). Before any exist, the site counts as building if it has pipelines.
|
|
169
|
+
*/
|
|
170
|
+
async deployStatus(revision) {
|
|
171
|
+
try {
|
|
172
|
+
const statuses = await gitlabJson(this.api, this.repo(`/commits/${revision}/statuses`));
|
|
173
|
+
if (statuses.length === 0) return { state: (await gitlabJson(this.api, `/projects/${this.project}/pipelines?per_page=1`)).length > 0 ? "building" : "unknown" };
|
|
174
|
+
const failed = statuses.find((status) => status.status === "failed" || status.status === "canceled");
|
|
175
|
+
if (failed) return {
|
|
176
|
+
state: "failed",
|
|
177
|
+
detailsUrl: failed.target_url ?? void 0
|
|
178
|
+
};
|
|
179
|
+
if (statuses.every((status) => status.status === "success" || status.status === "skipped")) return {
|
|
180
|
+
state: "live",
|
|
181
|
+
detailsUrl: statuses[0]?.target_url ?? void 0
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
state: "building",
|
|
185
|
+
detailsUrl: statuses[0]?.target_url ?? void 0
|
|
186
|
+
};
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error instanceof GitApiError) return { state: "unknown" };
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
signOut() {
|
|
193
|
+
this.onSignOut();
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
//#endregion
|
|
197
|
+
//#region src/oauth.ts
|
|
198
|
+
/** OAuth 2.0 with PKCE: lets the browser sign in to GitLab without any server or client secret. */
|
|
199
|
+
function base64Url(bytes) {
|
|
200
|
+
let binary = "";
|
|
201
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
202
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
203
|
+
}
|
|
204
|
+
function randomString(bytes = 32) {
|
|
205
|
+
return base64Url(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
206
|
+
}
|
|
207
|
+
async function codeChallenge(verifier) {
|
|
208
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
209
|
+
return base64Url(new Uint8Array(digest));
|
|
210
|
+
}
|
|
211
|
+
function toTokens(response, verifier, now = Date.now()) {
|
|
212
|
+
return {
|
|
213
|
+
type: "oauth",
|
|
214
|
+
access: response.access_token,
|
|
215
|
+
refresh: response.refresh_token,
|
|
216
|
+
expiresAt: now + (response.expires_in ?? 7200) * 1e3,
|
|
217
|
+
verifier
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/** The admin panel's address, with a trailing slash so it always matches the redirect URI registered with GitLab. */
|
|
221
|
+
function redirectUri(href) {
|
|
222
|
+
const url = new URL(href);
|
|
223
|
+
return `${url.origin}${url.pathname.replace(/\/?$/, "/")}`;
|
|
224
|
+
}
|
|
225
|
+
//#endregion
|
|
226
|
+
//#region src/index.ts
|
|
227
|
+
const MIN_ACCESS_LEVEL_TO_PUBLISH = 30;
|
|
228
|
+
function browserLocation() {
|
|
229
|
+
return {
|
|
230
|
+
href: () => window.location.href,
|
|
231
|
+
assign: (url) => window.location.assign(url),
|
|
232
|
+
replace: (url) => window.history.replaceState(window.history.state, "", url)
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/** Sign-in and storage for a site whose repository is on GitLab. */
|
|
236
|
+
function gitlab(options) {
|
|
237
|
+
const webUrl = (options.url ?? "https://gitlab.com").replace(/\/$/, "");
|
|
238
|
+
const apiUrl = `${webUrl}/api/v4`;
|
|
239
|
+
const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
240
|
+
const storage = credentialStorage(`goodfellow:gitlab:${webUrl}:${options.project}`, options.storage);
|
|
241
|
+
const pending = credentialStorage(`goodfellow:gitlab:pending:${options.project}`, options.storage);
|
|
242
|
+
const location = () => options.location ?? browserLocation();
|
|
243
|
+
async function requestTokens(params) {
|
|
244
|
+
const response = await fetcher(`${webUrl}/oauth/token`, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
247
|
+
body: new URLSearchParams(params)
|
|
248
|
+
});
|
|
249
|
+
if (!response.ok) throw new SignInError("invalid", `GitLab didn't accept the sign-in (${response.status}).`);
|
|
250
|
+
return await response.json();
|
|
251
|
+
}
|
|
252
|
+
async function connect(initial, remember) {
|
|
253
|
+
let credentials = initial;
|
|
254
|
+
const token = async ({ refresh } = {}) => {
|
|
255
|
+
if (credentials.type === "token") return credentials.token;
|
|
256
|
+
if (refresh || credentials.expiresAt - 6e4 < Date.now()) {
|
|
257
|
+
const { verifier } = credentials;
|
|
258
|
+
credentials = toTokens(await requestTokens({
|
|
259
|
+
client_id: options.clientId ?? "",
|
|
260
|
+
refresh_token: credentials.refresh,
|
|
261
|
+
grant_type: "refresh_token",
|
|
262
|
+
redirect_uri: redirectUri(location().href()),
|
|
263
|
+
code_verifier: verifier
|
|
264
|
+
}), verifier);
|
|
265
|
+
storage.save(JSON.stringify(credentials), remember);
|
|
266
|
+
}
|
|
267
|
+
return credentials.access;
|
|
268
|
+
};
|
|
269
|
+
const api = {
|
|
270
|
+
apiUrl,
|
|
271
|
+
token,
|
|
272
|
+
canRefresh: credentials.type === "oauth",
|
|
273
|
+
fetch: fetcher
|
|
274
|
+
};
|
|
275
|
+
const user = await gitlabJson(api, "/user");
|
|
276
|
+
let project;
|
|
277
|
+
try {
|
|
278
|
+
project = await gitlabJson(api, `/projects/${encodeURIComponent(options.project)}`);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error instanceof GitApiError && (error.status === 404 || error.status === 403)) throw new SignInError("no-access", `The GitLab account ${user.username} can't see ${options.project}.`);
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
if (Math.max(project.permissions?.project_access?.access_level ?? 0, project.permissions?.group_access?.access_level ?? 0) < MIN_ACCESS_LEVEL_TO_PUBLISH) throw new SignInError("cant-publish", `The GitLab account ${user.username} can see ${options.project} but can't publish to it.`);
|
|
284
|
+
storage.save(JSON.stringify(credentials), remember);
|
|
285
|
+
return new GitLabBackend({
|
|
286
|
+
api,
|
|
287
|
+
project: options.project,
|
|
288
|
+
branch: options.branch ?? project.default_branch,
|
|
289
|
+
user: {
|
|
290
|
+
login: user.username,
|
|
291
|
+
name: user.name,
|
|
292
|
+
avatarUrl: user.avatar_url
|
|
293
|
+
},
|
|
294
|
+
onSignOut: () => storage.clear()
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
/** Finishes a redirect sign-in if the page has just come back from GitLab. */
|
|
298
|
+
async function completeRedirect() {
|
|
299
|
+
const url = new URL(location().href());
|
|
300
|
+
const code = url.searchParams.get("code");
|
|
301
|
+
const returnedState = url.searchParams.get("state");
|
|
302
|
+
const error = url.searchParams.get("error");
|
|
303
|
+
if (!code && !error) return null;
|
|
304
|
+
const saved = pending.load();
|
|
305
|
+
if (!saved) return null;
|
|
306
|
+
pending.clear();
|
|
307
|
+
const redirect = JSON.parse(saved);
|
|
308
|
+
for (const key of [
|
|
309
|
+
"code",
|
|
310
|
+
"state",
|
|
311
|
+
"error",
|
|
312
|
+
"error_description"
|
|
313
|
+
]) url.searchParams.delete(key);
|
|
314
|
+
url.hash = redirect.hash;
|
|
315
|
+
location().replace(url.toString());
|
|
316
|
+
if (error || !code || returnedState !== redirect.state) throw new SignInError("redirect-failed", "Signing in with GitLab was cancelled or didn't finish.");
|
|
317
|
+
return connect(toTokens(await requestTokens({
|
|
318
|
+
client_id: options.clientId ?? "",
|
|
319
|
+
code,
|
|
320
|
+
grant_type: "authorization_code",
|
|
321
|
+
redirect_uri: redirectUri(url.toString()),
|
|
322
|
+
code_verifier: redirect.verifier
|
|
323
|
+
}), redirect.verifier), redirect.remember);
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
name: "GitLab",
|
|
327
|
+
canRedirect: Boolean(options.clientId),
|
|
328
|
+
tokenLinks: [{
|
|
329
|
+
url: `${webUrl}/-/user_settings/personal_access_tokens?${new URLSearchParams({
|
|
330
|
+
name: "Goodfellow admin",
|
|
331
|
+
scopes: "api"
|
|
332
|
+
})}`,
|
|
333
|
+
label: "signIn.token.create"
|
|
334
|
+
}],
|
|
335
|
+
async restore() {
|
|
336
|
+
const redirected = await completeRedirect();
|
|
337
|
+
if (redirected) return redirected;
|
|
338
|
+
const saved = storage.load();
|
|
339
|
+
if (!saved) return null;
|
|
340
|
+
try {
|
|
341
|
+
return await connect(JSON.parse(saved), storage.remembered());
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (error instanceof SignInError) storage.clear();
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
async signInWithToken(token, remember) {
|
|
348
|
+
const trimmed = token.trim();
|
|
349
|
+
if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
|
|
350
|
+
return connect({
|
|
351
|
+
type: "token",
|
|
352
|
+
token: trimmed
|
|
353
|
+
}, remember);
|
|
354
|
+
},
|
|
355
|
+
async startRedirect(remember) {
|
|
356
|
+
if (!options.clientId) throw new Error("gitlab({ clientId }) is needed to sign in with GitLab.");
|
|
357
|
+
const verifier = randomString(48);
|
|
358
|
+
const state = randomString(16);
|
|
359
|
+
const here = new URL(location().href());
|
|
360
|
+
const redirect = {
|
|
361
|
+
state,
|
|
362
|
+
verifier,
|
|
363
|
+
remember,
|
|
364
|
+
hash: here.hash
|
|
365
|
+
};
|
|
366
|
+
pending.save(JSON.stringify(redirect), false);
|
|
367
|
+
const query = new URLSearchParams({
|
|
368
|
+
client_id: options.clientId,
|
|
369
|
+
redirect_uri: redirectUri(here.toString()),
|
|
370
|
+
response_type: "code",
|
|
371
|
+
state,
|
|
372
|
+
scope: "api",
|
|
373
|
+
code_challenge: await codeChallenge(verifier),
|
|
374
|
+
code_challenge_method: "S256"
|
|
375
|
+
});
|
|
376
|
+
location().assign(`${webUrl}/oauth/authorize?${query}`);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
//#endregion
|
|
381
|
+
export { GitLabBackend, gitlab, redirectUri };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { FileChange } from "@goodfellow-cms/core";
|
|
2
|
+
import { FakeRepo } from "@goodfellow-cms/core/testing";
|
|
3
|
+
//#region src/testing.d.ts
|
|
4
|
+
export interface FakeGitLabUser {
|
|
5
|
+
username: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
/** GitLab access level: 30 is Developer, 40 Maintainer. Defaults to 40. */
|
|
8
|
+
accessLevel?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface FakeGitLabOptions {
|
|
11
|
+
project: string;
|
|
12
|
+
url?: string;
|
|
13
|
+
files?: Record<string, string>;
|
|
14
|
+
/** Personal access tokens and their users. */
|
|
15
|
+
tokens?: Record<string, FakeGitLabUser>;
|
|
16
|
+
/** The OAuth application's ID, and the user who signs in through it. */
|
|
17
|
+
oauth?: {
|
|
18
|
+
clientId: string;
|
|
19
|
+
user: FakeGitLabUser;
|
|
20
|
+
expiresIn?: number;
|
|
21
|
+
};
|
|
22
|
+
/** Simulates a GitLab Pages pipeline that succeeds after being checked this many times. Off by default. */
|
|
23
|
+
deployAfterChecks?: number;
|
|
24
|
+
}
|
|
25
|
+
export declare function fakeGitLab(options: FakeGitLabOptions): {
|
|
26
|
+
repo: FakeRepo;
|
|
27
|
+
requests: Request[];
|
|
28
|
+
commit: (changes: FileChange[], message: string) => string;
|
|
29
|
+
authorize: (authorizeUrl: string) => string;
|
|
30
|
+
handle: (request: Request) => Promise<Response>;
|
|
31
|
+
fetch: typeof fetch;
|
|
32
|
+
};
|
|
33
|
+
export type FakeGitLab = ReturnType<typeof fakeGitLab>;
|
|
34
|
+
//#endregion
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { decodeBase64 } from "@goodfellow-cms/core";
|
|
2
|
+
import { FakeRepo, fakeFileBytes } from "@goodfellow-cms/core/testing";
|
|
3
|
+
//#region src/testing.ts
|
|
4
|
+
/**
|
|
5
|
+
* A fake of the parts of GitLab's API that `@goodfellow-cms/gitlab` uses, backed by
|
|
6
|
+
* an in-memory repository, including OAuth with PKCE. For tests only.
|
|
7
|
+
*/
|
|
8
|
+
function json(body, status = 200) {
|
|
9
|
+
return new Response(JSON.stringify(body), {
|
|
10
|
+
status,
|
|
11
|
+
headers: { "content-type": "application/json" }
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
async function sha256Base64Url(text) {
|
|
15
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
|
|
16
|
+
let binary = "";
|
|
17
|
+
for (const byte of digest) binary += String.fromCharCode(byte);
|
|
18
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
19
|
+
}
|
|
20
|
+
function fakeGitLab(options) {
|
|
21
|
+
const webUrl = options.url ?? "https://gitlab.com";
|
|
22
|
+
const repo = new FakeRepo(options.files);
|
|
23
|
+
const tokens = new Map(Object.entries(options.tokens ?? { "test-token": {
|
|
24
|
+
username: "editor",
|
|
25
|
+
name: "Test Editor"
|
|
26
|
+
} }));
|
|
27
|
+
const codes = /* @__PURE__ */ new Map();
|
|
28
|
+
const refreshTokens = /* @__PURE__ */ new Map();
|
|
29
|
+
const pipelines = /* @__PURE__ */ new Map();
|
|
30
|
+
const requests = [];
|
|
31
|
+
let counter = 0;
|
|
32
|
+
function commit(changes, message) {
|
|
33
|
+
const sha = repo.commit(changes, message);
|
|
34
|
+
if (options.deployAfterChecks !== void 0) pipelines.set(sha, { checks: 0 });
|
|
35
|
+
return sha;
|
|
36
|
+
}
|
|
37
|
+
function issueTokens(user) {
|
|
38
|
+
counter += 1;
|
|
39
|
+
const access = `oauth-access-${counter}`;
|
|
40
|
+
const refresh = `oauth-refresh-${counter}`;
|
|
41
|
+
tokens.set(access, user);
|
|
42
|
+
refreshTokens.set(refresh, user);
|
|
43
|
+
return {
|
|
44
|
+
access_token: access,
|
|
45
|
+
refresh_token: refresh,
|
|
46
|
+
expires_in: options.oauth?.expiresIn ?? 7200,
|
|
47
|
+
token_type: "Bearer"
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* What GitLab's sign-in page does once the person approves: returns the
|
|
52
|
+
* address the browser is sent back to, with a one-time code.
|
|
53
|
+
*/
|
|
54
|
+
function authorize(authorizeUrl) {
|
|
55
|
+
const params = new URL(authorizeUrl).searchParams;
|
|
56
|
+
if (params.get("client_id") !== options.oauth?.clientId) throw new Error("Unknown OAuth application");
|
|
57
|
+
if (params.get("code_challenge_method") !== "S256") throw new Error("PKCE with S256 is required");
|
|
58
|
+
counter += 1;
|
|
59
|
+
const code = `code-${counter}`;
|
|
60
|
+
codes.set(code, {
|
|
61
|
+
challenge: params.get("code_challenge") ?? "",
|
|
62
|
+
redirectUri: params.get("redirect_uri") ?? ""
|
|
63
|
+
});
|
|
64
|
+
const back = new URL(params.get("redirect_uri") ?? "");
|
|
65
|
+
back.searchParams.set("code", code);
|
|
66
|
+
back.searchParams.set("state", params.get("state") ?? "");
|
|
67
|
+
return back.toString();
|
|
68
|
+
}
|
|
69
|
+
async function handle(request) {
|
|
70
|
+
requests.push(request.clone());
|
|
71
|
+
const url = new URL(request.url);
|
|
72
|
+
if (url.pathname === "/oauth/token" && request.method === "POST") {
|
|
73
|
+
const body = new URLSearchParams(await request.text());
|
|
74
|
+
if (body.get("client_id") !== options.oauth?.clientId || !options.oauth) return json({ error: "invalid_client" }, 401);
|
|
75
|
+
if (body.get("grant_type") === "authorization_code") {
|
|
76
|
+
const issued = codes.get(body.get("code") ?? "");
|
|
77
|
+
codes.delete(body.get("code") ?? "");
|
|
78
|
+
if (!issued || issued.redirectUri !== body.get("redirect_uri")) return json({ error: "invalid_grant" }, 400);
|
|
79
|
+
if (await sha256Base64Url(body.get("code_verifier") ?? "") !== issued.challenge) return json({
|
|
80
|
+
error: "invalid_grant",
|
|
81
|
+
error_description: "PKCE verification failed"
|
|
82
|
+
}, 400);
|
|
83
|
+
return json(issueTokens(options.oauth.user));
|
|
84
|
+
}
|
|
85
|
+
if (body.get("grant_type") === "refresh_token") {
|
|
86
|
+
const user = refreshTokens.get(body.get("refresh_token") ?? "");
|
|
87
|
+
refreshTokens.delete(body.get("refresh_token") ?? "");
|
|
88
|
+
return user ? json(issueTokens(user)) : json({ error: "invalid_grant" }, 400);
|
|
89
|
+
}
|
|
90
|
+
return json({ error: "unsupported_grant_type" }, 400);
|
|
91
|
+
}
|
|
92
|
+
if (!url.pathname.startsWith("/api/v4/")) return json({ message: "404 Not Found" }, 404);
|
|
93
|
+
const user = tokens.get(request.headers.get("authorization")?.replace(/^Bearer /, "") ?? "");
|
|
94
|
+
if (!user) return json({ message: "401 Unauthorized" }, 401);
|
|
95
|
+
const path = url.pathname.slice(7);
|
|
96
|
+
if (path === "/user") return json({
|
|
97
|
+
username: user.username,
|
|
98
|
+
name: user.name,
|
|
99
|
+
avatar_url: `https://avatars.example/${user.username}`
|
|
100
|
+
});
|
|
101
|
+
const projectMatch = path.match(/^\/projects\/([^/]+)(\/.*)?$/);
|
|
102
|
+
if (!projectMatch || decodeURIComponent(projectMatch[1] ?? "") !== options.project) return json({ message: "404 Project Not Found" }, 404);
|
|
103
|
+
const rest = projectMatch[2] ?? "";
|
|
104
|
+
const accessLevel = user.accessLevel ?? 40;
|
|
105
|
+
if (rest === "") return json({
|
|
106
|
+
default_branch: repo.defaultBranch,
|
|
107
|
+
permissions: {
|
|
108
|
+
project_access: { access_level: accessLevel },
|
|
109
|
+
group_access: null
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
const branch = rest.match(/^\/repository\/branches\/(.+)$/);
|
|
113
|
+
if (branch) {
|
|
114
|
+
const sha = repo.branches.get(decodeURIComponent(branch[1] ?? ""));
|
|
115
|
+
return sha ? json({ commit: { id: sha } }) : json({ message: "404 Branch Not Found" }, 404);
|
|
116
|
+
}
|
|
117
|
+
if (rest === "/repository/tree") {
|
|
118
|
+
const ref = url.searchParams.get("ref") ?? repo.defaultBranch;
|
|
119
|
+
const dir = url.searchParams.get("path") ?? "";
|
|
120
|
+
const perPage = Number(url.searchParams.get("per_page") ?? 20);
|
|
121
|
+
const page = Number(url.searchParams.get("page") ?? 1);
|
|
122
|
+
const entries = [...repo.files(ref)].filter(([file]) => file.startsWith(`${dir}/`)).map(([file, content]) => ({
|
|
123
|
+
id: repo.blobSha(content),
|
|
124
|
+
type: "blob",
|
|
125
|
+
path: file,
|
|
126
|
+
name: file.split("/").at(-1)
|
|
127
|
+
}));
|
|
128
|
+
if (entries.length === 0) return json({ message: "404 Tree Not Found" }, 404);
|
|
129
|
+
return json(entries.slice((page - 1) * perPage, page * perPage));
|
|
130
|
+
}
|
|
131
|
+
const blob = rest.match(/^\/repository\/blobs\/([0-9a-f]{40})\/raw$/);
|
|
132
|
+
if (blob) {
|
|
133
|
+
const content = repo.blobs.get(blob[1] ?? "");
|
|
134
|
+
return content === void 0 ? json({ message: "404 Blob Not Found" }, 404) : new Response(fakeFileBytes(content));
|
|
135
|
+
}
|
|
136
|
+
const file = rest.match(/^\/repository\/files\/([^/]+)$/);
|
|
137
|
+
if (file) {
|
|
138
|
+
const filePath = decodeURIComponent(file[1] ?? "");
|
|
139
|
+
const ref = url.searchParams.get("ref") ?? repo.defaultBranch;
|
|
140
|
+
const lastCommit = repo.files(ref).has(filePath) ? repo.lastCommitFor(filePath, ref) : void 0;
|
|
141
|
+
return lastCommit ? json({
|
|
142
|
+
file_path: filePath,
|
|
143
|
+
last_commit_id: lastCommit
|
|
144
|
+
}) : json({ message: "404 File Not Found" }, 404);
|
|
145
|
+
}
|
|
146
|
+
if (rest === "/repository/commits" && request.method === "POST") {
|
|
147
|
+
if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
|
|
148
|
+
const body = await request.json();
|
|
149
|
+
const files = repo.files(body.branch);
|
|
150
|
+
for (const action of body.actions) {
|
|
151
|
+
if (action.action === "create" && files.has(action.file_path)) return json({ message: "A file with this name already exists" }, 400);
|
|
152
|
+
if ((action.action === "update" || action.action === "delete") && !files.has(action.file_path)) return json({ message: "A file with this name doesn't exist" }, 400);
|
|
153
|
+
if (action.last_commit_id && repo.lastCommitFor(action.file_path, body.branch) !== action.last_commit_id) return json({ message: "You are attempting to update a file that has changed since you started editing it." }, 400);
|
|
154
|
+
}
|
|
155
|
+
return json({
|
|
156
|
+
id: commit(body.actions.map((action) => action.action === "delete" ? {
|
|
157
|
+
path: action.file_path,
|
|
158
|
+
delete: true
|
|
159
|
+
} : action.encoding === "base64" ? {
|
|
160
|
+
path: action.file_path,
|
|
161
|
+
bytes: decodeBase64(action.content ?? "")
|
|
162
|
+
} : {
|
|
163
|
+
path: action.file_path,
|
|
164
|
+
content: action.content ?? ""
|
|
165
|
+
}), body.commit_message),
|
|
166
|
+
message: body.commit_message
|
|
167
|
+
}, 201);
|
|
168
|
+
}
|
|
169
|
+
if (rest === "/repository/compare") return json({ diffs: repo.changedPaths(url.searchParams.get("from") ?? "", url.searchParams.get("to") ?? "").map((changedPath) => ({
|
|
170
|
+
old_path: changedPath,
|
|
171
|
+
new_path: changedPath
|
|
172
|
+
})) });
|
|
173
|
+
const statuses = rest.match(/^\/repository\/commits\/([0-9a-f]{40})\/statuses$/);
|
|
174
|
+
if (statuses) {
|
|
175
|
+
const pipeline = pipelines.get(statuses[1] ?? "");
|
|
176
|
+
if (!pipeline) return json([]);
|
|
177
|
+
pipeline.checks += 1;
|
|
178
|
+
return json([{
|
|
179
|
+
name: "pages",
|
|
180
|
+
status: pipeline.checks > (options.deployAfterChecks ?? 0) ? "success" : "running",
|
|
181
|
+
target_url: `${webUrl}/${options.project}/-/jobs/1`
|
|
182
|
+
}]);
|
|
183
|
+
}
|
|
184
|
+
if (rest === "/pipelines") return json([...pipelines.keys()].map((sha, index) => ({
|
|
185
|
+
id: index + 1,
|
|
186
|
+
sha
|
|
187
|
+
})));
|
|
188
|
+
return json({ message: "404 Not Found" }, 404);
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
repo,
|
|
192
|
+
requests,
|
|
193
|
+
commit,
|
|
194
|
+
authorize,
|
|
195
|
+
handle,
|
|
196
|
+
fetch: ((input, init) => handle(new Request(input, init)))
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
//#endregion
|
|
200
|
+
export { fakeGitLab };
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodfellow-cms/gitlab",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Edit a Goodfellow site stored on GitLab, straight from the browser.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"goodfellow",
|
|
7
|
+
"cms",
|
|
8
|
+
"website-builder",
|
|
9
|
+
"puck",
|
|
10
|
+
"gitlab",
|
|
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/gitlab"
|
|
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
|
}
|