storyshelf 0.6.2 → 0.8.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.
Files changed (39) hide show
  1. package/README.md +7 -0
  2. package/dist/client.js +45 -5
  3. package/dist/commands/build.js +2 -4
  4. package/dist/commands/connection.js +1 -2
  5. package/dist/commands/create.js +1 -2
  6. package/dist/commands/dedup.js +38 -0
  7. package/dist/commands/default.js +1 -2
  8. package/dist/commands/doctor.js +2 -4
  9. package/dist/commands/file-by-file.js +72 -0
  10. package/dist/commands/init.js +1 -2
  11. package/dist/commands/mcp/init.js +83 -0
  12. package/dist/commands/mcp/scaffold.js +91 -0
  13. package/dist/commands/mcp/serve.js +59 -0
  14. package/dist/commands/package-usage.js +84 -0
  15. package/dist/commands/protect-github.js +108 -0
  16. package/dist/commands/protect-gitlab.js +68 -0
  17. package/dist/commands/protect-types.js +29 -0
  18. package/dist/commands/protect.js +50 -0
  19. package/dist/commands/purge.js +1 -2
  20. package/dist/commands/retry.js +2 -3
  21. package/dist/commands/server/docker.js +8 -41
  22. package/dist/commands/server/init.js +48 -206
  23. package/dist/commands/server/prompts.js +20 -201
  24. package/dist/commands/server/serve.js +3 -5
  25. package/dist/commands/server/terraform-aws.js +546 -86
  26. package/dist/commands/server/terraform-azure.js +26 -98
  27. package/dist/commands/server/terraform-gcp.js +17 -82
  28. package/dist/commands/shared/detect-adapters.js +5 -18
  29. package/dist/commands/shared/scaffold.js +3 -7
  30. package/dist/commands/storybook-build.js +2 -4
  31. package/dist/commands/upload.js +50 -11
  32. package/dist/commands/whoami.js +1 -2
  33. package/dist/config.js +4 -7
  34. package/dist/index.js +14 -12
  35. package/dist/is-main.js +2 -4
  36. package/dist/output.js +1 -2
  37. package/package.json +10 -9
  38. package/dist/commands/worker/init.js +0 -260
  39. package/dist/commands/worker/serve.js +0 -109
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
package/dist/client.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import "node:path";
3
3
  import "node:url";
4
4
  import.meta.url;
