storyshelf 0.6.1 → 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.
package/README.md CHANGED
@@ -82,6 +82,13 @@ storyshelf purge --url <url> [--token <admin-token>]
82
82
  storyshelf retry --url <url> --slug <slug> --build-id <id> [--token <token>]
83
83
  ```
84
84
 
85
+ `storyshelf protect` — print, validate and (only with `--apply`) write the branch rule that requires StoryShelf's gate status. Dry run by default; needs an admin token for the git host (`--token`, `GITHUB_TOKEN` or `GITLAB_TOKEN`), and talks to the host directly, not to the StoryShelf server. Exits `1` when the rule is missing the check and nothing was written.
86
+
87
+ ```sh
88
+ storyshelf protect --provider github --repo <owner/repo> [--branch main] [--prefix storyshelf] [--gate testing|review] [--apply]
89
+ storyshelf protect --provider gitlab --repo <group/project> [--host <url>] [--apply]
90
+ ```
91
+
85
92
  ### Example CI snippet
86
93
 
87
94
  ```yaml
@@ -96,7 +103,7 @@ jobs:
96
103
  steps:
97
104
  - uses: actions/checkout@v4
98
105
  - uses: actions/setup-node@v4
99
- with: { node-version: 20 }
106
+ with: { node-version: 24 }
100
107
  - run: nub install && nub run build
101
108
  - run: nubx storybook build -o storybook-static
102
109
  - run:
package/dist/client.js CHANGED
@@ -18,6 +18,15 @@ function createClient(baseUrl, token) {
18
18
  const requestHeaders = (contentType) => buildRequestHeaders(authHeaders, contentType);
19
19
  return { projects: createProjectsApi(baseUrl, authHeaders, requestHeaders) };
20
20
  }
21
+ /** A non-2xx answer from the server; `status` is the HTTP status. */
22
+ var RequestError = class extends Error {
23
+ name = "RequestError";
24
+ status;
25
+ constructor(status, body) {
26
+ super(`Request failed (${status}): ${body}`);
27
+ this.status = status;
28
+ }
29
+ };
21
30
  function buildAuthHeaders(token) {
22
31
  return token ? { Authorization: `Bearer ${token}` } : {};
23
32
  }
@@ -28,7 +37,7 @@ function buildRequestHeaders(authHeaders, contentType) {
28
37
  }
29
38
  async function fetchJson(url, options) {
30
39
  const res = await fetch(url, options);
31
- if (!res.ok) throw new Error(`Request failed (${res.status}): ${await res.text()}`);
40
+ if (!res.ok) throw new RequestError(res.status, await res.text());
32
41
  return res.json();
33
42
  }
34
43
  function createTokensApi(baseUrl, requestHeaders) {
@@ -38,8 +47,35 @@ function createTokensApi(baseUrl, requestHeaders) {
38
47
  body: JSON.stringify(json)
39
48
  }) };
40
49
  }
