@goodfellow-cms/gitlab 0.5.0 → 0.6.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/README.md CHANGED
@@ -8,7 +8,7 @@ import { gitlab } from "@goodfellow-cms/gitlab";
8
8
  export default defineConfig({ blocks, backend: gitlab({ project: "your-name/your-site" }) });
9
9
  ```
10
10
 
11
- [Goodfellow](https://github.com/goodfellow-cms/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://goodfellow-cms.github.io/goodfellow-cms/) for everything else.
11
+ [Goodfellow](https://github.com/goodfellow-cms/goodfellow-cms) is a website builder for people who aren't developers, with no server or database: pages are files in your own GitHub, GitLab or Bitbucket 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://goodfellow-cms.github.io/goodfellow-cms/) for everything else.
12
12
 
13
13
  More in the docs: [Hosts and business sites](https://goodfellow-cms.github.io/goodfellow-cms/docs/hosts).
14
14
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, PagesDomains, RepositoryVisibility, RevisionFiles, ScheduledBuilds, SetupHost, SiteDrafts, SiteEditors, SiteUpdates, StorageLike, WriteOptions } from "@goodfellow-cms/core";
1
+ import { ConnectDomainOptions, DeployStatus, DnsRecord, FileChange, FileVersion, GitBackend, GitHost, GitUser, HistoryOptions, PagesDomains, PluginTasks, RepositoryVisibility, RevisionFiles, ScheduledBuilds, SetupHost, SiteDrafts, SiteEditors, SiteUpdates, StorageLike, WriteOptions } from "@goodfellow-cms/core";
2
2
  //#region src/api.d.ts
3
3
  interface ApiOptions {
4
4
  /** The API root, such as `https://gitlab.com/api/v4`. */
@@ -12,6 +12,15 @@ interface ApiOptions {
12
12
  fetch: typeof fetch;
13
13
  }
14
14
  //#endregion
15
+ //#region src/tasks.d.ts
16
+ interface GitLabTasksOptions {
17
+ /** How often to look for the answer, and how long to wait for it. */
18
+ pollMs?: number;
19
+ timeoutMs?: number;
20
+ /** Waits between looks; tests pass one that doesn't. */
21
+ wait?: (ms: number) => Promise<void>;
22
+ }
23
+ //#endregion
15
24
  //#region src/backend.d.ts