5
- //#region src/client.ts
5
+ // src/client.ts
6
6
  /**
7
7
  * Create a typed API client for a StoryShelf server.
8
8
  *
@@ -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"),
@@ -97,5 +138,4 @@ function createProjectsApi(baseUrl, authHeaders, requestHeaders) {
97
138
  admin: createAdminApi(baseUrl, requestHeaders)
98
139
  };
99
140
  }
100
- //#endregion
101
- export { createClient };
141
+ export { RequestError, createClient };
@@ -1,12 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import "node:path";
2
+ import { resolve } from "node:path";
3
3
  import "node:url";
4
4
  import.meta.url;
5
5
  import { loadStorybookConfig } from "../config.js";
6
6
  import { printLine } from "../output.js";
7
7
  import { assertBuildOutput, ensureBuildDir } from "./storybook-build.js";
8
- import { resolve } from "node:path";
9
- //#region src/commands/build.ts
8
+ // src/commands/build.ts
10
9
  /**
11
10
  * Build a Storybook directory without uploading, using the same
12
11
  * buildDir/buildCommand/buildScriptName resolution as `upload`.
@@ -27,5 +26,4 @@ async function runBuild(options) {
27
26
  await assertBuildOutput(cwd, buildDir);
28
27
  printLine(`Build ready: ${resolve(cwd, buildDir)}`);
29
28
  }
30
- //#endregion
31
29
  export { runBuild };
@@ -3,7 +3,7 @@ import "node:path";
3
3
  import "node:url";
4
4
  import.meta.url;
5
5
  import { loadStorybookConfig } from "../config.js";
6
- //#region src/commands/connection.ts
6
+ // src/commands/connection.ts
7
7
  /**
8
8
  * Resolve url/slug/token as flags > config file > env, throwing on the first
9
9
  * missing value (same messages as `upload`).
@@ -25,5 +25,4 @@ function assertConnectionFields(fields) {
25
25
  if (!fields.slug) throw new Error("--slug is required (or .storybook/storyshelf.json / STORYSHELF_SLUG)");
26
26
  if (!fields.token) throw new Error("--token is required (or STORYSHELF_TOKEN env)");
27
27
  }
28
- //#endregion
29
28
  export { resolveConnection };
@@ -5,7 +5,7 @@ import.meta.url;
5
5
  import { assertStorybookMain, detectGitDefaultBranch, detectGitRepository, detectPackageName, detectStorybookMeta, writeStorybookConfig } from "../config.js";
6
6
  import { printError, printLine } from "../output.js";
7
7
  import { createClient } from "../client.js";
8
- //#region src/commands/create.ts
8
+ // src/commands/create.ts
9
9
  async function readCreateInputs(options, cwd) {
10
10
  const name = (options.name === "" ? void 0 : options.name) ?? await detectPackageName(cwd);
11
11
  return {
@@ -61,5 +61,4 @@ async function runCreate(options) {
61
61
  }
62
62
  await executeCreate(inputs.url, inputs.token, buildCreatePayload(inputs.name, inputs), cwd);
63
63
  }
64
- //#endregion
65
64
  export { runCreate };
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import { join, relative, resolve } from "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ import { readFile, readdir, stat } from "node:fs/promises";
6
+ import { createHash } from "node:crypto";
7
+ // src/commands/dedup.ts
8
+ async function walkFiles(cwd, buildDir) {
9
+ const root = resolve(cwd, buildDir);
10
+ const files = [];
11
+ async function walk(dir) {
12
+ const entries = await readdir(dir, { withFileTypes: true });
13
+ for (const entry of entries) {
14
+ const full = join(dir, entry.name);
15
+ if (entry.isDirectory()) await walk(full);
16
+ else files.push(relative(root, full));
17
+ }
18
+ }
19
+ await walk(root);
20
+ return files;
21
+ }
22
+ async function hashFiles(cwd, buildDir, files) {
23
+ const root = resolve(cwd, buildDir);
24
+ const results = [];
25
+ for (const rel of files) {
26
+ const full = join(root, rel);
27
+ const buffer = await readFile(full);
28
+ const hash = createHash("sha256").update(buffer).digest("hex");
29
+ const { size } = await stat(full);
30
+ results.push({
31
+ rel,
32
+ hash,
33
+ size
34
+ });
35
+ }
36
+ return results;
37
+ }
38
+ export { hashFiles, walkFiles };
@@ -5,7 +5,7 @@ import.meta.url;
5
5
  import { loadStorybookConfig } from "../config.js";
6
6
  import { printError } from "../output.js";
7
7
  import { runUpload } from "./upload.js";
8
- //#region src/commands/default.ts
8
+ // src/commands/default.ts
9
9
  /** Print an error and mark the process as failed. */
10
10
  function handleError(error) {
11
11
  printError(error instanceof Error ? error.message : String(error));
@@ -46,5 +46,4 @@ async function runDefaultCommand(program) {
46
46
  }
47
47
  await runUpload(options).catch(handleError);
48
48
  }
49
- //#endregion
50
49
  export { handleError, runDefaultCommand };
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import "node:path";
2
+ import { resolve } from "node:path";
3
3
  import "node:url";
4
4
  import.meta.url;
5
5
  import { findStorybookMain, loadStorybookConfig } from "../config.js";
@@ -8,9 +8,8 @@ import { needsBuild, resolveBuildCommand } from "./storybook-build.js";
8
8
  import { createClient } from "../client.js";
9
9
  import { resolveConnection } from "./connection.js";
10
10
  import { access, readdir } from "node:fs/promises";
11
- import { resolve } from "node:path";
12
11
  import { gitRepoStatus } from "@storyshelf/affected";
