@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.
@@ -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) {