storyshelf 0.6.2 → 0.7.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.
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ import "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ import { HostError, hostJson } from "./protect-types.js";
6
+ //#region src/commands/protect-gitlab.ts
7
+ /**
8
+ * GitLab: statuses a build posts join the commit's pipeline, so the merge gate is the project's
9
+ * "pipelines must succeed" setting (`only_allow_merge_if_pipeline_succeeds`). GitLab has no
10
+ * per-status required-check list outside its paid external status checks.
11
+ */
12
+ /** The GitLab adapter for the project `group/project` on `host`. */
13
+ function gitlabProtection(repo, host, fetchImpl) {
14
+ const api = `${host.replace(/\/+$/u, "")}/api/v4/projects/${encodeURIComponent(repo)}`;
15
+ return {
16
+ tokenEnv: "GITLAB_TOKEN",
17
+ manualSteps: (target) => manualSteps(repo, host, target),
18
+ inspect: async (target, token) => await inspect({
19
+ api,
20
+ fetchImpl,
21
+ token
22
+ }, target)
23
+ };
24
+ }
25
+ function manualSteps(repo, host, target) {
26
+ return [
27
+ `GitLab: make ${target.checks.join(", ")} gate merges into ${target.branch} on ${repo}:`,
28
+ "",
29
+ `1. Open ${host.replace(/\/+$/u, "")}/${repo}/-/settings/merge_requests.`,
30
+ "2. Under \"Merge checks\" tick \"Pipelines must succeed\". The commit statuses StoryShelf posts",
31
+ " are part of the commit's pipeline, so a failed or pending one blocks the merge.",
32
+ `3. Open Settings > Repository > Protected branches and protect ${target.branch} (merge via merge request).`,
33
+ "4. Token scope: api, with the Maintainer role."
34
+ ];
35
+ }
36
+ async function inspect(ctx, target) {
37
+ const headers = { "private-token": ctx.token };
38
+ const project = await hostJson(ctx.fetchImpl, ctx.api, { headers });
39
+ const protectedBranch = await isProtected(ctx, target.branch);
40
+ const setting = project.only_allow_merge_if_pipeline_succeeds === true;
41
+ const branchNote = protectedBranch ? "" : ` Branch ${target.branch} is not protected: protect it by hand.`;
42
+ if (setting) return {
43
+ satisfied: protectedBranch,
44
+ summary: `Pipelines must succeed is on.${branchNote}`
45
+ };
46
+ return {
47
+ satisfied: false,
48
+ summary: `"Pipelines must succeed" is off, so ${target.checks.join(", ")} does not gate merges.${branchNote}`,
49
+ apply: async () => {
50
+ await hostJson(ctx.fetchImpl, ctx.api, {
51
+ method: "PUT",
52
+ headers,
53
+ json: { only_allow_merge_if_pipeline_succeeds: true }
54
+ });
55
+ return `Turned on "Pipelines must succeed".${branchNote}`;
56
+ }
57
+ };
58
+ }
59
+ async function isProtected(ctx, branch) {
60
+ try {
61
+ await hostJson(ctx.fetchImpl, `${ctx.api}/protected_branches/${encodeURIComponent(branch)}`, { headers: { "private-token": ctx.token } });
62
+ return true;
63
+ } catch (error) {
64
+ if (error instanceof HostError && error.status === 404) return false;
65
+ throw error;
66
+ }
67
+ }
68
+ //#endregion
69
+ export { gitlabProtection };
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ import "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ //#region src/commands/protect-types.ts
6
+ /** An HTTP failure of a host API call, with the status for callers that branch on 404. */
7
+ var HostError = class extends Error {
8
+ name = "HostError";
9
+ status;
10
+ constructor(status, message) {
11
+ super(message);
12
+ this.status = status;
13
+ }
14
+ };
15
+ /** Call a host API and parse its JSON; a non-2xx answer throws {@link HostError} (the token is never in the text). */
16
+ async function hostJson(fetchImpl, url, init) {
17
+ const response = await fetchImpl(url, {
18
+ method: init.method ?? "GET",
19
+ headers: init.json === void 0 ? init.headers : {
20
+ ...init.headers,
21
+ "content-type": "application/json"
22
+ },
23
+ ...init.json === void 0 ? {} : { body: JSON.stringify(init.json) }
24
+ });
25
+ const text = await response.text();
26
+ if (!response.ok) throw new HostError(response.status, `${String(response.status)} from ${new URL(url).pathname}: ${text.slice(0, 200)}`);
27
+ return text === "" ? {} : JSON.parse(text);
28
+ }
29
+ //#endregion
30
+ export { HostError, hostJson };
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ import "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ import { printError, printLine } from "../output.js";
6
+ import { githubProtection } from "./protect-github.js";
7
+ import { gitlabProtection } from "./protect-gitlab.js";
8
+ //#region src/commands/protect.ts
9
+ /** The check names a merge must wait for: the channel's gate status. */
10
+ function requiredChecks(prefix, gate) {
11
+ return [`${prefix}/${gate}`];
12
+ }
13
+ /**
14
+ * Print the exact required-check rule for the host, validate the branch's existing rule against
15
+ * it, and write the missing part only with `--apply` (dry run by default). Never reads or writes
16
+ * anything but the branch protection of one repository.
17
+ */
18
+ async function runProtect(options) {
19
+ const adapter = adapterFor(options);
20
+ const checks = requiredChecks(options.prefix ?? "storyshelf", options.gate ?? "testing");
21
+ const target = {
22
+ branch: options.branch ?? "main",
23
+ checks
24
+ };
25
+ for (const line of adapter.manualSteps(target)) printLine(line);
26
+ const token = options.token ?? process.env[adapter.tokenEnv];
27
+ if (token) await validateAndApply(adapter.inspect(target, token), options.apply === true);
28
+ else if (options.apply) throw new Error(`--apply needs an admin token (--token or ${adapter.tokenEnv})`);
29
+ else printLine(`\nNo token: rule not checked. Pass --token (or ${adapter.tokenEnv}) to validate it.`);
30
+ }
31
+ /** Print what the rule lacks; write it when `apply`, else say it was a dry run. */
32
+ async function validateAndApply(inspected, apply) {
33
+ const result = await inspected;
34
+ printLine(`\n${result.summary}`);
35
+ if (result.satisfied) return;
36
+ process.exitCode = 1;
37
+ if (!apply) printLine("Dry run: nothing was changed. Run again with --apply to write the rule.");
38
+ else if (result.apply === void 0) printError("This rule cannot be written automatically; follow the steps above.");
39
+ else {
40
+ printLine(await result.apply());
41
+ process.exitCode = void 0;
42
+ }
43
+ }
44
+ function adapterFor(options) {
45
+ const fetchImpl = options.fetch ?? fetch;
46
+ if (options.provider === "github") return githubProtection(options.repo, fetchImpl);
47
+ if (options.provider === "gitlab") return gitlabProtection(options.repo, options.host ?? "https://gitlab.com", fetchImpl);
48
+ throw new Error(`Unknown provider "${options.provider}": use github or gitlab`);
49
+ }
50
+ //#endregion
51
+ export { requiredChecks, runProtect };
@@ -6,7 +6,7 @@ import { printLine } from "../output.js";
6
6
  import { createClient } from "../client.js";
