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.
- package/README.md +7 -0
- package/dist/client.js +44 -3
- package/dist/commands/dedup.js +40 -0
- package/dist/commands/file-by-file.js +74 -0
- package/dist/commands/mcp/init.js +85 -0
- package/dist/commands/mcp/scaffold.js +92 -0
- package/dist/commands/mcp/serve.js +61 -0
- package/dist/commands/package-usage.js +86 -0
- package/dist/commands/protect-github.js +109 -0
- package/dist/commands/protect-gitlab.js +69 -0
- package/dist/commands/protect-types.js +30 -0
- package/dist/commands/protect.js +51 -0
- package/dist/commands/retry.js +1 -1
- package/dist/commands/server/docker.js +7 -39
- package/dist/commands/server/init.js +46 -200
- package/dist/commands/server/prompts.js +19 -187
- package/dist/commands/server/serve.js +1 -1
- package/dist/commands/server/terraform-aws.js +5 -35
- package/dist/commands/server/terraform-azure.js +16 -87
- package/dist/commands/server/terraform-gcp.js +9 -73
- package/dist/commands/shared/detect-adapters.js +3 -14
- package/dist/commands/shared/scaffold.js +1 -3
- package/dist/commands/upload.js +42 -6
- package/dist/config.js +1 -1
- package/dist/index.js +13 -10
- package/package.json +8 -7
- package/dist/commands/worker/init.js +0 -260
- 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
|
@@ -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
|
|
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
|
|
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 };
|
|
@@ -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 };
|