50
+ /** The three calls of a file-by-file upload. */
51
+ function createFileUploadApi(baseUrl, requestHeaders) {
52
+ return {
53
+ dedup: async (slug, buildId, hashes) => {
54
+ return (await fetchJson(`${baseUrl}/api/v1/projects/${slug}/builds/${buildId}/dedup`, {
55
+ method: "POST",
56
+ headers: requestHeaders("application/json"),
57
+ body: JSON.stringify({ hashes })
58
+ })).needed;
59
+ },
60
+ uploadContent: async (slug, buildId, files) => {
61
+ const form = new FormData();
62
+ for (const file of files) form.set(file.hash, new File([new Uint8Array(file.data)], file.hash));
63
+ return await fetchJson(`${baseUrl}/api/v1/projects/${slug}/builds/${buildId}/content`, {
64
+ method: "POST",
65
+ headers: requestHeaders(),
66
+ body: form
67
+ });
68
+ },
69
+ postManifest: async (slug, buildId, files) => await fetchJson(`${baseUrl}/api/v1/projects/${slug}/builds/${buildId}/manifest`, {
70
+ method: "POST",
71
+ headers: requestHeaders("application/json"),
72
+ body: JSON.stringify({ files })
73
+ })
74
+ };
75
+ }
41
76
  function createBuildsApi(baseUrl, authHeaders, requestHeaders) {
42
77
  return {
78
+ ...createFileUploadApi(baseUrl, requestHeaders),
43
79
  createJson: async (slug, json) => await fetchJson(`${baseUrl}/api/v1/projects/${slug}/builds`, {
44
80
  method: "POST",
45
81
  headers: requestHeaders("application/json"),
@@ -56,7 +92,7 @@ function createBuildsApi(baseUrl, authHeaders, requestHeaders) {
56
92
  duplex: "half"
57
93
  };
58
94
  const res = await fetch(`${baseUrl}${uploadUrl}`, init);
59
- if (!res.ok) throw new Error(`Request failed (${res.status}): ${await res.text()}`);
95
+ if (!res.ok) throw new RequestError(res.status, await res.text());
60
96
  return res.json();
61
97
  },
62
98
  retry: async (slug, buildId) => await fetchJson(`${baseUrl}/api/v1/projects/${slug}/builds/${buildId}/retry`, {
@@ -64,6 +100,11 @@ function createBuildsApi(baseUrl, authHeaders, requestHeaders) {
64
100
  headers: requestHeaders("application/json"),
65
101
  body: JSON.stringify({})
66
102
  }),
103
+ postUsage: async (slug, buildId, usage) => await fetchJson(`${baseUrl}/api/v1/projects/${slug}/builds/${buildId}/usage`, {
104
+ method: "POST",
105
+ headers: requestHeaders("application/json"),
106
+ body: JSON.stringify({ usage })
107
+ }),
67
108
  postAffected: async (slug, buildId, json) => await fetchJson(`${baseUrl}/api/v1/projects/${slug}/builds/${buildId}/affected`, {
68
109
  method: "POST",
69
110
  headers: requestHeaders("application/json"),
@@ -98,4 +139,4 @@ function createProjectsApi(baseUrl, authHeaders, requestHeaders) {
98
139
  };
99
140
  }
100
141
  //#endregion
101
- export { createClient };
142
+ export { RequestError, createClient };
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ import "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ import { readFile, readdir, stat } from "node:fs/promises";
6
+ import { join, relative, resolve } from "node:path";
7
+ import { createHash } from "node:crypto";
8
+ //#region src/commands/dedup.ts
9
+ async function walkFiles(cwd, buildDir) {
10
+ const root = resolve(cwd, buildDir);
11
+ const files = [];
12
+ async function walk(dir) {
13
+ const entries = await readdir(dir, { withFileTypes: true });
14
+ for (const entry of entries) {
15
+ const full = join(dir, entry.name);
16
+ if (entry.isDirectory()) await walk(full);
17
+ else files.push(relative(root, full));
18
+ }
19
+ }
20
+ await walk(root);
21
+ return files;
22
+ }
23
+ async function hashFiles(cwd, buildDir, files) {
24
+ const root = resolve(cwd, buildDir);
25
+ const results = [];
26
+ for (const rel of files) {
27
+ const full = join(root, rel);
28
+ const buffer = await readFile(full);
29
+ const hash = createHash("sha256").update(buffer).digest("hex");
30
+ const { size } = await stat(full);
31
+ results.push({
32
+ rel,
33
+ hash,
34
+ size
35
+ });
36
+ }
37
+ return results;
38
+ }
39
+ //#endregion
40
+ export { hashFiles, walkFiles };
@@ -7,8 +7,8 @@ import { printLine } from "../output.js";
7
7
  import { needsBuild, resolveBuildCommand } from "./storybook-build.js";
8
8
  import { createClient } from "../client.js";
9
9
  import { resolveConnection } from "./connection.js";
10
- import { resolve } from "node:path";
11
10
  import { access, readdir } from "node:fs/promises";
11
+ import { resolve } from "node:path";
12
12
  import { gitRepoStatus } from "@storyshelf/affected";
13
13
  //#region src/commands/doctor.ts
14
14
  function mark(status) {
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ import "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ import { RequestError } from "../client.js";
6
+ import { hashFiles, walkFiles } from "./dedup.js";
7
+ import { readFile } from "node:fs/promises";
8
+ import { join, resolve } from "node:path";
9
+ //#region src/commands/file-by-file.ts
10
+ /**
11
+ * File-by-file upload (`/dedup`, `/content`, `/manifest`): stores a build's files content-addressed
12
+ * and needs no zip extraction on the server, so it publishes on any host, serverless included.
13
+ * `upload` falls back to it when the server refuses a zip over its inline limit.
14
+ */
15
+ /** Files sent in one `/content` request. */
16
+ const BATCH_FILES = 50;
17
+ /** Bytes sent in one `/content` request (a single larger file goes alone). */
18
+ const BATCH_BYTES = 16777216;
19
+ /** Hashes asked about in one `/dedup` request. */
20
+ const DEDUP_CHUNK = 5e3;
21
+ /** What the server answers a zip it cannot extract inline and cannot hand to a job. */
22
+ function isOverInlineLimit(error) {
23
+ return error instanceof RequestError && error.status === 413 && error.message.includes("/dedup");
24
+ }
25
+ /** Upload every file of the build directory the server lacks, then post the manifest. */
26
+ async function uploadFileByFile(client, target, log) {
27
+ const files = await hashFiles(target.cwd, target.buildDir, await walkFiles(target.cwd, target.buildDir));
28
+ const toSend = firstFileOfEach(files, await neededHashes(client, target, files));
29
+ log(`Uploading ${toSend.length} of ${files.length} files (the server has the rest)`);
30
+ for (const batch of batches(toSend)) await sendBatch(client, target, batch);
31
+ await client.projects.builds.postManifest(target.slug, target.buildId, files);
32
+ }
33
+ async function neededHashes(client, target, files) {
34
+ const unique = [...new Set(files.map((file) => file.hash))];
35
+ const needed = /* @__PURE__ */ new Set();
36
+ for (let at = 0; at < unique.length; at += DEDUP_CHUNK) {
37
+ const answer = await client.projects.builds.dedup(target.slug, target.buildId, unique.slice(at, at + DEDUP_CHUNK));
38
+ for (const hash of answer) needed.add(hash);
39
+ }
40
+ return needed;
41
+ }
42
+ /** One file per needed hash (identical files are sent once). */
43
+ function firstFileOfEach(files, needed) {
44
+ const seen = /* @__PURE__ */ new Set();
45
+ return files.filter((file) => {
46
+ if (!needed.has(file.hash) || seen.has(file.hash)) return false;
47
+ seen.add(file.hash);
48
+ return true;
49
+ });
50
+ }
51
+ /** Split into requests of at most {@link BATCH_FILES} files and {@link BATCH_BYTES} bytes (one big file goes alone). */
52
+ function batches(files) {
53
+ const out = [];
54
+ for (const file of files) {
55
+ const last = out.at(-1);
56
+ if (last && fitsBatch(last, file)) last.push(file);
57
+ else out.push([file]);
58
+ }
59
+ return out;
60
+ }
61
+ function fitsBatch(batch, file) {
62
+ const bytes = batch.reduce((sum, member) => sum + member.size, 0);
63
+ return batch.length < BATCH_FILES && bytes + file.size <= BATCH_BYTES;
64
+ }
65
+ async function sendBatch(client, target, batch) {
66
+ const root = resolve(target.cwd, target.buildDir);
67
+ const files = await Promise.all(batch.map(async (file) => ({
68
+ hash: file.hash,
69
+ data: await readFile(join(root, file.rel))
70
+ })));
71
+ await client.projects.builds.uploadContent(target.slug, target.buildId, files);
72
+ }
73
+ //#endregion
74
+ export { isOverInlineLimit, uploadFileByFile };
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ import "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ import { detectPackageRunner, installCommand } from "../../config.js";
6
+ import { printError, printLine } from "../../output.js";
7
+ import { PROJECT_PROMPTS } from "../server/prompts.js";
8
+ import { SCAFFOLD_DEV_DEPENDENCIES, generateTsconfig, writeScaffoldFile } from "../shared/scaffold.js";
9
+ import { MCP_ENTRY, generateMcpClientConfig, generateMcpDockerfile, generateMcpEntry, generateMcpEnvExample, generateMcpPackageJson } from "./scaffold.js";
10
+ import { mkdir, writeFile } from "node:fs/promises";
11
+ import { join, resolve } from "node:path";
12
+ import prompts from "prompts";
13
+ //#region src/commands/mcp/init.ts
14
+ const MCP_PROMPTS = [
15
+ {
16
+ type: "select",
17
+ name: "transport",
18
+ message: "How will agents connect?",
19
+ choices: [{
20
+ title: "stdio (local agents: Claude Code, Cursor)",
21
+ value: "stdio"
22
+ }, {
23
+ title: "HTTP (remote or shared agents)",
24
+ value: "http"
25
+ }]
26
+ },
27
+ {
28
+ type: "text",
29
+ name: "url",
30
+ message: "StoryShelf server URL?",
31
+ initial: process.env["STORYSHELF_URL"] ?? "https://shelf.example.com"
32
+ },
33
+ {
34
+ type: "text",
35
+ name: "slug",
36
+ message: "Default project slug?",
37
+ initial: process.env["STORYSHELF_SLUG"] ?? ""
38
+ },
39
+ {
40
+ type: "confirm",
41
+ name: "docker",
42
+ message: "Add a Dockerfile (HTTP transport)?",
43
+ initial: false
44
+ }
45
+ ];
46
+ /** Scaffold a StoryShelf MCP server project (`storyshelf mcp init`). */
47
+ async function runMcpInit(options) {
48
+ const responses = await prompts([...PROJECT_PROMPTS, ...MCP_PROMPTS]);
49
+ if (!responses.name || !responses.dir) {
50
+ printError("Cancelled.");
51
+ return;
52
+ }
53
+ const answers = {
54
+ ...responses,
55
+ dir: options.dir ?? responses.dir
56
+ };
57
+ const outDir = resolve(answers.dir);
58
+ await mkdir(outDir, { recursive: true });
59
+ await writeFiles(outDir, answers);
60
+ const runner = await detectPackageRunner(process.cwd());
61
+ printNextSteps(answers, outDir, installCommand(runner));
62
+ }
63
+ async function writeFiles(outDir, answers) {
64
+ await writeScaffoldFile(outDir, MCP_ENTRY, generateMcpEntry(answers.transport));
65
+ await writeFile(join(outDir, "package.json"), generateMcpPackageJson(answers, SCAFFOLD_DEV_DEPENDENCIES));
66
+ await writeFile(join(outDir, "tsconfig.json"), generateTsconfig());
67
+ await writeFile(join(outDir, ".env.example"), generateMcpEnvExample(answers));
68
+ await writeFile(join(outDir, ".mcp.json"), `${generateMcpClientConfig(answers)}\n`);
69
+ printLine(`Created ${MCP_ENTRY}, package.json, tsconfig.json, .env.example, .mcp.json`);
70
+ if (answers.docker) {
71
+ await writeFile(join(outDir, "Dockerfile"), generateMcpDockerfile());
72
+ printLine(`Created Dockerfile`);
73
+ }
74
+ }
75
+ function printNextSteps(answers, outDir, install) {
76
+ printLine(`\nScaffolded ${answers.name} in ${outDir}`);
77
+ printLine(`\nNext steps:`);
78
+ printLine(` cd ${answers.dir}`);
79
+ printLine(` ${install}`);
80
+ printLine(` cp .env.example .env # add STORYSHELF_TOKEN (never commit it)`);
81
+ printLine(` npx storyshelf mcp serve --dir .`);
82
+ printLine(`\n.mcp.json is a ready client config for Claude Code (copy to your repo root).`);
83
+ }
84
+ //#endregion
85
+ export { runMcpInit };
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ import "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ //#region src/commands/mcp/scaffold.ts
6
+ /** Entry file of a scaffolded MCP project. */
7
+ const MCP_ENTRY = "src/index.ts";
8
+ /** `src/index.ts`: stdio runs the package bin on import; http mounts the handler on `/mcp`. */
9
+ function generateMcpEntry(transport) {
10
+ if (transport === "stdio") return [
11
+ `// Serves MCP over stdio. Configuration comes from STORYSHELF_URL / STORYSHELF_TOKEN / STORYSHELF_SLUG.`,
12
+ `import "@storyshelf/mcp";`,
13
+ ``
14
+ ].join("\n");
15
+ return [
16
+ `import { serveHttp } from "@storyshelf/mcp/http";`,
17
+ ``,
18
+ `// Each MCP client sends its own "Authorization: Bearer <StoryShelf token>"; it is forwarded to /api/v1.`,
19
+ `await serveHttp({`,
20
+ ` url: process.env.STORYSHELF_URL!,`,
21
+ ` slug: process.env.STORYSHELF_SLUG,`,
22
+ ` port: Number(process.env.PORT) || 3333,`,
23
+ ` host: process.env.HOST || "127.0.0.1",`,
24
+ `});`,
25
+ `console.error("storyshelf-mcp listening on /mcp");`,
26
+ ``
27
+ ].join("\n");
28
+ }
29
+ /** `package.json` of the scaffolded project. */
30
+ function generateMcpPackageJson(answers, devDependencies) {
31
+ return JSON.stringify({
32
+ name: answers.name,
33
+ version: "0.1.0",
34
+ type: "module",
35
+ private: true,
36
+ description: "StoryShelf MCP server.",
37
+ engines: { node: ">=24" },
38
+ scripts: {
39
+ start: `node ${MCP_ENTRY}`,
40
+ typecheck: "tsc"
41
+ },
42
+ dependencies: { "@storyshelf/mcp": "0.7.0" },
43
+ devDependencies
44
+ }, null, 2);
45
+ }
46
+ /** `.env.example`: never commit a real token. */
47
+ function generateMcpEnvExample(answers) {
48
+ const transportLines = answers.transport === "stdio" ? ["# A project token with at least the viewer role (developer to post comments)", "STORYSHELF_TOKEN="] : [
49
+ "# HTTP clients send their own Bearer token; no token is stored on this server",
50
+ "PORT=3333",
51
+ "HOST=0.0.0.0"
52
+ ];
53
+ return `${[
54
+ `STORYSHELF_URL=${answers.url}`,
55
+ `STORYSHELF_SLUG=${answers.slug}`,
56
+ ...transportLines
57
+ ].join("\n")}\n`;
58
+ }
59
+ const TOKEN_REF = "${STORYSHELF_TOKEN}";
60
+ /** Client config for Claude Code (`.mcp.json`) and Cursor (`.cursor/mcp.json`). */
61
+ function generateMcpClientConfig(answers) {
62
+ const server = answers.transport === "stdio" ? {
63
+ command: "npx",
64
+ args: ["-y", "@storyshelf/mcp"],
65
+ env: {
66
+ STORYSHELF_URL: answers.url,
67
+ STORYSHELF_SLUG: answers.slug,
68
+ STORYSHELF_TOKEN: TOKEN_REF
69
+ }
70
+ } : {
71
+ type: "http",
72
+ url: "http://127.0.0.1:3333/mcp",
73
+ headers: { Authorization: `Bearer ${TOKEN_REF}` }
74
+ };
75
+ return JSON.stringify({ mcpServers: { storyshelf: server } }, null, 2);
76
+ }
77
+ /** Small Node image for the HTTP transport (no browser needed). */
78
+ function generateMcpDockerfile() {
79
+ return [
80
+ "FROM node:24-slim",
81
+ "WORKDIR /app",
82
+ "COPY package.json ./",
83
+ "RUN npm install --omit=dev",
84
+ "COPY src ./src",
85
+ "ENV HOST=0.0.0.0 PORT=3333",
86
+ "EXPOSE 3333",
87
+ `CMD ["node", "${MCP_ENTRY}"]`,
88
+ ""
89
+ ].join("\n");
90
+ }
91
+ //#endregion
92
+ export { MCP_ENTRY, generateMcpClientConfig, generateMcpDockerfile, generateMcpEntry, generateMcpEnvExample, generateMcpPackageJson };
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ import { detectPackageRunner, installCommand } from "../../config.js";
6
+ import { printError } from "../../output.js";
7
+ import { defaultSpawnProcess } from "../server/serve.js";
8
+ import { access } from "node:fs/promises";
9
+ import { join, resolve } from "node:path";
10
+ //#region src/commands/mcp/serve.ts
11
+ const MCP_CANDIDATES = [
12
+ "src/index.ts",
13
+ "src/index.js",
14
+ "src/index.mjs",
15
+ "index.ts",
16
+ "index.js",
17
+ "index.mjs"
18
+ ];
19
+ /** Find the entry of a scaffolded MCP project. */
20
+ async function resolveMcpFile(dir) {
21
+ for (const candidate of MCP_CANDIDATES) {
22
+ const full = join(dir, candidate);
23
+ try {
24
+ await access(full);
25
+ return full;
26
+ } catch {
27
+ continue;
28
+ }
29
+ }
30
+ throw new Error(`No MCP entry found in ${dir} (looked for ${MCP_CANDIDATES.join(", ")}). Run "storyshelf mcp init" first.`);
31
+ }
32
+ /** Run a scaffolded MCP project (`storyshelf mcp serve`). Flags map to STORYSHELF_* env. */
33
+ async function runMcpServe(options, deps = {}) {
34
+ const dir = resolve(options.dir ?? process.cwd());
35
+ const file = await resolveMcpFile(dir);
36
+ await warnIfUninstalled(dir);
37
+ printError(`Starting MCP server: ${file}`);
38
+ const code = await (deps.spawnProcess ?? defaultSpawnProcess)(process.execPath, [file], {
39
+ cwd: dir,
40
+ env: childEnv(options)
41
+ });
42
+ if (code !== 0) throw new Error(`MCP server exited with code ${code}`);
43
+ }
44
+ function childEnv(options) {
45
+ const env = { ...process.env };
46
+ if (options.url !== void 0) env["STORYSHELF_URL"] = options.url;
47
+ if (options.slug !== void 0) env["STORYSHELF_SLUG"] = options.slug;
48
+ if (options.token !== void 0) env["STORYSHELF_TOKEN"] = options.token;
49
+ if (options.port !== void 0) env["PORT"] = options.port;
50
+ return env;
51
+ }
52
+ async function warnIfUninstalled(dir) {
53
+ try {
54
+ await access(join(dir, "node_modules"));
55
+ } catch {
56
+ const runner = await detectPackageRunner(dir);
57
+ printError(`Warning: no node_modules in ${dir} — run "${installCommand(runner)}" there first.`);
58
+ }
59
+ }
60
+ //#endregion
61
+ export { resolveMcpFile, runMcpServe };
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ import "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ import { existsSync } from "node:fs";
6
+ import { readFile, realpath } from "node:fs/promises";
7
+ import { dirname, join, relative, resolve, sep } from "node:path";
8
+ import { extractPackageUsage, loadDepGraph, loadStoryImportPaths, statsPathFor } from "@storyshelf/affected";
9
+ //#region src/commands/package-usage.ts
10
+ /** Dependency sections of `package.json` whose packages are tracked. */
11
+ const DEPENDENCY_FIELDS = [
12
+ "dependencies",
13
+ "devDependencies",
14
+ "peerDependencies"
15
+ ];
16
+ /** Names declared in the project's `package.json`, or none when unreadable. */
17
+ async function declaredPackages(cwd) {
18
+ try {
19
+ const manifest = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
20
+ const names = DEPENDENCY_FIELDS.flatMap((field) => {
21
+ const section = manifest[field];
22
+ return typeof section === "object" && section !== null ? Object.keys(section) : [];
23
+ });
24
+ return [...new Set(names)];
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+ /** Nearest `node_modules/<name>` directory walking up from `cwd` (hoisted installs), if any. */
30
+ function installedDir(cwd, name) {
31
+ for (let dir = resolve(cwd);; dir = dirname(dir)) {
32
+ const candidate = join(dir, "node_modules", name);
33
+ if (existsSync(join(candidate, "package.json"))) return candidate;
34
+ if (dirname(dir) === dir) return null;
35
+ }
36
+ }
37
+ /** Parse an installed `package.json`; null when unreadable or corrupt (never throws). */
38
+ async function readManifest(path) {
39
+ try {
40
+ const parsed = JSON.parse(await readFile(path, "utf8"));
41
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+ /** Installed version and, for workspace links, the real source directory. */
47
+ async function resolvePackage(cwd, name) {
48
+ const dir = installedDir(cwd, name);
49
+ if (dir === null) return {
50
+ tracked: {},
51
+ version: null
52
+ };
53
+ const manifest = await readManifest(join(dir, "package.json"));
54
+ if (manifest === null) return {
55
+ tracked: {},
56
+ version: null
57
+ };
58
+ const version = typeof manifest.version === "string" ? manifest.version : null;
59
+ const real = relative(await realpath(cwd), await realpath(dir));
60
+ return {
61
+ tracked: !real.startsWith("..") && !real.split(sep).includes("node_modules") ? { root: real.split(sep).join("/") } : {},
62
+ version
63
+ };
64
+ }
65
+ /**
66
+ * Collect which declared dependency packages each story reaches, with the
67
+ * installed version. Resolves `[]` (never throws) when the build has no usable
68
+ * stats or the project declares no dependencies.
69
+ */
70
+ async function collectPackageUsage(options) {
71
+ const names = await declaredPackages(options.cwd);
72
+ if (names.length === 0) return [];
73
+ const graph = await loadDepGraph(statsPathFor(options.cwd, options.buildDir, options.statsFile));
74
+ const stories = await loadStoryImportPaths(options.cwd, options.buildDir).catch(() => []);
75
+ if (!graph || stories.length === 0) return [];
76
+ const resolved = new Map(await Promise.all(names.map(async (name) => [name, await resolvePackage(options.cwd, name)])));
77
+ const tracked = Object.fromEntries([...resolved].map(([name, pkg]) => [name, pkg.tracked]));
78
+ return extractPackageUsage(graph, stories, tracked).map((usage) => ({
79
+ storyImportPath: usage.storyFile,
80
+ packageName: usage.packageName,
81
+ modulePath: usage.modulePath,
82
+ version: resolved.get(usage.packageName)?.version ?? null
83
+ }));
84
+ }
85
+ //#endregion
86
+ export { collectPackageUsage };
@@ -0,0 +1,109 @@
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-github.ts
7
+ /** GitHub: the required status checks of a branch protection rule (REST API, admin token). */
8
+ const API = "https://api.github.com";
9
+ /** The GitHub adapter for `owner/repo`. */
10
+ function githubProtection(repo, fetchImpl) {
11
+ if (!/^[\w.-]+\/[\w.-]+$/u.test(repo)) throw new Error(`--repo must be owner/repo, got "${repo}"`);
12
+ return {
13
+ tokenEnv: "GITHUB_TOKEN",
14
+ manualSteps: (target) => manualSteps(repo, target),
15
+ inspect: async (target, token) => await inspect({
16
+ repo,
17
+ fetchImpl,
18
+ token
19
+ }, target)
20
+ };
21
+ }
22
+ function manualSteps(repo, target) {
23
+ return [
24
+ `GitHub: require these checks on ${repo}, branch ${target.branch}:`,
25
+ ...target.checks.map((check) => ` - ${check}`),
26
+ "",
27
+ `1. Open https://github.com/${repo}/settings/branches and edit (or add) the rule for ${target.branch}.`,
28
+ "2. Tick \"Require status checks to pass before merging\".",
29
+ "3. Search for each check above and add it (it appears once a build has posted it).",
30
+ "4. Save. Admin token scopes: repo (classic) or Administration: write (fine-grained)."
31
+ ];
32
+ }
33
+ function headers(token) {
34
+ return {
35
+ accept: "application/vnd.github+json",
36
+ authorization: `Bearer ${token}`,
37
+ "x-github-api-version": "2022-11-28"
38
+ };
39
+ }
40
+ async function inspect(ctx, target) {
41
+ const base = `${API}/repos/${ctx.repo}/branches/${encodeURIComponent(target.branch)}/protection`;
42
+ const existing = await readChecks(ctx, base);
43
+ if (existing === "unprotected") return {
44
+ satisfied: false,
45
+ summary: `Branch ${target.branch} has no protection rule.`,
46
+ apply: async () => await createRule(ctx, base, target)
47
+ };
48
+ if (existing === "no-checks") return {
49
+ satisfied: false,
50
+ summary: `Branch ${target.branch} is protected but requires no status checks. Add them by hand (the API cannot add them to a rule without any).`
51
+ };
52
+ return compare({
53
+ ctx,
54
+ base,
55
+ existing
56
+ }, target);
57
+ }
58
+ /** Which of the target's checks the rule lacks. */
59
+ function compare(rule, target) {
60
+ const { existing } = rule;
61
+ const present = [...existing.contexts ?? [], ...(existing.checks ?? []).map((c) => c.context)];
62
+ const missing = target.checks.filter((check) => !present.includes(check));
63
+ if (missing.length === 0) return {
64
+ satisfied: true,
65
+ summary: `OK: ${target.branch} already requires ${target.checks.join(", ")}.`
66
+ };
67
+ return {
68
+ satisfied: false,
69
+ summary: `Branch ${target.branch} does not require: ${missing.join(", ")} (it requires ${present.join(", ") || "nothing"}).`,
70
+ apply: async () => await addChecks(rule.ctx, rule.base, missing)
71
+ };
72
+ }
73
+ /** The branch's required checks, or why there are none. */
74
+ async function readChecks(ctx, base) {
75
+ try {
76
+ return await hostJson(ctx.fetchImpl, `${base}/required_status_checks`, { headers: headers(ctx.token) });
77
+ } catch (error) {
78
+ if (!(error instanceof HostError) || error.status !== 404) throw error;
79
+ return /not protected/iu.test(error.message) ? "unprotected" : "no-checks";
80
+ }
81
+ }
82
+ /** Add checks to a rule that already requires some; nothing else in the rule changes. */
83
+ async function addChecks(ctx, base, missing) {
84
+ await hostJson(ctx.fetchImpl, `${base}/required_status_checks/contexts`, {
85
+ method: "POST",
86
+ headers: headers(ctx.token),
87
+ json: missing
88
+ });
89
+ return `Added required checks: ${missing.join(", ")}.`;
90
+ }
91
+ /** A new rule on an unprotected branch: required checks only, nothing else restricted. */
92
+ async function createRule(ctx, base, target) {
93
+ await hostJson(ctx.fetchImpl, base, {
94
+ method: "PUT",
95
+ headers: headers(ctx.token),
96
+ json: {
97
+ required_status_checks: {
98
+ strict: false,
99
+ contexts: target.checks
100
+ },
101
+ enforce_admins: false,
102
+ required_pull_request_reviews: null,
103
+ restrictions: null
104
+ }
105
+ });
106
+ return `Created a rule on ${target.branch} requiring ${target.checks.join(", ")}.`;
107
+ }
108
+ //#endregion
109
+ export { githubProtection };