7
7
  //#region src/commands/retry.ts
8
8
  /**
9
- * Retry a failed StoryShelf build.
9
+ * Retry a failed or cancelled StoryShelf build.
10
10
  *
11
11
  * @param options - Retry command options.
12
12
  */
@@ -11,21 +11,20 @@ const PLAYWRIGHT_IMAGE = "mcr.microsoft.com/playwright:v1.63.0-noble";
11
11
  * CommonJS dependencies ("Dynamic require ... is not supported") and the vendored
12
12
  * assets `@storyshelf/app` reads from disk next to its own modules.
13
13
  */
14
- function imageLines(baseImage, entry, exposePort) {
14
+ function imageLines(baseImage, entry) {
15
15
  return [
16
16
  `FROM ${baseImage}`,
17
17
  "WORKDIR /app",
18
18
  "COPY package*.json ./",
19
19
  "RUN npm install --omit=dev",
20
20
  "COPY src/ ./src/",
21
- ...exposePort ? ["EXPOSE 3000", "ENV PORT=3000"] : [],
21
+ "EXPOSE 3000",
22
+ "ENV PORT=3000",
22
23
  "ENV DATA_DIR=/data",
23
24
  `CMD ["node", "${entry}"]`
24
25
  ];
25
26
  }
26
- const DOCKERFILE_LINES = imageLines(PLAYWRIGHT_IMAGE, "src/index.ts", true);
27
- /** Server image without a browser: the capture worker runs in its own image. */
28
- const SLIM_DOCKERFILE_LINES = imageLines("node:24-alpine", "src/index.ts", true);
27
+ const DOCKERFILE_LINES = imageLines(PLAYWRIGHT_IMAGE, "src/index.ts");
29
28
  const DOCKERIGNORE_LINES = [
30
29
  "node_modules/",
31
30
  ".git/",
@@ -71,53 +70,22 @@ const COMPOSE_POSTGRES_VOLUMES_LINES = [" postgres-data:"];
71
70
  function generateDockerfile() {
72
71
  return DOCKERFILE_LINES.join("\n");
73
72
  }
74
- function generateSlimServerDockerfile() {
75
- return SLIM_DOCKERFILE_LINES.join("\n");
76
- }
77
73
  function generateDockerignore() {
78
74
  return DOCKERIGNORE_LINES.join("\n");
79
75
  }
80
- function composeLines(database, withWorker) {
76
+ function composeLines(database) {
81
77
  return [
82
78
  ...COMPOSE_BASE_LINES,
83
79
  " # Add your env vars here:",
84
80
  " # - AUTH_PASSWORD=your-password",
85
81
  ...database === "turso" ? COMPOSE_TURSO_LINES : [],
86
82
  ...database === "postgres" ? COMPOSE_POSTGRES_LINES : [],
87
- ...withWorker ? workerComposeLines(database) : [],
88
83
  ...COMPOSE_VOLUMES_LINES,
89
84
  ...database === "postgres" ? COMPOSE_POSTGRES_VOLUMES_LINES : []
90
85
  ];
91
86
  }
92
87
  function generateComposeYaml(database = "sqlite") {
93
- return composeLines(database, false).join("\n");
94
- }
95
- const WORKER_DOCKERFILE_LINES = imageLines(PLAYWRIGHT_IMAGE, "src/worker.ts", false);
96
- function workerComposeLines(database) {
97
- const postgres = database === "postgres";
98
- return [
99
- " worker:",
100
- " build:",
101
- " dockerfile: Dockerfile.worker",
102
- " volumes:",
103
- " - storyshelf-data:/data",
104
- " environment:",
105
- " - SECRET=change-me",
106
- " - DATA_DIR=/data",
107
- ...postgres ? [" - DATABASE_URL=postgres://shelf:shelf@postgres:5432/shelf"] : [],
108
- " - QUEUE_URL=${QUEUE_URL}",
109
- " - WORKER_CONCURRENCY=2",
110
- " depends_on:",
111
- " storyshelf:",
112
- " condition: service_started",
113
- ...postgres ? [" postgres:", " condition: service_healthy"] : []
114
- ];
115
- }
116
- function generateWorkerDockerfile() {
117
- return WORKER_DOCKERFILE_LINES.join("\n");
118
- }
119
- function generateComposeYamlWithWorker(database = "sqlite") {
120
- return composeLines(database, true).join("\n");
88
+ return composeLines(database).join("\n");
121
89
  }
122
90
  //#endregion
123
- export { generateComposeYaml, generateComposeYamlWithWorker, generateDockerfile, generateDockerignore, generateSlimServerDockerfile, generateWorkerDockerfile };
91
+ export { generateComposeYaml, generateDockerfile, generateDockerignore };