16
25
  interface GitLabBackendOptions {
17
26
  api: ApiOptions;
@@ -26,6 +35,8 @@ interface GitLabBackendOptions {
26
35
  };
27
36
  /** The commit reads see until `revision()` is next called, as a full-stack site's server reads what a browser loaded. */
28
37
  revision?: string;
38
+ /** How plugins' tasks wait for their answers; tests pass short waits. */
39
+ tasks?: GitLabTasksOptions;
29
40
  /** The project holds only the site's content, with its code in a project of its own (`GitBackend.contentOnly`). */
30
41
  contentOnly?: boolean;
31
42
  /** The code project, for a project that holds only content, where the site's updates run. */
@@ -47,6 +58,7 @@ export declare class GitLabBackend implements GitBackend {
47
58
  readonly updates?: SiteUpdates;
48
59
  readonly drafts: SiteDrafts;
49
60
  readonly scheduledBuilds: ScheduledBuilds;
61
+ readonly tasks: PluginTasks;
50
62
  readonly contentOnly: boolean;
51
63
  private readonly options;
52
64
  private readonly api;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ConflictError, DomainError, DraftError, EDITABLE_FOLDERS, EditorsError, GitApiError, NIGHTLY_VARIABLE, REBUILD_JOB, ScheduledBuildsError, SetupError, SignInError, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, credentialStorage, encodeBase64Bytes, findBuildCause, findUpdateResult, hostSource } from "@goodfellow-cms/core";
1
+ import { ConflictError, DomainError, DraftError, EDITABLE_FOLDERS, EditorsError, GitApiError, LOCKFILE_JOB, NIGHTLY_VARIABLE, PluginTaskError, REBUILD_JOB, ScheduledBuildsError, SetupError, SignInError, TASK_JOB, TASK_POLL_MS, TASK_RESULT_FILE, TASK_TIMEOUT_MS, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, credentialStorage, encodeBase64Bytes, findBuildCause, findUpdateResult, hostSource, taskRequestProblem } from "@goodfellow-cms/core";
2
2
  //#region src/api.ts
3
3
  /** Calls the GitLab API. A 401 refreshes the token once; after that it means the sign-in has expired. */
4
4
  async function gitlabRequest(api, path, init = {}) {
@@ -411,6 +411,72 @@ function gitlabScheduledBuilds(api, project, branch, readCi, now = () => /* @__P
411
411
  };
412
412
  }
413
413
  //#endregion
414
+ //#region src/tasks.ts
415
+ /**
416
+ * Plugins' tasks on GitLab, through the starters' `plugin-task` job: a
417
+ * pipeline started with the request's variables (`GOODFELLOW_TASK_PLUGIN` and
418
+ * the rest), whose job leaves the sealed answer as its artifact, read here.
419
+ * Starting pipelines on the main branch needs Developer access, which
420
+ * editors have once `letEditorsPublish()` lets them publish.
421
+ */
422
+ function gitlabTasks(api, project, branch, readSetup, options = {}) {
423
+ const base = `/projects/${encodeURIComponent(project)}`;
424
+ const wait = options.wait ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
425
+ const supported = async () => (await readSetup().catch(() => void 0) ?? "").includes("$GOODFELLOW_TASK_PLUGIN");
426
+ return {
427
+ supported,
428
+ secretsPlace: async () => ({
429
+ kind: "gitlab",
430
+ url: `${api.apiUrl.replace(/\/api\/v4\/?$/, "")}/${project}/-/settings/ci_cd#js-cicd-variables-settings`
431
+ }),
432
+ async run(request) {
433
+ const problem = taskRequestProblem(request);
434
+ if (problem) throw new PluginTaskError("failed", problem);
435
+ if (!await supported()) throw new PluginTaskError("missing", "The site's setup files don't run plugins' tasks: .gitlab-ci.yml needs the plugin-task job.");
436
+ const variable = (key, value) => ({
437
+ key,
438
+ variable_type: "env_var",
439
+ value
440
+ });
441
+ let pipeline;
442
+ try {
443
+ pipeline = await gitlabJson(api, `${base}/pipeline`, {
444
+ method: "POST",
445
+ body: {
446
+ ref: branch,
447
+ variables: [
448
+ variable("GOODFELLOW_TASK_REQUEST", request.id),
449
+ variable("GOODFELLOW_TASK_PLUGIN", request.plugin),
450
+ variable("GOODFELLOW_TASK_NAME", request.task),
451
+ variable("GOODFELLOW_TASK_INPUT", request.input),
452
+ variable("GOODFELLOW_TASK_KEY", request.key)
453
+ ]
454
+ }
455
+ });
456
+ } catch (error) {
457
+ if (error instanceof GitApiError && (error.status === 403 || error.status === 401)) throw new PluginTaskError("not-allowed", "Running plugins' tasks needs a sign-in that can publish to the site.");
458
+ throw error;
459
+ }
460
+ const deadline = Date.now() + (options.timeoutMs ?? TASK_TIMEOUT_MS);
461
+ for (;;) {
462
+ const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === TASK_JOB);
463
+ if (job?.status === "success") {
464
+ const answer = await gitlabRequest(api, `${base}/jobs/${job.id}/artifacts/${TASK_RESULT_FILE}`, { allow: [404] });
465
+ if (answer.ok) return await answer.text();
466
+ throw new PluginTaskError("failed", "The site's build service ran the task but left no answer.");
467
+ }
468
+ if (job && [
469
+ "failed",
470
+ "canceled",
471
+ "skipped"
472
+ ].includes(job.status)) throw new PluginTaskError("failed", "The site's build service couldn't run the task.");
473
+ if (Date.now() >= deadline) throw new PluginTaskError("timeout", "The site's build service didn't answer in time.");
474
+ await wait(options.pollMs ?? TASK_POLL_MS);
475
+ }
476
+ }
477
+ };
478
+ }
479
+ //#endregion
414
480
  //#region src/updates.ts
415
481
  const CI_FILE = ".gitlab-ci.yml";
416
482
  /** When the nightly schedule the Updates screen adds runs, in UTC, as the GitHub Pages workflow's does. */
@@ -635,11 +701,12 @@ const GRAPHQL_BATCH = 50;
635
701
  const ANSI_CODES = new RegExp(`${String.fromCharCode(27)}\\[[\\d;]*[A-Za-z]`, "g");
636
702
  /**
637
703
  * Whether a status says when the site is live. A job that only asks another
638
- * host to rebuild the site doesn't, and nor does the nightly update where
639
- * Cloudflare builds the site, which runs on the newest commit too.
704
+ * host to rebuild the site doesn't, and nor do the nightly update and the
705
+ * job that writes the lockfile where Cloudflare builds the site, which run
706
+ * on the newest commit too.
640
707
  */
641
708
  function builds(status) {
642
- return status.name !== REBUILD_JOB && status.name !== "update";
709
+ return status.name !== REBUILD_JOB && status.name !== "update" && status.name !== LOCKFILE_JOB;
643
710
  }
644
711
  function listFrom(tree, dir) {
645
712
  const prefix = `${dir}/`;
@@ -658,6 +725,7 @@ var GitLabBackend = class GitLabBackend {
658
725
  updates;
659
726
  drafts;
660
727
  scheduledBuilds;
728
+ tasks;
661
729
  contentOnly;
662
730
  options;
663
731
  api;
@@ -696,6 +764,7 @@ var GitLabBackend = class GitLabBackend {
696
764
  }
697
765
  this.drafts = branchDrafts(this, this.draftHost());
698
766
  this.scheduledBuilds = gitlabScheduledBuilds(this.api, options.project, this.branch, async () => this.readAt(".gitlab-ci.yml", this.pinned ?? await this.revision()));
767
+ this.tasks = gitlabTasks(this.api, options.project, this.branch, async () => this.readAt(".gitlab-ci.yml", this.pinned ?? await this.revision()), options.tasks);
699
768
  }
700
769
  async repositoryVisibility() {
701
770
  const { visibility } = await gitlabJson(this.api, `/projects/${this.project}`);
package/dist/testing.d.ts CHANGED
@@ -42,6 +42,12 @@ export interface FakeGitLabOptions {
42
42
  accounts?: string[];
43
43
  /** Who can see the project. Private by default. */
44
44
  visibility?: "public" | "internal" | "private";
45
+ /**
46
+ * Answers plugins' tasks, as the site's `plugin-task` job would: given each
47
+ * pipeline's task variables, it gives the text the job leaves as its
48
+ * artifact, or nothing for a job that fails. Without it, task pipelines fail.
49
+ */
50
+ tasks?: (variables: Record<string, string>) => string | undefined | Promise<string | undefined>;
45
51
  }
46
52
  /** A pipeline schedule, such as the nightly one or one the admin panel adds for a scheduled change. */
47
53
  export interface FakeSchedule {
package/dist/testing.js CHANGED
@@ -173,6 +173,8 @@ function fakeGitLab(options) {
173
173
  updateRuns.push(added);
174
174
  return added;
175
175
  }
176
+ /** Pipelines started for plugins' tasks, with their `plugin-task` job's artifact. */
177
+ const taskRuns = [];
176
178
  /** Failed jobs, by commit, which tests add with `failBuild()` and `cancelBuild()`. */
177
179
  const failures = /* @__PURE__ */ new Map();
178
180
  /**
@@ -691,10 +693,25 @@ function fakeGitLab(options) {
691
693
  })));
692
694
  if (rest === "/pipeline" && request.method === "POST") {
693
695
  if (accessLevel < 30) return json({ message: "403 Forbidden" }, 403);
696
+ const body = await request.json();
697
+ const variables = Object.fromEntries((body.variables ?? []).map(({ key, value }) => [key, value]));
698
+ if (variables.GOODFELLOW_TASK_PLUGIN) {
699
+ const id = 9e4 + taskRuns.length;
700
+ taskRuns.push({
701
+ id,
702
+ job: id + 5e4,
703
+ variables,
704
+ answer: await options.tasks?.(variables)
705
+ });
706
+ return json({
707
+ id,
708
+ status: "created"
709
+ }, 201);
710
+ }
694
711
  const run = addUpdateRun({
695
712
  source: "api",
696
713
  status: "created",
697
- variables: (await request.json()).variables
714
+ variables: body.variables
698
715
  });
699
716
  return json({
700
717
  id: run.id,
@@ -787,6 +804,18 @@ function fakeGitLab(options) {
787
804
  if (body.ci_push_repository_for_job_token_allowed !== void 0) updateSettings.jobTokenPush = body.ci_push_repository_for_job_token_allowed;
788
805
  return json({ ci_push_repository_for_job_token_allowed: updateSettings.jobTokenPush });
789
806
  }
807
+ const taskJobs = rest.match(/^\/pipelines\/(\d+)\/jobs$/);
808
+ const taskRun = taskJobs && taskRuns.find((candidate) => candidate.id === Number(taskJobs[1]));
809
+ if (taskRun) return json([{
810
+ id: taskRun.job,
811
+ name: "plugin-task",
812
+ status: taskRun.answer === void 0 ? "failed" : "success"
813
+ }]);
814
+ const artifact = rest.match(/^\/jobs\/(\d+)\/artifacts\/(.+)$/);
815
+ if (artifact) {
816
+ const run = taskRuns.find((candidate) => candidate.job === Number(artifact[1]));
817
+ return run?.answer !== void 0 && artifact[2] === "goodfellow-task-result.json" ? new Response(`${run.answer}\n`) : json({ message: "404 Not Found" }, 404);
818
+ }
790
819
  const updateJobs = rest.match(/^\/pipelines\/(\d{4,})\/jobs$/);
791
820
  if (updateJobs) {
792
821
  const run = updateRuns.find((candidate) => candidate.id === Number(updateJobs[1]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/gitlab",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Edit a Goodfellow site stored on GitLab, straight from the browser.",
5
5
  "keywords": [
6
6
  "goodfellow",
@@ -35,7 +35,7 @@
35
35
  "dist"
36
36
  ],
37
37
  "dependencies": {
38
- "@goodfellow-cms/core": "0.5.0"
38
+ "@goodfellow-cms/core": "0.6.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@puckeditor/core": "0.23.0"