@goodfellow-cms/gitlab 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1026 @@
1
+ import { ConflictError, DomainError, EDITABLE_FOLDERS, EditorsError, GitApiError, SetupError, SignInError, UpdatesError, buildStepFor, credentialStorage, encodeBase64Bytes, findBuildCause, findUpdateResult } 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/editors.ts
29
+ /** GitLab's roles: Developer, Maintainer and Owner. */
30
+ const DEVELOPER = 30;
31
+ const MAINTAINER = 40;
32
+ const MEMBER = "member:";
33
+ const INVITATION = "invitation:";
34
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
35
+ const USERNAME = /^[\w][\w.-]*$/;
36
+ function roleOf(level) {
37
+ if (level >= MAINTAINER) return "owner";
38
+ return level >= DEVELOPER ? "editor" : "viewer";
39
+ }
40
+ function levelOf(role) {
41
+ return role === "owner" ? MAINTAINER : DEVELOPER;
42
+ }
43
+ function notAllowed$1(error) {
44
+ if (error instanceof GitApiError && (error.status === 401 || error.status === 403)) throw new EditorsError("not-allowed", "Changing editors on GitLab needs the Maintainer role in the project.");
45
+ throw error;
46
+ }
47
+ /**
48
+ * The project's members. Editors are Developers, and owners Maintainers,
49
+ * who can also add and remove members. GitLab protects the main branch so
50
+ * only Maintainers can publish to it, until `letEditorsPublish()` lets
51
+ * Developers publish too.
52
+ */
53
+ function gitlabEditors(api, project, branch, username) {
54
+ const base = `/projects/${encodeURIComponent(project)}`;
55
+ const protectedPath = `${base}/protected_branches/${encodeURIComponent(branch)}`;
56
+ async function all(path) {
57
+ const found = [];
58
+ for (let page = 1; page <= 10; page++) {
59
+ const items = await gitlabJson(api, `${path}${path.includes("?") ? "&" : "?"}per_page=100&page=${page}`);
60
+ found.push(...items);
61
+ if (items.length < 100) break;
62
+ }
63
+ return found;
64
+ }
65
+ return {
66
+ invitesByEmail: true,
67
+ async list() {
68
+ const [everyone, direct] = await Promise.all([all(`${base}/members/all`), all(`${base}/members`)]);
69
+ const own = new Set(direct.map((member) => member.id));
70
+ const self = everyone.find((member) => member.username.toLowerCase() === username.toLowerCase());
71
+ const manage = (self?.access_level ?? 0) >= MAINTAINER;
72
+ const invitations = manage ? await all(`${base}/invitations`).catch(() => []) : [];
73
+ const protection = await gitlabRequest(api, protectedPath, { allow: [404] });
74
+ const levels = protection.ok ? (await protection.json()).push_access_levels ?? [] : [];
75
+ const editorsCanPublish = !protection.ok || levels.some((level) => level.access_level !== null && level.access_level <= DEVELOPER);
76
+ return {
77
+ editors: [...everyone.map((member) => ({
78
+ id: `${MEMBER}${member.id}`,
79
+ login: member.username,
80
+ name: member.name,
81
+ avatarUrl: member.avatar_url,
82
+ role: roleOf(member.access_level),
83
+ ...!own.has(member.id) && { inherited: true },
84
+ ...member.id === self?.id && { self: true }
85
+ })), ...invitations.map((invitation) => ({
86
+ id: `${INVITATION}${invitation.invite_email}`,
87
+ email: invitation.invite_email,
88
+ role: roleOf(invitation.access_level),
89
+ invited: true
90
+ }))],
91
+ manage: manage ? "yes" : "no",
92
+ editorsCanPublish
93
+ };
94
+ },
95
+ async invite(who, role) {
96
+ const name = who.trim().replace(/^@/, "");
97
+ if (name.includes("@")) {
98
+ if (!EMAIL.test(name)) throw new EditorsError("invalid", "That isn't an email address.");
99
+ const result = await gitlabJson(api, `${base}/invitations`, {
100
+ method: "POST",
101
+ body: {
102
+ email: name,
103
+ access_level: levelOf(role)
104
+ }
105
+ }).catch(notAllowed$1);
106
+ if (result.status !== "success") {
107
+ const message = typeof result.message === "string" ? result.message : Object.values(result.message ?? {})[0];
108
+ if (/already/i.test(message ?? "")) throw new EditorsError("already", "They've already been invited.");
109
+ throw new EditorsError("invalid", `GitLab didn't accept the invitation: ${message ?? result.status}`);
110
+ }
111
+ return;
112
+ }
113
+ if (!USERNAME.test(name)) throw new EditorsError("invalid", "That isn't a GitLab username or email address.");
114
+ const [user] = await gitlabJson(api, `/users?${new URLSearchParams({ username: name })}`);
115
+ if (!user) throw new EditorsError("not-found", `There's no GitLab account named ${name}.`);
116
+ try {
117
+ await gitlabRequest(api, `${base}/members`, {
118
+ method: "POST",
119
+ body: {
120
+ user_id: user.id,
121
+ access_level: levelOf(role)
122
+ }
123
+ });
124
+ } catch (error) {
125
+ if (error instanceof GitApiError && error.status === 409) throw new EditorsError("already", `${name} already edits the site.`);
126
+ notAllowed$1(error);
127
+ }
128
+ },
129
+ async setRole(editor, role) {
130
+ await gitlabRequest(api, editor.id.startsWith(INVITATION) ? `${base}/invitations/${encodeURIComponent(editor.id.slice(11))}` : `${base}/members/${editor.id.slice(7)}`, {
131
+ method: "PUT",
132
+ body: { access_level: levelOf(role) }
133
+ }).catch(notAllowed$1);
134
+ },
135
+ async remove(editor) {
136
+ await gitlabRequest(api, editor.id.startsWith(INVITATION) ? `${base}/invitations/${encodeURIComponent(editor.id.slice(11))}` : `${base}/members/${editor.id.slice(7)}`, { method: "DELETE" }).catch(notAllowed$1);
137
+ },
138
+ async letEditorsPublish() {
139
+ const protect = (level) => gitlabRequest(api, `${base}/protected_branches`, {
140
+ method: "POST",
141
+ body: {
142
+ name: branch,
143
+ push_access_level: level,
144
+ merge_access_level: MAINTAINER,
145
+ allow_force_push: false
146
+ }
147
+ });
148
+ await gitlabRequest(api, protectedPath, {
149
+ method: "DELETE",
150
+ allow: [404]
151
+ }).catch(notAllowed$1);
152
+ try {
153
+ await protect(DEVELOPER);
154
+ } catch (error) {
155
+ await protect(MAINTAINER).catch(() => void 0);
156
+ notAllowed$1(error);
157
+ }
158
+ }
159
+ };
160
+ }
161
+ //#endregion
162
+ //#region src/pages.ts
163
+ /** GitLab.com Pages' addresses for an apex domain, from GitLab's documentation. */
164
+ const PAGES_IPV4 = "35.185.44.232";
165
+ const PAGES_IPV6 = "2600:1901:0:7b8a::";
166
+ /** The domains `connect()` adds: the domain, and `www.` for an apex domain that wants it. */
167
+ function domainsFor(domain, { apex, www }) {
168
+ return apex && www ? [domain, `www.${domain}`] : [domain];
169
+ }
170
+ /**
171
+ * The records GitLab Pages needs: its address for an apex domain, or an alias
172
+ * of the namespace's gitlab.io otherwise, and a TXT record per domain that
173
+ * proves it's the owner's, with the codes GitLab gave.
174
+ */
175
+ function gitlabPagesRecords(project, domain, options, codes) {
176
+ const target = `${(project.split("/")[0] ?? "").toLowerCase()}.gitlab.io`;
177
+ const site = options.apex ? [{
178
+ type: "A",
179
+ name: domain,
180
+ value: PAGES_IPV4,
181
+ purpose: "site"
182
+ }, {
183
+ type: "AAAA",
184
+ name: domain,
185
+ value: PAGES_IPV6,
186
+ purpose: "site",
187
+ optional: true
188
+ }] : [{
189
+ type: "CNAME",
190
+ name: domain,
191
+ value: target,
192
+ purpose: "site"
193
+ }];
194
+ const www = options.apex && options.www ? [{
195
+ type: "CNAME",
196
+ name: `www.${domain}`,
197
+ value: target,
198
+ purpose: "www",
199
+ optional: true
200
+ }] : [];
201
+ const verification = domainsFor(domain, options).flatMap((name) => {
202
+ const code = codes[name];
203
+ if (!code) return [];
204
+ const value = code.startsWith("gitlab-pages-verification-code=") ? code : `gitlab-pages-verification-code=${code}`;
205
+ return [{
206
+ type: "TXT",
207
+ name: `_gitlab-pages-verification-code.${name}`,
208
+ value,
209
+ purpose: "verification",
210
+ optional: name !== domain
211
+ }];
212
+ });
213
+ return [
214
+ ...site,
215
+ ...www,
216
+ ...verification
217
+ ];
218
+ }
219
+ /** GitLab Pages, for a site's project: where it's published, and its custom domains. */
220
+ function gitlabPages(api, project) {
221
+ const pagesPath = `/projects/${encodeURIComponent(project)}/pages`;
222
+ const domainPath = (domain) => `${pagesPath}/domains/${encodeURIComponent(domain)}`;
223
+ function notAllowed(error) {
224
+ if (error instanceof GitApiError && (error.status === 401 || error.status === 403)) throw new DomainError("not-allowed", "Connecting a domain on GitLab needs the Maintainer role in the project.");
225
+ throw error;
226
+ }
227
+ async function pages() {
228
+ const response = await gitlabRequest(api, pagesPath, { allow: [404] }).catch(notAllowed);
229
+ return response.ok ? await response.json() : void 0;
230
+ }
231
+ async function codes(domains) {
232
+ const found = await Promise.all(domains.map(async (domain) => {
233
+ const response = await gitlabRequest(api, domainPath(domain), { allow: [404] });
234
+ if (!response.ok) return [domain, ""];
235
+ return [domain, (await response.json()).verification_code ?? ""];
236
+ }));
237
+ return Object.fromEntries(found);
238
+ }
239
+ async function verified(domain) {
240
+ const found = await gitlabJson(api, domainPath(domain)).catch(notAllowed);
241
+ if (found.verified) return found;
242
+ const response = await gitlabRequest(api, `${domainPath(domain)}/verify`, {
243
+ method: "PUT",
244
+ allow: [
245
+ 400,
246
+ 404,
247
+ 422
248
+ ]
249
+ });
250
+ return response.ok ? await response.json() : found;
251
+ }
252
+ return {
253
+ async site() {
254
+ const found = await pages();
255
+ if (!found) return void 0;
256
+ const names = (await gitlabJson(api, `${pagesPath}/domains`).catch(() => [])).map((entry) => entry.domain);
257
+ const domain = (found.primary_domain && names.includes(found.primary_domain) ? found.primary_domain : void 0) ?? names.sort((a, b) => a.length - b.length)[0];
258
+ return {
259
+ url: found.url,
260
+ domain
261
+ };
262
+ },
263
+ async connect(domain, options) {
264
+ const found = await pages();
265
+ if (found && found.is_unique_domain_enabled === false) await gitlabRequest(api, pagesPath, {
266
+ method: "PATCH",
267
+ body: { pages_unique_domain_enabled: true }
268
+ }).catch(notAllowed);
269
+ for (const name of domainsFor(domain, options)) try {
270
+ await gitlabRequest(api, `${pagesPath}/domains`, {
271
+ method: "POST",
272
+ body: {
273
+ domain: name,
274
+ auto_ssl_enabled: true
275
+ }
276
+ });
277
+ } catch (error) {
278
+ if (error instanceof GitApiError && error.status === 400) {
279
+ if (/taken/i.test(error.message)) {
280
+ if ((await gitlabRequest(api, domainPath(name), { allow: [404] })).ok) continue;
281
+ throw new DomainError("taken", "Another GitLab Pages site already uses this domain.");
282
+ }
283
+ throw new DomainError("invalid", "GitLab Pages doesn't accept this domain.");
284
+ }
285
+ notAllowed(error);
286
+ }
287
+ return gitlabPagesRecords(project, domain, options, await codes(domainsFor(domain, options)));
288
+ },
289
+ async records(domain, options) {
290
+ return gitlabPagesRecords(project, domain, options, await codes(domainsFor(domain, options)));
291
+ },
292
+ async status(domain) {
293
+ const found = await verified(domain);
294
+ const www = await gitlabRequest(api, domainPath(`www.${domain}`), { allow: [404] });
295
+ if (www.ok && !(await www.json()).verified) await verified(`www.${domain}`);
296
+ const settings = await pages();
297
+ return {
298
+ verified: found.verified,
299
+ certificate: found.certificate && !found.certificate.expired ? "ready" : "pending",
300
+ httpsOnly: settings?.force_https === true,
301
+ primary: settings?.primary_domain === void 0 || settings.primary_domain === domain
302
+ };
303
+ },
304
+ async secure(domain) {
305
+ const found = await gitlabJson(api, domainPath(domain)).catch(notAllowed);
306
+ if (!found.verified || !found.certificate) throw new DomainError("not-ready", "The domain's certificate isn't ready yet.");
307
+ try {
308
+ await gitlabRequest(api, pagesPath, {
309
+ method: "PATCH",
310
+ body: {
311
+ pages_https_only: true,
312
+ pages_primary_domain: domain
313
+ }
314
+ });
315
+ } catch (error) {
316
+ if (!(error instanceof GitApiError && error.status === 400)) notAllowed(error);
317
+ await gitlabRequest(api, pagesPath, {
318
+ method: "PATCH",
319
+ body: { pages_https_only: true }
320
+ }).catch(notAllowed);
321
+ }
322
+ },
323
+ async disconnect(domain) {
324
+ for (const name of [domain, `www.${domain}`]) await gitlabRequest(api, domainPath(name), {
325
+ method: "DELETE",
326
+ allow: [404]
327
+ }).catch(notAllowed);
328
+ }
329
+ };
330
+ }
331
+ //#endregion
332
+ //#region src/updates.ts
333
+ const CI_FILE = ".gitlab-ci.yml";
334
+ /** When the nightly schedule the Updates screen adds runs, in UTC, as the GitHub Pages workflow's does. */
335
+ const NIGHTLY = "17 4 * * *";
336
+ function notAllowed(error) {
337
+ if (error instanceof GitApiError && (error.status === 401 || error.status === 403)) throw new UpdatesError("not-allowed", "Setting up updates on GitLab needs the Maintainer role in the project.");
338
+ throw error;
339
+ }
340
+ /**
341
+ * The site's nightly update, which runs in its GitLab Pages job on scheduled
342
+ * pipelines, and on pipelines the Updates screen starts with `GOODFELLOW_UPDATE`.
343
+ * It publishes with the job's own token, which GitLab only allows once the
344
+ * project lets job tokens push.
345
+ */
346
+ function gitlabUpdates(api, project, branch, readAt) {
347
+ const base = `/projects/${encodeURIComponent(project)}`;
348
+ async function schedules() {
349
+ return gitlabJson(api, `${base}/pipeline_schedules`);
350
+ }
351
+ return {
352
+ async setup() {
353
+ if (!(await readAt(CI_FILE))?.includes("update --publish")) return "missing";
354
+ try {
355
+ const [settings, found] = await Promise.all([gitlabJson(api, base), schedules()]);
356
+ const scheduled = found.some((schedule) => schedule.active && (schedule.ref === branch || schedule.ref === `refs/heads/${branch}`));
357
+ return settings.ci_push_repository_for_job_token_allowed && scheduled ? "ready" : "needs-setup";
358
+ } catch (error) {
359
+ if (error instanceof GitApiError && error.status === 403) return "needs-setup";
360
+ throw error;
361
+ }
362
+ },
363
+ async enable() {
364
+ await gitlabRequest(api, base, {
365
+ method: "PUT",
366
+ body: { ci_push_repository_for_job_token_allowed: true }
367
+ }).catch(notAllowed);
368
+ if (!(await schedules().catch(notAllowed)).some((schedule) => schedule.active && schedule.ref.endsWith(branch))) await gitlabRequest(api, `${base}/pipeline_schedules`, {
369
+ method: "POST",
370
+ body: {
371
+ description: "Rebuild the site and install Goodfellow's fixes each night",
372
+ ref: branch,
373
+ cron: NIGHTLY,
374
+ cron_timezone: "UTC",
375
+ active: true
376
+ }
377
+ }).catch(notAllowed);
378
+ },
379
+ async lastRun() {
380
+ const pipelines = await gitlabJson(api, `${base}/pipelines?${new URLSearchParams({
381
+ ref: branch,
382
+ per_page: "20"
383
+ })}`);
384
+ for (const pipeline of pipelines) {
385
+ if (pipeline.source !== "schedule" && pipeline.source !== "api") continue;
386
+ const run = {
387
+ date: pipeline.created_at,
388
+ detailsUrl: pipeline.web_url
389
+ };
390
+ if ([
391
+ "created",
392
+ "pending",
393
+ "running",
394
+ "waiting_for_resource",
395
+ "preparing"
396
+ ].includes(pipeline.status)) return {
397
+ state: "running",
398
+ ...run
399
+ };
400
+ const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === "pages");
401
+ if (!job) continue;
402
+ const response = await gitlabRequest(api, `${base}/jobs/${job.id}/trace`, { allow: [403, 404] });
403
+ const result = response.ok ? findUpdateResult(await response.text()) : void 0;
404
+ if (!result) continue;
405
+ return {
406
+ state: result.state,
407
+ result,
408
+ ...run
409
+ };
410
+ }
411
+ },
412
+ async start(target) {
413
+ await gitlabRequest(api, `${base}/pipeline`, {
414
+ method: "POST",
415
+ body: {
416
+ ref: branch,
417
+ variables: [{
418
+ key: "GOODFELLOW_UPDATE",
419
+ variable_type: "env_var",
420
+ value: target
421
+ }]
422
+ }
423
+ }).catch(notAllowed);
424
+ }
425
+ };
426
+ }
427
+ //#endregion
428
+ //#region src/backend.ts
429
+ const PAGE_SIZE = 100;
430
+ /** The color and section codes in GitLab's job logs. */
431
+ const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g");
432
+ /**
433
+ * The site's files on GitLab. Reads come from the commit `revision()` last
434
+ * returned. Saves are single commits; each changed file carries the commit it
435
+ * was last changed in, so GitLab refuses to overwrite anyone else's edits.
436
+ */
437
+ var GitLabBackend = class {
438
+ user;
439
+ pages;
440
+ editors;
441
+ updates;
442
+ api;
443
+ project;
444
+ branch;
445
+ onSignOut;
446
+ pinned;
447
+ trees = /* @__PURE__ */ new Map();
448
+ blobs = /* @__PURE__ */ new Map();
449
+ constructor(options) {
450
+ this.api = options.api;
451
+ this.project = encodeURIComponent(options.project);
452
+ this.branch = options.branch;
453
+ this.user = options.user;
454
+ this.onSignOut = options.onSignOut;
455
+ this.pages = gitlabPages(this.api, options.project);
456
+ this.editors = gitlabEditors(this.api, options.project, this.branch, this.user.login);
457
+ this.updates = gitlabUpdates(this.api, options.project, this.branch, async (path) => this.readAt(path, this.pinned ?? await this.revision()));
458
+ }
459
+ repo(path) {
460
+ return `/projects/${this.project}/repository${path}`;
461
+ }
462
+ async revision() {
463
+ const branch = await gitlabJson(this.api, this.repo(`/branches/${encodeURIComponent(this.branch)}`));
464
+ this.pinned = branch.commit.id;
465
+ return this.pinned;
466
+ }
467
+ /** Every file in one folder at a commit, page by page. */
468
+ async listFolder(sha, dir) {
469
+ const files = [];
470
+ for (let page = 1;; page++) {
471
+ const query = new URLSearchParams({
472
+ ref: sha,
473
+ path: dir,
474
+ recursive: "true",
475
+ per_page: String(PAGE_SIZE),
476
+ page: String(page)
477
+ });
478
+ const response = await gitlabRequest(this.api, this.repo(`/tree?${query}`), { allow: [404] });
479
+ if (response.status === 404) return files;
480
+ const entries = await response.json();
481
+ for (const entry of entries) if (entry.type === "blob") files.push([entry.path, entry.id]);
482
+ if (entries.length < PAGE_SIZE) return files;
483
+ }
484
+ }
485
+ /** Path → blob id for the editable files at a commit. Commits never change, so this is cached. */
486
+ tree(sha) {
487
+ let tree = this.trees.get(sha);
488
+ if (!tree) {
489
+ tree = Promise.all(EDITABLE_FOLDERS.map((folder) => this.listFolder(sha, folder))).then((folders) => new Map(folders.flat()));
490
+ tree.catch(() => this.trees.delete(sha));
491
+ this.trees.set(sha, tree);
492
+ }
493
+ return tree;
494
+ }
495
+ async currentTree() {
496
+ return this.tree(this.pinned ?? await this.revision());
497
+ }
498
+ async read(path) {
499
+ const sha = (await this.currentTree()).get(path);
500
+ if (!sha) return void 0;
501
+ let blob = this.blobs.get(sha);
502
+ if (!blob) {
503
+ blob = gitlabRequest(this.api, this.repo(`/blobs/${sha}/raw`)).then((response) => response.text());
504
+ blob.catch(() => this.blobs.delete(sha));
505
+ this.blobs.set(sha, blob);
506
+ }
507
+ return blob;
508
+ }
509
+ async readBytes(path) {
510
+ const sha = (await this.currentTree()).get(path);
511
+ if (!sha) return void 0;
512
+ const response = await gitlabRequest(this.api, this.repo(`/blobs/${sha}/raw`));
513
+ return new Uint8Array(await response.arrayBuffer());
514
+ }
515
+ async list(dir) {
516
+ const prefix = `${dir}/`;
517
+ return [...(await this.currentTree()).keys()].filter((path) => path.startsWith(prefix)).sort();
518
+ }
519
+ async lastCommitFor(path, ref) {
520
+ return (await gitlabJson(this.api, this.repo(`/files/${encodeURIComponent(path)}?ref=${ref}`))).last_commit_id;
521
+ }
522
+ async write(changes, { message, expectedRevision }) {
523
+ if (await this.revision() !== expectedRevision) throw new ConflictError();
524
+ const existing = await this.tree(expectedRevision);
525
+ const toApply = (await Promise.all(changes.map(async (change) => {
526
+ const exists = existing.has(change.path);
527
+ if ("delete" in change) return exists ? {
528
+ action: "delete",
529
+ file_path: change.path,
530
+ last_commit_id: await this.lastCommitFor(change.path, expectedRevision)
531
+ } : void 0;
532
+ const content = "bytes" in change ? {
533
+ content: encodeBase64Bytes(change.bytes),
534
+ encoding: "base64"
535
+ } : { content: change.content };
536
+ return exists ? {
537
+ action: "update",
538
+ file_path: change.path,
539
+ ...content,
540
+ last_commit_id: await this.lastCommitFor(change.path, expectedRevision)
541
+ } : {
542
+ action: "create",
543
+ file_path: change.path,
544
+ ...content
545
+ };
546
+ }))).filter((action) => action !== void 0);
547
+ if (toApply.length === 0) return { revision: expectedRevision };
548
+ try {
549
+ const commit = await gitlabJson(this.api, this.repo("/commits"), {
550
+ method: "POST",
551
+ body: {
552
+ branch: this.branch,
553
+ commit_message: message,
554
+ actions: toApply
555
+ }
556
+ });
557
+ this.pinned = commit.id;
558
+ return { revision: commit.id };
559
+ } catch (error) {
560
+ if (!(error instanceof GitApiError)) throw error;
561
+ if (error.status === 403) throw new SignInError("cant-publish", "This GitLab account can't publish to the site.");
562
+ if (error.status === 400 && /already exists|changed since/i.test(error.message) || await this.revision() !== expectedRevision) throw new ConflictError();
563
+ throw error;
564
+ }
565
+ }
566
+ async history(path, { page = 1, perPage = 20 } = {}) {
567
+ const query = new URLSearchParams({
568
+ ref_name: this.pinned ?? await this.revision(),
569
+ path,
570
+ per_page: String(perPage),
571
+ page: String(page)
572
+ });
573
+ return (await gitlabJson(this.api, this.repo(`/commits?${query}`))).map((commit) => ({
574
+ revision: commit.id,
575
+ date: commit.authored_date ?? commit.committed_date ?? "",
576
+ author: commit.author_name || void 0,
577
+ message: commit.title
578
+ }));
579
+ }
580
+ async readAt(path, revision) {
581
+ const response = await gitlabRequest(this.api, this.repo(`/files/${encodeURIComponent(path)}/raw?${new URLSearchParams({ ref: revision })}`), { allow: [404] });
582
+ return response.ok ? response.text() : void 0;
583
+ }
584
+ async changedPaths(from, to) {
585
+ const comparison = await gitlabJson(this.api, this.repo(`/compare?${new URLSearchParams({
586
+ from,
587
+ to,
588
+ straight: "true"
589
+ })}`));
590
+ return [...new Set((comparison.diffs ?? []).flatMap((diff) => [diff.old_path, diff.new_path]))];
591
+ }
592
+ /**
593
+ * Reads the commit's pipeline and external statuses (GitLab Pages jobs, or
594
+ * Vercel). Before any exist, the site counts as building if it has pipelines.
595
+ */
596
+ async deployStatus(revision) {
597
+ try {
598
+ const statuses = await gitlabJson(this.api, this.repo(`/commits/${revision}/statuses`));
599
+ if (statuses.length === 0) return { state: (await gitlabJson(this.api, `/projects/${this.project}/pipelines?per_page=1`)).length > 0 ? "building" : "unknown" };
600
+ const failed = statuses.find((status) => status.status === "failed" || status.status === "canceled");
601
+ if (failed) {
602
+ const problem = await this.jobProblem(revision).catch(() => void 0);
603
+ return {
604
+ state: "failed",
605
+ detailsUrl: failed.target_url ?? void 0,
606
+ ...problem && { problem }
607
+ };
608
+ }
609
+ if (statuses.every((status) => status.status === "success" || status.status === "skipped")) return {
610
+ state: "live",
611
+ detailsUrl: statuses[0]?.target_url ?? void 0
612
+ };
613
+ return {
614
+ state: "building",
615
+ detailsUrl: statuses[0]?.target_url ?? void 0
616
+ };
617
+ } catch (error) {
618
+ if (error instanceof GitApiError) return { state: "unknown" };
619
+ throw error;
620
+ }
621
+ }
622
+ /** Why a commit's pipeline failed: the failed job's reason, and its log for which command failed and why. */
623
+ async jobProblem(revision) {
624
+ const [pipeline] = await gitlabJson(this.api, `/projects/${this.project}/pipelines?${new URLSearchParams({
625
+ sha: revision,
626
+ per_page: "1"
627
+ })}`);
628
+ if (!pipeline) return void 0;
629
+ const failed = (await gitlabJson(this.api, `/projects/${this.project}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((job) => job.status === "failed");
630
+ if (!failed) return void 0;
631
+ const reason = failed.failure_reason ?? "";
632
+ const detail = `${failed.name}: ${reason}`;
633
+ if (reason === "ci_quota_exceeded") return {
634
+ step: "not-started",
635
+ detail
636
+ };
637
+ if (/runner_system|stuck_or_timeout|scheduler|api_failure|data_integrity|job_execution_timeout/.test(reason)) return {
638
+ step: "host",
639
+ detail
640
+ };
641
+ if (failed.name === "pages:deploy") return {
642
+ step: "deploy",
643
+ detail
644
+ };
645
+ const response = await gitlabRequest(this.api, `/projects/${this.project}/jobs/${failed.id}/trace`, { allow: [403, 404] });
646
+ const trace = response.ok ? await response.text() : "";
647
+ const cause = findBuildCause(trace);
648
+ const command = trace.split("\n").map((line) => line.replace(ANSI_CODES, "").replace(/^.*\r/, "")).filter((line) => line.startsWith("$ ")).at(-1);
649
+ return {
650
+ step: cause ? "build" : (command ? buildStepFor(command) : void 0) ?? "build",
651
+ detail,
652
+ ...cause && { cause }
653
+ };
654
+ }
655
+ signOut() {
656
+ this.onSignOut();
657
+ }
658
+ };
659
+ //#endregion
660
+ //#region src/oauth.ts
661
+ /** OAuth 2.0 with PKCE: lets the browser sign in to GitLab without any server or client secret. */
662
+ function base64Url(bytes) {
663
+ let binary = "";
664
+ for (const byte of bytes) binary += String.fromCharCode(byte);
665
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
666
+ }
667
+ function randomString(bytes = 32) {
668
+ return base64Url(crypto.getRandomValues(new Uint8Array(bytes)));
669
+ }
670
+ async function codeChallenge(verifier) {
671
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
672
+ return base64Url(new Uint8Array(digest));
673
+ }
674
+ function toTokens(response, verifier, now = Date.now()) {
675
+ return {
676
+ type: "oauth",
677
+ access: response.access_token,
678
+ refresh: response.refresh_token,
679
+ expiresAt: now + (response.expires_in ?? 7200) * 1e3,
680
+ verifier
681
+ };
682
+ }
683
+ /** The admin panel's address, with a trailing slash so it always matches the redirect URI registered with GitLab. */
684
+ function redirectUri(href) {
685
+ const url = new URL(href);
686
+ return `${url.origin}${url.pathname.replace(/\/?$/, "/")}`;
687
+ }
688
+ //#endregion
689
+ //#region src/setup.ts
690
+ /** The branch the deploy setups build from. */
691
+ const MAIN = "main";
692
+ function browserLocation$1() {
693
+ return {
694
+ href: () => window.location.href,
695
+ assign: (url) => window.location.assign(url),
696
+ replace: (url) => window.history.replaceState(window.history.state, "", url)
697
+ };
698
+ }
699
+ /** Signing in to GitLab to create a site's project, with GitLab Pages if wanted. */
700
+ function gitlabSetup(options = {}) {
701
+ const webUrl = (options.url ?? "https://gitlab.com").replace(/\/$/, "");
702
+ const apiUrl = `${webUrl}/api/v4`;
703
+ const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
704
+ const pending = credentialStorage("goodfellow:gitlab-setup:pending", options.storage);
705
+ const location = () => options.location ?? browserLocation$1();
706
+ async function requestTokens(params) {
707
+ const response = await fetcher(`${webUrl}/oauth/token`, {
708
+ method: "POST",
709
+ headers: { "content-type": "application/x-www-form-urlencoded" },
710
+ body: new URLSearchParams(params)
711
+ });
712
+ if (!response.ok) throw new SignInError("invalid", `GitLab didn't accept the sign-in (${response.status}).`);
713
+ return await response.json();
714
+ }
715
+ async function connect(initial) {
716
+ let credentials = initial;
717
+ const token = async ({ refresh } = {}) => {
718
+ if (credentials.type === "token") return credentials.token;
719
+ if (refresh || credentials.expiresAt - 6e4 < Date.now()) {
720
+ const { verifier } = credentials;
721
+ credentials = toTokens(await requestTokens({
722
+ client_id: options.clientId ?? "",
723
+ refresh_token: credentials.refresh,
724
+ grant_type: "refresh_token",
725
+ redirect_uri: redirectUri(location().href()),
726
+ code_verifier: verifier
727
+ }), verifier);
728
+ }
729
+ return credentials.access;
730
+ };
731
+ const api = {
732
+ apiUrl,
733
+ token,
734
+ canRefresh: credentials.type === "oauth",
735
+ fetch: fetcher
736
+ };
737
+ const user = await gitlabJson(api, "/user");
738
+ async function isAvailable(owner, name) {
739
+ return (await gitlabRequest(api, `/projects/${encodeURIComponent(`${owner.path}/${name}`)}`, { allow: [404] })).status === 404;
740
+ }
741
+ return {
742
+ user: {
743
+ login: user.username,
744
+ name: user.name,
745
+ avatarUrl: user.avatar_url
746
+ },
747
+ async owners() {
748
+ const owners = (await gitlabJson(api, "/namespaces?per_page=100")).map((namespace) => ({
749
+ path: namespace.full_path,
750
+ name: namespace.kind === "user" ? user.name ?? namespace.name : namespace.name,
751
+ kind: namespace.kind === "user" ? "personal" : "organization",
752
+ id: namespace.id
753
+ }));
754
+ return [...owners.filter((owner) => owner.kind === "personal"), ...owners.filter((o) => o.kind !== "personal")];
755
+ },
756
+ isAvailable,
757
+ async createSite(site, onStep) {
758
+ onStep?.("repository");
759
+ if (!await isAvailable(site.owner, site.name)) throw new SetupError("name-taken", `There's already a project called ${site.owner.path}/${site.name}.`);
760
+ let project;
761
+ try {
762
+ project = await gitlabJson(api, "/projects", {
763
+ method: "POST",
764
+ body: {
765
+ name: site.name,
766
+ path: site.name,
767
+ namespace_id: site.owner.id,
768
+ visibility: site.private ? "private" : "public",
769
+ description: site.description,
770
+ ...site.pages && { pages_access_level: site.private ? "public" : "enabled" }
771
+ }
772
+ });
773
+ } catch (error) {
774
+ if (error instanceof GitApiError && error.status === 400) throw new SetupError("name-invalid", `GitLab can't use "${site.name}" as a project name.`);
775
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", `This sign-in can't create projects in ${site.owner.path}.`);
776
+ throw error;
777
+ }
778
+ onStep?.("files");
779
+ await gitlabRequest(api, `/projects/${project.id}/repository/commits`, {
780
+ method: "POST",
781
+ body: {
782
+ branch: MAIN,
783
+ commit_message: site.message,
784
+ actions: site.files.flatMap((file) => "delete" in file ? [] : "content" in file ? [{
785
+ action: "create",
786
+ file_path: file.path,
787
+ content: file.content
788
+ }] : [{
789
+ action: "create",
790
+ file_path: file.path,
791
+ content: encodeBase64Bytes(file.bytes),
792
+ encoding: "base64"
793
+ }])
794
+ }
795
+ });
796
+ return {
797
+ repo: project.path_with_namespace,
798
+ webUrl: project.web_url,
799
+ buildsUrl: `${project.web_url}/-/pipelines`,
800
+ pagesUrl: site.pages ? `${project.web_url}/pages` : void 0,
801
+ warnings: []
802
+ };
803
+ }
804
+ };
805
+ }
806
+ return {
807
+ name: "GitLab",
808
+ canRedirect: Boolean(options.clientId),
809
+ tokenLinks: [{
810
+ url: `${webUrl}/-/user_settings/personal_access_tokens?${new URLSearchParams({
811
+ name: "Goodfellow site setup",
812
+ scopes: "api"
813
+ })}`,
814
+ label: "signIn.token.create"
815
+ }],
816
+ async restore() {
817
+ const url = new URL(location().href());
818
+ const code = url.searchParams.get("code");
819
+ const error = url.searchParams.get("error");
820
+ if (!code && !error) return null;
821
+ const saved = pending.load();
822
+ if (!saved) return null;
823
+ pending.clear();
824
+ const redirect = JSON.parse(saved);
825
+ const returnedState = url.searchParams.get("state");
826
+ for (const key of [
827
+ "code",
828
+ "state",
829
+ "error",
830
+ "error_description"
831
+ ]) url.searchParams.delete(key);
832
+ location().replace(url.toString());
833
+ if (error || !code || returnedState !== redirect.state) throw new SignInError("redirect-failed", "Signing in with GitLab was cancelled or didn't finish.");
834
+ return connect(toTokens(await requestTokens({
835
+ client_id: options.clientId ?? "",
836
+ code,
837
+ grant_type: "authorization_code",
838
+ redirect_uri: redirectUri(url.toString()),
839
+ code_verifier: redirect.verifier
840
+ }), redirect.verifier));
841
+ },
842
+ async signInWithToken(token) {
843
+ const trimmed = token.trim();
844
+ if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
845
+ return connect({
846
+ type: "token",
847
+ token: trimmed
848
+ });
849
+ },
850
+ async startRedirect() {
851
+ if (!options.clientId) throw new Error("gitlabSetup({ clientId }) is needed to sign in with GitLab.");
852
+ const redirect = {
853
+ state: randomString(16),
854
+ verifier: randomString(48)
855
+ };
856
+ pending.save(JSON.stringify(redirect), false);
857
+ const query = new URLSearchParams({
858
+ client_id: options.clientId,
859
+ redirect_uri: redirectUri(location().href()),
860
+ response_type: "code",
861
+ state: redirect.state,
862
+ scope: "api",
863
+ code_challenge: await codeChallenge(redirect.verifier),
864
+ code_challenge_method: "S256"
865
+ });
866
+ location().assign(`${webUrl}/oauth/authorize?${query}`);
867
+ }
868
+ };
869
+ }
870
+ //#endregion
871
+ //#region src/index.ts
872
+ const MIN_ACCESS_LEVEL_TO_PUBLISH = 30;
873
+ function browserLocation() {
874
+ return {
875
+ href: () => window.location.href,
876
+ assign: (url) => window.location.assign(url),
877
+ replace: (url) => window.history.replaceState(window.history.state, "", url)
878
+ };
879
+ }
880
+ /** Sign-in and storage for a site whose repository is on GitLab. */
881
+ function gitlab(options) {
882
+ const webUrl = (options.url ?? "https://gitlab.com").replace(/\/$/, "");
883
+ const apiUrl = `${webUrl}/api/v4`;
884
+ const fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
885
+ const storage = credentialStorage(`goodfellow:gitlab:${webUrl}:${options.project}`, options.storage);
886
+ const pending = credentialStorage(`goodfellow:gitlab:pending:${options.project}`, options.storage);
887
+ const location = () => options.location ?? browserLocation();
888
+ async function requestTokens(params) {
889
+ const response = await fetcher(`${webUrl}/oauth/token`, {
890
+ method: "POST",
891
+ headers: { "content-type": "application/x-www-form-urlencoded" },
892
+ body: new URLSearchParams(params)
893
+ });
894
+ if (!response.ok) throw new SignInError("invalid", `GitLab didn't accept the sign-in (${response.status}).`);
895
+ return await response.json();
896
+ }
897
+ async function connect(initial, remember) {
898
+ let credentials = initial;
899
+ const token = async ({ refresh } = {}) => {
900
+ if (credentials.type === "token") return credentials.token;
901
+ if (refresh || credentials.expiresAt - 6e4 < Date.now()) {
902
+ const { verifier } = credentials;
903
+ credentials = toTokens(await requestTokens({
904
+ client_id: options.clientId ?? "",
905
+ refresh_token: credentials.refresh,
906
+ grant_type: "refresh_token",
907
+ redirect_uri: redirectUri(location().href()),
908
+ code_verifier: verifier
909
+ }), verifier);
910
+ storage.save(JSON.stringify(credentials), remember);
911
+ }
912
+ return credentials.access;
913
+ };
914
+ const api = {
915
+ apiUrl,
916
+ token,
917
+ canRefresh: credentials.type === "oauth",
918
+ fetch: fetcher
919
+ };
920
+ const user = await gitlabJson(api, "/user");
921
+ let project;
922
+ try {
923
+ project = await gitlabJson(api, `/projects/${encodeURIComponent(options.project)}`);
924
+ } catch (error) {
925
+ 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}.`);
926
+ throw error;
927
+ }
928
+ 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.`);
929
+ storage.save(JSON.stringify(credentials), remember);
930
+ return new GitLabBackend({
931
+ api,
932
+ project: options.project,
933
+ branch: options.branch ?? project.default_branch,
934
+ user: {
935
+ login: user.username,
936
+ name: user.name,
937
+ avatarUrl: user.avatar_url
938
+ },
939
+ onSignOut: () => storage.clear()
940
+ });
941
+ }
942
+ /** Finishes a redirect sign-in if the page has just come back from GitLab. */
943
+ async function completeRedirect() {
944
+ const url = new URL(location().href());
945
+ const code = url.searchParams.get("code");
946
+ const returnedState = url.searchParams.get("state");
947
+ const error = url.searchParams.get("error");
948
+ if (!code && !error) return null;
949
+ const saved = pending.load();
950
+ if (!saved) return null;
951
+ pending.clear();
952
+ const redirect = JSON.parse(saved);
953
+ for (const key of [
954
+ "code",
955
+ "state",
956
+ "error",
957
+ "error_description"
958
+ ]) url.searchParams.delete(key);
959
+ url.hash = redirect.hash;
960
+ location().replace(url.toString());
961
+ if (error || !code || returnedState !== redirect.state) throw new SignInError("redirect-failed", "Signing in with GitLab was cancelled or didn't finish.");
962
+ return connect(toTokens(await requestTokens({
963
+ client_id: options.clientId ?? "",
964
+ code,
965
+ grant_type: "authorization_code",
966
+ redirect_uri: redirectUri(url.toString()),
967
+ code_verifier: redirect.verifier
968
+ }), redirect.verifier), redirect.remember);
969
+ }
970
+ return {
971
+ name: "GitLab",
972
+ canRedirect: Boolean(options.clientId),
973
+ tokenLinks: [{
974
+ url: `${webUrl}/-/user_settings/personal_access_tokens?${new URLSearchParams({
975
+ name: "Goodfellow admin",
976
+ scopes: "api"
977
+ })}`,
978
+ label: "signIn.token.create"
979
+ }],
980
+ async restore() {
981
+ const redirected = await completeRedirect();
982
+ if (redirected) return redirected;
983
+ const saved = storage.load();
984
+ if (!saved) return null;
985
+ try {
986
+ return await connect(JSON.parse(saved), storage.remembered());
987
+ } catch (error) {
988
+ if (error instanceof SignInError) storage.clear();
989
+ throw error;
990
+ }
991
+ },
992
+ async signInWithToken(token, remember) {
993
+ const trimmed = token.trim();
994
+ if (!trimmed) throw new SignInError("invalid", "Paste a token to sign in.");
995
+ return connect({
996
+ type: "token",
997
+ token: trimmed
998
+ }, remember);
999
+ },
1000
+ async startRedirect(remember) {
1001
+ if (!options.clientId) throw new Error("gitlab({ clientId }) is needed to sign in with GitLab.");
1002
+ const verifier = randomString(48);
1003
+ const state = randomString(16);
1004
+ const here = new URL(location().href());
1005
+ const redirect = {
1006
+ state,
1007
+ verifier,
1008
+ remember,
1009
+ hash: here.hash
1010
+ };
1011
+ pending.save(JSON.stringify(redirect), false);
1012
+ const query = new URLSearchParams({
1013
+ client_id: options.clientId,
1014
+ redirect_uri: redirectUri(here.toString()),
1015
+ response_type: "code",
1016
+ state,
1017
+ scope: "api",
1018
+ code_challenge: await codeChallenge(verifier),
1019
+ code_challenge_method: "S256"
1020
+ });
1021
+ location().assign(`${webUrl}/oauth/authorize?${query}`);
1022
+ }
1023
+ };
1024
+ }
1025
+ //#endregion
1026
+ export { GitLabBackend, gitlab, gitlabPagesRecords, gitlabSetup, redirectUri };