13
- //#region src/commands/doctor.ts
12
+ // src/commands/doctor.ts
14
13
  function mark(status) {
15
14
  if (status === "pass") return "✓";
16
15
  return status === "warn" ? "!" : "✗";
@@ -146,5 +145,4 @@ function reportChecks(checks) {
146
145
  for (const check of checks) printLine(`${mark(check.status)} ${check.message}`);
147
146
  if (checks.some((check) => check.status === "fail")) process.exitCode = 1;
148
147
  }
149
- //#endregion
150
148
  export { runDoctor };
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ import { join, resolve } from "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
+ // src/commands/file-by-file.ts
9
+ /**
10
+ * File-by-file upload (`/dedup`, `/content`, `/manifest`): stores a build's files content-addressed
11
+ * and needs no zip extraction on the server, so it publishes on any host, serverless included.
12
+ * `upload` falls back to it when the server refuses a zip over its inline limit.
13
+ */
14
+ /** Files sent in one `/content` request. */
15
+ const BATCH_FILES = 50;
16
+ /** Bytes sent in one `/content` request (a single larger file goes alone). */
17
+ const BATCH_BYTES = 16777216;
18
+ /** Hashes asked about in one `/dedup` request. */
19
+ const DEDUP_CHUNK = 5e3;
20
+ /** What the server answers a zip it cannot extract inline and cannot hand to a job. */
21
+ function isOverInlineLimit(error) {
22
+ return error instanceof RequestError && error.status === 413 && error.message.includes("/dedup");
23
+ }
24
+ /** Upload every file of the build directory the server lacks, then post the manifest. */
25
+ async function uploadFileByFile(client, target, log) {
26
+ const files = await hashFiles(target.cwd, target.buildDir, await walkFiles(target.cwd, target.buildDir));
27
+ const toSend = firstFileOfEach(files, await neededHashes(client, target, files));
28
+ log(`Uploading ${toSend.length} of ${files.length} files (the server has the rest)`);
29
+ for (const batch of batches(toSend)) await sendBatch(client, target, batch);
30
+ await client.projects.builds.postManifest(target.slug, target.buildId, files);
31
+ }
32
+ async function neededHashes(client, target, files) {
33
+ const unique = [...new Set(files.map((file) => file.hash))];
34
+ const needed = /* @__PURE__ */ new Set();
35
+ for (let at = 0; at < unique.length; at += DEDUP_CHUNK) {
36
+ const answer = await client.projects.builds.dedup(target.slug, target.buildId, unique.slice(at, at + DEDUP_CHUNK));
37
+ for (const hash of answer) needed.add(hash);
38
+ }
39
+ return needed;
40
+ }
41
+ /** One file per needed hash (identical files are sent once). */
42
+ function firstFileOfEach(files, needed) {
43
+ const seen = /* @__PURE__ */ new Set();
44
+ return files.filter((file) => {
45
+ if (!needed.has(file.hash) || seen.has(file.hash)) return false;
46
+ seen.add(file.hash);
47
+ return true;
48
+ });
49
+ }
50
+ /** Split into requests of at most {@link BATCH_FILES} files and {@link BATCH_BYTES} bytes (one big file goes alone). */
51
+ function batches(files) {
52
+ const out = [];
53
+ for (const file of files) {
54
+ const last = out.at(-1);
55
+ if (last && fitsBatch(last, file)) last.push(file);
56
+ else out.push([file]);
57
+ }
58
+ return out;
59
+ }
60
+ function fitsBatch(batch, file) {
61
+ const bytes = batch.reduce((sum, member) => sum + member.size, 0);
62
+ return batch.length < BATCH_FILES && bytes + file.size <= BATCH_BYTES;
63
+ }
64
+ async function sendBatch(client, target, batch) {
65
+ const root = resolve(target.cwd, target.buildDir);
66
+ const files = await Promise.all(batch.map(async (file) => ({
67
+ hash: file.hash,
68
+ data: await readFile(join(root, file.rel))
69
+ })));
70
+ await client.projects.builds.uploadContent(target.slug, target.buildId, files);
71
+ }
72
+ export { isOverInlineLimit, uploadFileByFile };
@@ -6,7 +6,7 @@ import { assertStorybookMain, detectStorybookMeta, loadStorybookConfig, writeSto
6
6
  import { printError, printLine } from "../output.js";
7
7
  import { createClient } from "../client.js";
8
8
  import prompts from "prompts";
9
- //#region src/commands/init.ts
9
+ // src/commands/init.ts
10
10
  /** Human description of the detected Storybook (framework, addons, path). */
11
11
  function describeMeta(meta) {
12
12
  const hintParts = [];
@@ -193,5 +193,4 @@ async function runInit(options) {
193
193
  if (!answers) return;
194
194
  await finalizeInit(answers, cwd, options);
195
195
  }
196
- //#endregion
197
196
  export { runInit };
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ import { join, resolve } from "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 prompts from "prompts";
12
+ // src/commands/mcp/init.ts
13
+ const MCP_PROMPTS = [
14
+ {
15
+ type: "select",
16
+ name: "transport",
17
+ message: "How will agents connect?",
18
+ choices: [{
19
+ title: "stdio (local agents: Claude Code, Cursor)",
20
+ value: "stdio"
21
+ }, {
22
+ title: "HTTP (remote or shared agents)",
23
+ value: "http"
24
+ }]
25
+ },
26
+ {
27
+ type: "text",
28
+ name: "url",
29
+ message: "StoryShelf server URL?",
30
+ initial: process.env["STORYSHELF_URL"] ?? "https://shelf.example.com"
31
+ },
32
+ {
33
+ type: "text",
34
+ name: "slug",
35
+ message: "Default project slug?",
36
+ initial: process.env["STORYSHELF_SLUG"] ?? ""
37
+ },
38
+ {
39
+ type: "confirm",
40
+ name: "docker",
41
+ message: "Add a Dockerfile (HTTP transport)?",
42
+ initial: false
43
+ }
44
+ ];
45
+ /** Scaffold a StoryShelf MCP server project (`storyshelf mcp init`). */
46
+ async function runMcpInit(options) {
47
+ const responses = await prompts([...PROJECT_PROMPTS, ...MCP_PROMPTS]);
48
+ if (!responses.name || !responses.dir) {
49
+ printError("Cancelled.");
50
+ return;
51
+ }
52
+ const answers = {
53
+ ...responses,
54
+ dir: options.dir ?? responses.dir
55
+ };
56
+ const outDir = resolve(answers.dir);
57
+ await mkdir(outDir, { recursive: true });
58
+ await writeFiles(outDir, answers);
59
+ const runner = await detectPackageRunner(process.cwd());
60
+ printNextSteps(answers, outDir, installCommand(runner));
61
+ }
62
+ async function writeFiles(outDir, answers) {
63
+ await writeScaffoldFile(outDir, MCP_ENTRY, generateMcpEntry(answers.transport));
64
+ await writeFile(join(outDir, "package.json"), generateMcpPackageJson(answers, SCAFFOLD_DEV_DEPENDENCIES));
65
+ await writeFile(join(outDir, "tsconfig.json"), generateTsconfig());
66
+ await writeFile(join(outDir, ".env.example"), generateMcpEnvExample(answers));
67
+ await writeFile(join(outDir, ".mcp.json"), `${generateMcpClientConfig(answers)}\n`);
68
+ printLine(`Created ${MCP_ENTRY}, package.json, tsconfig.json, .env.example, .mcp.json`);
69
+ if (answers.docker) {
70
+ await writeFile(join(outDir, "Dockerfile"), generateMcpDockerfile());
71
+ printLine(`Created Dockerfile`);
72
+ }
73
+ }
74
+ function printNextSteps(answers, outDir, install) {
75
+ printLine(`\nScaffolded ${answers.name} in ${outDir}`);
76
+ printLine(`\nNext steps:`);
77
+ printLine(` cd ${answers.dir}`);
78
+ printLine(` ${install}`);
79
+ printLine(` cp .env.example .env # add STORYSHELF_TOKEN (never commit it)`);
80
+ printLine(` npx storyshelf mcp serve --dir .`);
81
+ printLine(`\n.mcp.json is a ready client config for Claude Code (copy to your repo root).`);
82
+ }
83
+ export { runMcpInit };
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ import "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ // 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.8.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
+ export { MCP_ENTRY, generateMcpClientConfig, generateMcpDockerfile, generateMcpEntry, generateMcpEnvExample, generateMcpPackageJson };
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ import { join, resolve } from "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
+ // src/commands/mcp/serve.ts
10
+ const MCP_CANDIDATES = [
11
+ "src/index.ts",
12
+ "src/index.js",
13
+ "src/index.mjs",
14
+ "index.ts",
15
+ "index.js",
16
+ "index.mjs"
17
+ ];
18
+ /** Find the entry of a scaffolded MCP project. */
19
+ async function resolveMcpFile(dir) {
20
+ for (const candidate of MCP_CANDIDATES) {
21
+ const full = join(dir, candidate);
22
+ try {
23
+ await access(full);
24
+ return full;
25
+ } catch {
26
+ continue;
27
+ }
28
+ }
29
+ throw new Error(`No MCP entry found in ${dir} (looked for ${MCP_CANDIDATES.join(", ")}). Run "storyshelf mcp init" first.`);
30
+ }
31
+ /** Run a scaffolded MCP project (`storyshelf mcp serve`). Flags map to STORYSHELF_* env. */
32
+ async function runMcpServe(options, deps = {}) {
33
+ const dir = resolve(options.dir ?? process.cwd());
34
+ const file = await resolveMcpFile(dir);
35
+ await warnIfUninstalled(dir);
36
+ printError(`Starting MCP server: ${file}`);
37
+ const code = await (deps.spawnProcess ?? defaultSpawnProcess)(process.execPath, [file], {
38
+ cwd: dir,
39
+ env: childEnv(options)
40
+ });
41
+ if (code !== 0) throw new Error(`MCP server exited with code ${code}`);
42
+ }
43
+ function childEnv(options) {
44
+ const env = { ...process.env };
45
+ if (options.url !== void 0) env["STORYSHELF_URL"] = options.url;
46
+ if (options.slug !== void 0) env["STORYSHELF_SLUG"] = options.slug;
47
+ if (options.token !== void 0) env["STORYSHELF_TOKEN"] = options.token;
48
+ if (options.port !== void 0) env["PORT"] = options.port;
49
+ return env;
50
+ }
51
+ async function warnIfUninstalled(dir) {
52
+ try {
53
+ await access(join(dir, "node_modules"));
54
+ } catch {
55
+ const runner = await detectPackageRunner(dir);
56
+ printError(`Warning: no node_modules in ${dir} — run "${installCommand(runner)}" there first.`);
57
+ }
58
+ }
59
+ export { resolveMcpFile, runMcpServe };
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ import { dirname, join, relative, resolve, sep } from "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 { extractPackageUsage, loadDepGraph, loadStoryImportPaths, statsPathFor } from "@storyshelf/affected";
8
+ // src/commands/package-usage.ts
9
+ /** Dependency sections of `package.json` whose packages are tracked. */
10
+ const DEPENDENCY_FIELDS = [
11
+ "dependencies",
12
+ "devDependencies",
13
+ "peerDependencies"
14
+ ];
15
+ /** Names declared in the project's `package.json`, or none when unreadable. */
16
+ async function declaredPackages(cwd) {
17
+ try {
18
+ const manifest = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
19
+ const names = DEPENDENCY_FIELDS.flatMap((field) => {
20
+ const section = manifest[field];
21
+ return typeof section === "object" && section !== null ? Object.keys(section) : [];
22
+ });
23
+ return [...new Set(names)];
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+ /** Nearest `node_modules/<name>` directory walking up from `cwd` (hoisted installs), if any. */
29
+ function installedDir(cwd, name) {
30
+ for (let dir = resolve(cwd);; dir = dirname(dir)) {
31
+ const candidate = join(dir, "node_modules", name);
32
+ if (existsSync(join(candidate, "package.json"))) return candidate;
33
+ if (dirname(dir) === dir) return null;
34
+ }
35
+ }
36
+ /** Parse an installed `package.json`; null when unreadable or corrupt (never throws). */
37
+ async function readManifest(path) {
38
+ try {
39
+ const parsed = JSON.parse(await readFile(path, "utf8"));
40
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+ /** Installed version and, for workspace links, the real source directory. */
46
+ async function resolvePackage(cwd, name) {
47
+ const dir = installedDir(cwd, name);
48
+ if (dir === null) return {
49
+ tracked: {},
50
+ version: null
51
+ };
52
+ const manifest = await readManifest(join(dir, "package.json"));
53
+ if (manifest === null) return {
54
+ tracked: {},
55
+ version: null
56
+ };
57
+ const version = typeof manifest.version === "string" ? manifest.version : null;
58
+ const real = relative(await realpath(cwd), await realpath(dir));
59
+ return {
60
+ tracked: !real.startsWith("..") && !real.split(sep).includes("node_modules") ? { root: real.split(sep).join("/") } : {},
61
+ version
62
+ };
63
+ }
64
+ /**
65
+ * Collect which declared dependency packages each story reaches, with the
66
+ * installed version. Resolves `[]` (never throws) when the build has no usable
67
+ * stats or the project declares no dependencies.
68
+ */
69
+ async function collectPackageUsage(options) {
70
+ const names = await declaredPackages(options.cwd);
71
+ if (names.length === 0) return [];
72
+ const graph = await loadDepGraph(statsPathFor(options.cwd, options.buildDir, options.statsFile));
73
+ const stories = await loadStoryImportPaths(options.cwd, options.buildDir).catch(() => []);
74
+ if (!graph || stories.length === 0) return [];
75
+ const resolved = new Map(await Promise.all(names.map(async (name) => [name, await resolvePackage(options.cwd, name)])));
76
+ const tracked = Object.fromEntries([...resolved].map(([name, pkg]) => [name, pkg.tracked]));
77
+ return extractPackageUsage(graph, stories, tracked).map((usage) => ({
78
+ storyImportPath: usage.storyFile,
79
+ packageName: usage.packageName,
80
+ modulePath: usage.modulePath,
81
+ version: resolved.get(usage.packageName)?.version ?? null
82
+ }));
83
+ }
84
+ export { collectPackageUsage };