@mcptoolshop/mcpt-publishing 0.2.0 → 0.3.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/CHANGELOG.md +27 -0
- package/README.md +89 -33
- package/package.json +8 -1
- package/schemas/assets-receipt.schema.json +53 -0
- package/schemas/fix-receipt.schema.json +113 -0
- package/scripts/lib/providers/npm.mjs +10 -0
- package/src/audit/run-audit.mjs +108 -0
- package/src/cli/exit-codes.mjs +2 -0
- package/src/cli/help.mjs +15 -8
- package/src/cli/router.mjs +3 -0
- package/src/commands/assets.mjs +169 -0
- package/src/commands/audit.mjs +4 -84
- package/src/commands/fix.mjs +333 -0
- package/src/commands/init.mjs +27 -12
- package/src/commands/plan.mjs +17 -15
- package/src/commands/verify-receipt.mjs +58 -0
- package/src/commands/weekly.mjs +143 -0
- package/src/fixers/fixer.mjs +79 -0
- package/src/fixers/fixers/_npm-helpers.mjs +75 -0
- package/src/fixers/fixers/github-about.mjs +100 -0
- package/src/fixers/fixers/npm-bugs.mjs +67 -0
- package/src/fixers/fixers/npm-homepage.mjs +67 -0
- package/src/fixers/fixers/npm-keywords.mjs +77 -0
- package/src/fixers/fixers/npm-repository.mjs +80 -0
- package/src/fixers/fixers/nuget-csproj.mjs +200 -0
- package/src/fixers/fixers/readme-header.mjs +136 -0
- package/src/fixers/registry.mjs +71 -0
- package/src/plugins/loader.mjs +48 -0
- package/src/receipts/fix-receipt.mjs +49 -0
- package/src/receipts/index-writer.mjs +21 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixer: npm-keywords — adds starter `keywords` to package.json.
|
|
3
|
+
* Matches audit finding code: "missing-keywords"
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Fixer } from "../fixer.mjs";
|
|
7
|
+
import { readPkgJson, writePkgJson, readRemoteFile, writeRemoteFile } from "./_npm-helpers.mjs";
|
|
8
|
+
|
|
9
|
+
export default class NpmKeywordsFixer extends Fixer {
|
|
10
|
+
get code() { return "npm-keywords"; }
|
|
11
|
+
get target() { return "npm"; }
|
|
12
|
+
|
|
13
|
+
canFix(finding) {
|
|
14
|
+
return finding.code === "missing-keywords";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe() {
|
|
18
|
+
return "Add starter keywords to package.json";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#buildKeywords(entry) {
|
|
22
|
+
const base = ["mcp", "mcp-tool-shop"];
|
|
23
|
+
// Extract a usable keyword from package name (strip scope)
|
|
24
|
+
const shortName = entry.name.replace(/^@[^/]+\//, "");
|
|
25
|
+
if (shortName && !base.includes(shortName)) {
|
|
26
|
+
base.push(shortName);
|
|
27
|
+
}
|
|
28
|
+
return base;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async diagnose(entry, ctx, opts = {}) {
|
|
32
|
+
const expected = this.#buildKeywords(entry);
|
|
33
|
+
|
|
34
|
+
if (opts.remote) {
|
|
35
|
+
const remote = readRemoteFile(entry.repo, "package.json");
|
|
36
|
+
if (!remote) return { needed: false };
|
|
37
|
+
const data = JSON.parse(remote.content);
|
|
38
|
+
if (data.keywords?.length > 0) return { needed: false };
|
|
39
|
+
return { needed: true, before: null, after: expected, file: "package.json" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const pkg = readPkgJson(opts.cwd ?? process.cwd());
|
|
43
|
+
if (!pkg) return { needed: false };
|
|
44
|
+
if (pkg.data.keywords?.length > 0) return { needed: false };
|
|
45
|
+
return { needed: true, before: null, after: expected, file: "package.json" };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async applyLocal(entry, ctx, opts = {}) {
|
|
49
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
50
|
+
const pkg = readPkgJson(cwd);
|
|
51
|
+
if (!pkg) return { changed: false };
|
|
52
|
+
|
|
53
|
+
const before = pkg.data.keywords ?? null;
|
|
54
|
+
pkg.data.keywords = this.#buildKeywords(entry);
|
|
55
|
+
writePkgJson(pkg.path, pkg.data);
|
|
56
|
+
|
|
57
|
+
return { changed: true, before, after: pkg.data.keywords, file: "package.json" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async applyRemote(entry, ctx, opts = {}) {
|
|
61
|
+
const remote = readRemoteFile(entry.repo, "package.json");
|
|
62
|
+
if (!remote) return { changed: false };
|
|
63
|
+
|
|
64
|
+
const data = JSON.parse(remote.content);
|
|
65
|
+
const before = data.keywords ?? null;
|
|
66
|
+
data.keywords = this.#buildKeywords(entry);
|
|
67
|
+
|
|
68
|
+
const ok = writeRemoteFile(
|
|
69
|
+
entry.repo, "package.json",
|
|
70
|
+
JSON.stringify(data, null, 2) + "\n",
|
|
71
|
+
remote.sha,
|
|
72
|
+
`chore: add keywords to package.json`
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return { changed: ok, before, after: data.keywords, file: "package.json" };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixer: npm-repository — corrects the `repository` field in package.json.
|
|
3
|
+
* Matches audit finding code: "wrong-repo-url"
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Fixer } from "../fixer.mjs";
|
|
7
|
+
import { readPkgJson, writePkgJson, readRemoteFile, writeRemoteFile } from "./_npm-helpers.mjs";
|
|
8
|
+
|
|
9
|
+
export default class NpmRepositoryFixer extends Fixer {
|
|
10
|
+
get code() { return "npm-repository"; }
|
|
11
|
+
get target() { return "npm"; }
|
|
12
|
+
|
|
13
|
+
canFix(finding) {
|
|
14
|
+
return finding.code === "wrong-repo-url";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe() {
|
|
18
|
+
return "Fix npm package repository URL";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async diagnose(entry, ctx, opts = {}) {
|
|
22
|
+
const expected = {
|
|
23
|
+
type: "git",
|
|
24
|
+
url: `git+https://github.com/${entry.repo}.git`,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
if (opts.remote) {
|
|
28
|
+
const remote = readRemoteFile(entry.repo, "package.json");
|
|
29
|
+
if (!remote) return { needed: false };
|
|
30
|
+
const data = JSON.parse(remote.content);
|
|
31
|
+
const current = data.repository;
|
|
32
|
+
const currentUrl = typeof current === "string" ? current : current?.url;
|
|
33
|
+
if (currentUrl === expected.url) return { needed: false };
|
|
34
|
+
return { needed: true, before: current ?? null, after: expected, file: "package.json" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const pkg = readPkgJson(opts.cwd ?? process.cwd());
|
|
38
|
+
if (!pkg) return { needed: false };
|
|
39
|
+
const current = pkg.data.repository;
|
|
40
|
+
const currentUrl = typeof current === "string" ? current : current?.url;
|
|
41
|
+
if (currentUrl === expected.url) return { needed: false };
|
|
42
|
+
return { needed: true, before: current ?? null, after: expected, file: "package.json" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async applyLocal(entry, ctx, opts = {}) {
|
|
46
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
47
|
+
const pkg = readPkgJson(cwd);
|
|
48
|
+
if (!pkg) return { changed: false };
|
|
49
|
+
|
|
50
|
+
const before = pkg.data.repository ?? null;
|
|
51
|
+
pkg.data.repository = {
|
|
52
|
+
type: "git",
|
|
53
|
+
url: `git+https://github.com/${entry.repo}.git`,
|
|
54
|
+
};
|
|
55
|
+
writePkgJson(pkg.path, pkg.data);
|
|
56
|
+
|
|
57
|
+
return { changed: true, before, after: pkg.data.repository, file: "package.json" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async applyRemote(entry, ctx, opts = {}) {
|
|
61
|
+
const remote = readRemoteFile(entry.repo, "package.json");
|
|
62
|
+
if (!remote) return { changed: false };
|
|
63
|
+
|
|
64
|
+
const data = JSON.parse(remote.content);
|
|
65
|
+
const before = data.repository ?? null;
|
|
66
|
+
data.repository = {
|
|
67
|
+
type: "git",
|
|
68
|
+
url: `git+https://github.com/${entry.repo}.git`,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const ok = writeRemoteFile(
|
|
72
|
+
entry.repo, "package.json",
|
|
73
|
+
JSON.stringify(data, null, 2) + "\n",
|
|
74
|
+
remote.sha,
|
|
75
|
+
`chore: fix repository URL in package.json`
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return { changed: ok, before, after: data.repository, file: "package.json" };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixer: nuget-csproj — adds/fixes metadata fields in .csproj.
|
|
3
|
+
* Matches audit finding code: "missing-project-url"
|
|
4
|
+
*
|
|
5
|
+
* Handles: PackageProjectUrl, RepositoryUrl, PackageIcon, PackageReadmeFile
|
|
6
|
+
* Uses string manipulation (no XML parser, zero deps).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { Fixer } from "../fixer.mjs";
|
|
12
|
+
import { readRemoteFile, writeRemoteFile } from "./_npm-helpers.mjs";
|
|
13
|
+
|
|
14
|
+
export default class NuGetCsprojFixer extends Fixer {
|
|
15
|
+
get code() { return "nuget-csproj"; }
|
|
16
|
+
get target() { return "nuget"; }
|
|
17
|
+
|
|
18
|
+
canFix(finding) {
|
|
19
|
+
return finding.code === "missing-project-url";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe() {
|
|
23
|
+
return "Add PackageProjectUrl and RepositoryUrl to .csproj";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async diagnose(entry, ctx, opts = {}) {
|
|
27
|
+
const repoUrl = `https://github.com/${entry.repo}`;
|
|
28
|
+
const projectUrl = `${repoUrl}#readme`;
|
|
29
|
+
|
|
30
|
+
if (opts.remote) {
|
|
31
|
+
// Find csproj remotely — check common paths
|
|
32
|
+
const csprojPath = await this.#findCsprojRemote(entry);
|
|
33
|
+
if (!csprojPath) return { needed: false };
|
|
34
|
+
|
|
35
|
+
const remote = readRemoteFile(entry.repo, csprojPath);
|
|
36
|
+
if (!remote) return { needed: false };
|
|
37
|
+
|
|
38
|
+
const needs = this.#analyzeCsproj(remote.content, repoUrl, projectUrl);
|
|
39
|
+
if (needs.length === 0) return { needed: false };
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
needed: true,
|
|
43
|
+
before: needs.map(n => `${n}: (missing)`).join(", "),
|
|
44
|
+
after: needs.map(n => `${n}: (set)`).join(", "),
|
|
45
|
+
file: csprojPath,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
50
|
+
const csprojPath = this.#findCsprojLocal(cwd, entry.name);
|
|
51
|
+
if (!csprojPath) return { needed: false };
|
|
52
|
+
|
|
53
|
+
const content = readFileSync(csprojPath, "utf8");
|
|
54
|
+
const needs = this.#analyzeCsproj(content, repoUrl, projectUrl);
|
|
55
|
+
if (needs.length === 0) return { needed: false };
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
needed: true,
|
|
59
|
+
before: needs.map(n => `${n}: (missing)`).join(", "),
|
|
60
|
+
after: needs.map(n => `${n}: (set)`).join(", "),
|
|
61
|
+
file: csprojPath,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async applyLocal(entry, ctx, opts = {}) {
|
|
66
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
67
|
+
const csprojPath = this.#findCsprojLocal(cwd, entry.name);
|
|
68
|
+
if (!csprojPath) return { changed: false };
|
|
69
|
+
|
|
70
|
+
const content = readFileSync(csprojPath, "utf8");
|
|
71
|
+
const repoUrl = `https://github.com/${entry.repo}`;
|
|
72
|
+
const projectUrl = `${repoUrl}#readme`;
|
|
73
|
+
const newContent = this.#fixCsproj(content, repoUrl, projectUrl);
|
|
74
|
+
|
|
75
|
+
if (newContent === content) return { changed: false };
|
|
76
|
+
|
|
77
|
+
writeFileSync(csprojPath, newContent);
|
|
78
|
+
return { changed: true, before: "(missing metadata)", after: "(metadata added)", file: csprojPath };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async applyRemote(entry, ctx, opts = {}) {
|
|
82
|
+
const csprojPath = await this.#findCsprojRemote(entry);
|
|
83
|
+
if (!csprojPath) return { changed: false };
|
|
84
|
+
|
|
85
|
+
const remote = readRemoteFile(entry.repo, csprojPath);
|
|
86
|
+
if (!remote) return { changed: false };
|
|
87
|
+
|
|
88
|
+
const repoUrl = `https://github.com/${entry.repo}`;
|
|
89
|
+
const projectUrl = `${repoUrl}#readme`;
|
|
90
|
+
const newContent = this.#fixCsproj(remote.content, repoUrl, projectUrl);
|
|
91
|
+
|
|
92
|
+
if (newContent === remote.content) return { changed: false };
|
|
93
|
+
|
|
94
|
+
const ok = writeRemoteFile(
|
|
95
|
+
entry.repo, csprojPath, newContent, remote.sha,
|
|
96
|
+
`chore: add NuGet metadata to ${csprojPath}`
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return { changed: ok, before: "(missing metadata)", after: "(metadata added)", file: csprojPath };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Private ──────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/** Find a .csproj file locally that matches the NuGet package name. */
|
|
105
|
+
#findCsprojLocal(cwd, packageName) {
|
|
106
|
+
// Look for <PackageName>.csproj or any .csproj in cwd and one level down
|
|
107
|
+
const candidates = [];
|
|
108
|
+
|
|
109
|
+
// Direct match
|
|
110
|
+
const directPath = join(cwd, `${packageName}.csproj`);
|
|
111
|
+
if (existsSync(directPath)) return directPath;
|
|
112
|
+
|
|
113
|
+
// Search cwd for any .csproj
|
|
114
|
+
try {
|
|
115
|
+
const files = readdirSync(cwd).filter(f => f.endsWith(".csproj"));
|
|
116
|
+
for (const f of files) candidates.push(join(cwd, f));
|
|
117
|
+
} catch { /* ignore */ }
|
|
118
|
+
|
|
119
|
+
// Search src/ directory
|
|
120
|
+
const srcDir = join(cwd, "src");
|
|
121
|
+
if (existsSync(srcDir)) {
|
|
122
|
+
try {
|
|
123
|
+
for (const dir of readdirSync(srcDir, { withFileTypes: true })) {
|
|
124
|
+
if (dir.isDirectory()) {
|
|
125
|
+
const subPath = join(srcDir, dir.name);
|
|
126
|
+
const subFiles = readdirSync(subPath).filter(f => f.endsWith(".csproj"));
|
|
127
|
+
for (const f of subFiles) candidates.push(join(subPath, f));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch { /* ignore */ }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Prefer a csproj whose name matches the package name
|
|
134
|
+
const match = candidates.find(p => {
|
|
135
|
+
const name = p.split(/[\\/]/).pop().replace(".csproj", "");
|
|
136
|
+
return name.toLowerCase() === packageName.toLowerCase() ||
|
|
137
|
+
name.toLowerCase() === packageName.split(".").pop().toLowerCase();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return match ?? candidates[0] ?? null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Find csproj remotely by checking common paths. */
|
|
144
|
+
async #findCsprojRemote(entry) {
|
|
145
|
+
const { execSync } = await import("node:child_process");
|
|
146
|
+
try {
|
|
147
|
+
// List repo root for .csproj files
|
|
148
|
+
const raw = execSync(
|
|
149
|
+
`gh api "repos/${entry.repo}/git/trees/HEAD?recursive=1" --jq "[.tree[].path | select(test(\"\\\\.csproj$\"))]"`,
|
|
150
|
+
{ encoding: "utf8", timeout: 15_000, stdio: ["pipe", "pipe", "pipe"] }
|
|
151
|
+
);
|
|
152
|
+
const paths = JSON.parse(raw);
|
|
153
|
+
if (paths.length === 0) return null;
|
|
154
|
+
|
|
155
|
+
// Prefer one matching the package name
|
|
156
|
+
const match = paths.find(p => {
|
|
157
|
+
const name = p.split("/").pop().replace(".csproj", "");
|
|
158
|
+
return name.toLowerCase() === entry.name.toLowerCase() ||
|
|
159
|
+
name.toLowerCase() === entry.name.split(".").pop().toLowerCase();
|
|
160
|
+
});
|
|
161
|
+
return match ?? paths[0];
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Check which metadata elements are missing. */
|
|
168
|
+
#analyzeCsproj(content, repoUrl, projectUrl) {
|
|
169
|
+
const missing = [];
|
|
170
|
+
if (!content.includes("<PackageProjectUrl>")) missing.push("PackageProjectUrl");
|
|
171
|
+
if (!content.includes("<RepositoryUrl>")) missing.push("RepositoryUrl");
|
|
172
|
+
return missing;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Insert missing metadata into the first <PropertyGroup>. */
|
|
176
|
+
#fixCsproj(content, repoUrl, projectUrl) {
|
|
177
|
+
let result = content;
|
|
178
|
+
|
|
179
|
+
// Find the first <PropertyGroup> closing or a line before </PropertyGroup>
|
|
180
|
+
const pgMatch = result.match(/<PropertyGroup[^>]*>/);
|
|
181
|
+
if (!pgMatch) return result; // Can't safely edit without PropertyGroup
|
|
182
|
+
|
|
183
|
+
const insertIdx = result.indexOf(pgMatch[0]) + pgMatch[0].length;
|
|
184
|
+
|
|
185
|
+
const additions = [];
|
|
186
|
+
if (!result.includes("<PackageProjectUrl>")) {
|
|
187
|
+
additions.push(` <PackageProjectUrl>${projectUrl}</PackageProjectUrl>`);
|
|
188
|
+
}
|
|
189
|
+
if (!result.includes("<RepositoryUrl>")) {
|
|
190
|
+
additions.push(` <RepositoryUrl>${repoUrl}.git</RepositoryUrl>`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (additions.length === 0) return result;
|
|
194
|
+
|
|
195
|
+
const insertBlock = "\n" + additions.join("\n");
|
|
196
|
+
result = result.slice(0, insertIdx) + insertBlock + result.slice(insertIdx);
|
|
197
|
+
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixer: readme-header — ensures README.md has a logo and site link.
|
|
3
|
+
* Matches audit finding code: "missing-readme"
|
|
4
|
+
*
|
|
5
|
+
* Ported from scripts/storefront-fix.mjs (GitHub API version) to
|
|
6
|
+
* support both local and remote modes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { Fixer } from "../fixer.mjs";
|
|
12
|
+
import { readRemoteFile, writeRemoteFile } from "./_npm-helpers.mjs";
|
|
13
|
+
|
|
14
|
+
const SITE_URL = "https://mcptoolshop.com";
|
|
15
|
+
const LOGO_PATHS = ["logo.png", "logo.svg", "assets/logo.png", "assets/logo.svg"];
|
|
16
|
+
|
|
17
|
+
export default class ReadmeHeaderFixer extends Fixer {
|
|
18
|
+
get code() { return "readme-header"; }
|
|
19
|
+
get target() { return "readme"; }
|
|
20
|
+
|
|
21
|
+
canFix(finding) {
|
|
22
|
+
return finding.code === "missing-readme";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe() {
|
|
26
|
+
return "Add logo and site link to README.md header";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async diagnose(entry, ctx, opts = {}) {
|
|
30
|
+
if (opts.remote) {
|
|
31
|
+
const remote = readRemoteFile(entry.repo, "README.md");
|
|
32
|
+
if (!remote) return { needed: true, before: null, after: "README.md with header", file: "README.md" };
|
|
33
|
+
return this.#analyzeContent(remote.content, entry);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
37
|
+
const readmePath = join(cwd, "README.md");
|
|
38
|
+
if (!existsSync(readmePath)) {
|
|
39
|
+
return { needed: true, before: null, after: "README.md with header", file: "README.md" };
|
|
40
|
+
}
|
|
41
|
+
const content = readFileSync(readmePath, "utf8");
|
|
42
|
+
return this.#analyzeContent(content, entry);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async applyLocal(entry, ctx, opts = {}) {
|
|
46
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
47
|
+
const readmePath = join(cwd, "README.md");
|
|
48
|
+
if (!existsSync(readmePath)) return { changed: false };
|
|
49
|
+
|
|
50
|
+
const content = readFileSync(readmePath, "utf8");
|
|
51
|
+
const newContent = this.#fixContent(content, entry, cwd);
|
|
52
|
+
|
|
53
|
+
if (newContent === content) return { changed: false };
|
|
54
|
+
|
|
55
|
+
writeFileSync(readmePath, newContent);
|
|
56
|
+
return { changed: true, before: "(header missing)", after: "(header added)", file: "README.md" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async applyRemote(entry, ctx, opts = {}) {
|
|
60
|
+
const remote = readRemoteFile(entry.repo, "README.md");
|
|
61
|
+
if (!remote) return { changed: false };
|
|
62
|
+
|
|
63
|
+
const newContent = this.#fixContent(remote.content, entry);
|
|
64
|
+
if (newContent === remote.content) return { changed: false };
|
|
65
|
+
|
|
66
|
+
const ok = writeRemoteFile(
|
|
67
|
+
entry.repo, "README.md",
|
|
68
|
+
newContent, remote.sha,
|
|
69
|
+
`chore: add logo and MCP Tool Shop link to README`
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return { changed: ok, before: "(header missing)", after: "(header added)", file: "README.md" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Private ──────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
#analyzeContent(content, entry) {
|
|
78
|
+
const firstLines = content.split("\n").slice(0, 10).join("\n");
|
|
79
|
+
const hasLogo = firstLines.includes("![") || firstLines.includes("<img");
|
|
80
|
+
const hasSiteLink = content.includes(SITE_URL) || content.includes("mcptoolshop.com") || content.includes("MCP Tool Shop");
|
|
81
|
+
|
|
82
|
+
if (hasLogo && hasSiteLink) return { needed: false };
|
|
83
|
+
|
|
84
|
+
const changes = [];
|
|
85
|
+
if (!hasLogo) changes.push("logo");
|
|
86
|
+
if (!hasSiteLink) changes.push("site link");
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
needed: true,
|
|
90
|
+
before: `Missing: ${changes.join(", ")}`,
|
|
91
|
+
after: "Logo + site link in header",
|
|
92
|
+
file: "README.md",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#fixContent(content, entry, cwd = null) {
|
|
97
|
+
let result = content;
|
|
98
|
+
const repoName = entry.repo.split("/")[1];
|
|
99
|
+
|
|
100
|
+
// Detect existing logo path
|
|
101
|
+
let logoPath = "logo.png";
|
|
102
|
+
if (cwd) {
|
|
103
|
+
for (const p of LOGO_PATHS) {
|
|
104
|
+
if (existsSync(join(cwd, p))) {
|
|
105
|
+
logoPath = p;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check if logo is in the first 10 lines
|
|
112
|
+
const firstLines = result.split("\n").slice(0, 10).join("\n");
|
|
113
|
+
const hasLogo = firstLines.includes("![") || firstLines.includes("<img");
|
|
114
|
+
|
|
115
|
+
if (!hasLogo) {
|
|
116
|
+
const logoHeader = `<p align="center">\n <img src="${logoPath}" width="200" alt="${repoName}">\n</p>\n\n`;
|
|
117
|
+
result = logoHeader + result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check for site link
|
|
121
|
+
const hasSiteLink = result.includes(SITE_URL) || result.includes("mcptoolshop.com") || result.includes("MCP Tool Shop");
|
|
122
|
+
|
|
123
|
+
if (!hasSiteLink) {
|
|
124
|
+
const headingMatch = result.match(/^#\s+.+$/m);
|
|
125
|
+
if (headingMatch) {
|
|
126
|
+
const idx = result.indexOf(headingMatch[0]) + headingMatch[0].length;
|
|
127
|
+
const catalogLine = `\n\n> Part of [MCP Tool Shop](${SITE_URL})`;
|
|
128
|
+
result = result.slice(0, idx) + catalogLine + result.slice(idx);
|
|
129
|
+
} else {
|
|
130
|
+
result = result + `\n\n> Part of [MCP Tool Shop](${SITE_URL})\n`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixer registry — auto-discovers and validates fixer modules.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the pattern from scripts/lib/registry.mjs (provider auto-discovery).
|
|
5
|
+
* Scans src/fixers/fixers/*.mjs, validates each extends Fixer, returns instances.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdirSync } from "node:fs";
|
|
9
|
+
import { join, dirname } from "node:path";
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
+
import { Fixer } from "./fixer.mjs";
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const FIXERS_DIR = join(__dirname, "fixers");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Load all fixer modules from the fixers/ directory.
|
|
18
|
+
* Skips files prefixed with `_` (helpers).
|
|
19
|
+
*
|
|
20
|
+
* @returns {Promise<Fixer[]>} Array of fixer instances
|
|
21
|
+
*/
|
|
22
|
+
export async function loadFixers() {
|
|
23
|
+
const files = readdirSync(FIXERS_DIR)
|
|
24
|
+
.filter(f => f.endsWith(".mjs") && !f.startsWith("_"))
|
|
25
|
+
.sort();
|
|
26
|
+
|
|
27
|
+
const fixers = [];
|
|
28
|
+
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
const url = pathToFileURL(join(FIXERS_DIR, file)).href;
|
|
31
|
+
const mod = await import(url);
|
|
32
|
+
const FixerClass = mod.default;
|
|
33
|
+
|
|
34
|
+
if (!FixerClass) {
|
|
35
|
+
throw new Error(`Fixer module ${file} has no default export`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const instance = new FixerClass();
|
|
39
|
+
|
|
40
|
+
if (!(instance instanceof Fixer)) {
|
|
41
|
+
throw new Error(`Fixer module ${file} does not extend Fixer`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Validate required overrides
|
|
45
|
+
try {
|
|
46
|
+
instance.code;
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error(`Fixer ${file} does not override 'code'`);
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
instance.target;
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error(`Fixer ${file} does not override 'target'`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fixers.push(instance);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return fixers;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Find fixers that can handle a given audit finding.
|
|
64
|
+
*
|
|
65
|
+
* @param {Fixer[]} fixers - All loaded fixers
|
|
66
|
+
* @param {object} finding - { severity, code, msg }
|
|
67
|
+
* @returns {Fixer[]} Matching fixers
|
|
68
|
+
*/
|
|
69
|
+
export function matchFixers(fixers, finding) {
|
|
70
|
+
return fixers.filter(f => f.canFix(finding));
|
|
71
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin loader — discovers and loads optional plugin packages.
|
|
3
|
+
*
|
|
4
|
+
* Pattern: known plugin names → npm package names.
|
|
5
|
+
* If the package is installed, import and return it.
|
|
6
|
+
* If not, return null (caller shows install hint).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const KNOWN_PLUGINS = {
|
|
10
|
+
assets: "@mcptoolshop/mcpt-publishing-assets",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Attempt to load a known plugin.
|
|
15
|
+
* @param {string} name — "assets"
|
|
16
|
+
* @returns {Promise<object|null>} The plugin module, or null if not installed.
|
|
17
|
+
*/
|
|
18
|
+
export async function loadPlugin(name) {
|
|
19
|
+
const pkgName = KNOWN_PLUGINS[name];
|
|
20
|
+
if (!pkgName) return null;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
return await import(pkgName);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
if (e.code === "ERR_MODULE_NOT_FOUND" || e.code === "MODULE_NOT_FOUND") {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
throw e; // Re-throw unexpected errors
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get the install command hint for a plugin.
|
|
34
|
+
* @param {string} name — "assets"
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function installHint(name) {
|
|
38
|
+
const pkgName = KNOWN_PLUGINS[name];
|
|
39
|
+
return pkgName ? `npm i -D ${pkgName}` : `Unknown plugin: ${name}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* List all known plugins.
|
|
44
|
+
* @returns {Array<{ name: string, package: string }>}
|
|
45
|
+
*/
|
|
46
|
+
export function listPlugins() {
|
|
47
|
+
return Object.entries(KNOWN_PLUGINS).map(([name, pkg]) => ({ name, package: pkg }));
|
|
48
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix receipt builder — emits a receipt after each fix run.
|
|
3
|
+
*
|
|
4
|
+
* Path: receipts/fix/<YYYY-MM-DD>-<slug>.json
|
|
5
|
+
* Date-keyed with repo slug: overwrites within the same day for same repo.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { updateFixEntry } from "./index-writer.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build and write a fix receipt.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} config - Resolved config (needs config.receiptsDir)
|
|
16
|
+
* @param {object} fixResult - Fix results:
|
|
17
|
+
* { repo, mode, dryRun, changes[], auditBefore, auditAfter, commitSha?, prUrl?, branchName?, fileHashes? }
|
|
18
|
+
* @returns {string} Path to the written receipt file
|
|
19
|
+
*/
|
|
20
|
+
export function emitFixReceipt(config, fixResult) {
|
|
21
|
+
const fixDir = join(config.receiptsDir, "fix");
|
|
22
|
+
mkdirSync(fixDir, { recursive: true });
|
|
23
|
+
|
|
24
|
+
const receipt = {
|
|
25
|
+
schemaVersion: "1.0.0",
|
|
26
|
+
type: "fix",
|
|
27
|
+
timestamp: new Date().toISOString(),
|
|
28
|
+
repo: fixResult.repo ?? "*",
|
|
29
|
+
mode: fixResult.mode,
|
|
30
|
+
dryRun: fixResult.dryRun ?? false,
|
|
31
|
+
prUrl: fixResult.prUrl ?? null,
|
|
32
|
+
branchName: fixResult.branchName ?? null,
|
|
33
|
+
changes: fixResult.changes ?? [],
|
|
34
|
+
auditBefore: fixResult.auditBefore ?? null,
|
|
35
|
+
auditAfter: fixResult.auditAfter ?? null,
|
|
36
|
+
commitSha: fixResult.commitSha ?? null,
|
|
37
|
+
fileHashes: fixResult.fileHashes ?? {},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const date = receipt.timestamp.slice(0, 10); // YYYY-MM-DD
|
|
41
|
+
const slug = (fixResult.repo ?? "fleet").replace(/\//g, "--");
|
|
42
|
+
const filePath = join(fixDir, `${date}-${slug}.json`);
|
|
43
|
+
writeFileSync(filePath, JSON.stringify(receipt, null, 2) + "\n");
|
|
44
|
+
|
|
45
|
+
// Update the receipts index
|
|
46
|
+
updateFixEntry(config.receiptsDir, receipt);
|
|
47
|
+
|
|
48
|
+
return filePath;
|
|
49
|
+
}
|
|
@@ -48,6 +48,27 @@ export function updatePublishEntry(receiptsDir, publishReceipt) {
|
|
|
48
48
|
saveIndex(receiptsDir, index);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Update the index with a fix receipt entry.
|
|
53
|
+
* @param {string} receiptsDir - Absolute path to receipts directory
|
|
54
|
+
* @param {object} fixReceipt - A fix receipt object
|
|
55
|
+
*/
|
|
56
|
+
export function updateFixEntry(receiptsDir, fixReceipt) {
|
|
57
|
+
const index = loadIndex(receiptsDir);
|
|
58
|
+
|
|
59
|
+
if (!index.fix) index.fix = {};
|
|
60
|
+
|
|
61
|
+
const key = fixReceipt.repo ?? "fleet";
|
|
62
|
+
index.fix[key] = {
|
|
63
|
+
timestamp: fixReceipt.timestamp,
|
|
64
|
+
mode: fixReceipt.mode,
|
|
65
|
+
changesCount: fixReceipt.changes?.length ?? 0,
|
|
66
|
+
dryRun: fixReceipt.dryRun ?? false,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
saveIndex(receiptsDir, index);
|
|
70
|
+
}
|
|
71
|
+
|
|
51
72
|
// ─── Internal ────────────────────────────────────────────────────────────────
|
|
52
73
|
|
|
53
74
|
function loadIndex(receiptsDir) {
|