@mcptoolshop/mcpt-publishing 0.2.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 +36 -0
- package/LICENSE +21 -0
- package/README.md +237 -0
- package/bin/mcpt-publishing.mjs +3 -0
- package/docs/CONTRACT.md +109 -0
- package/logo.png +0 -0
- package/package.json +51 -0
- package/profiles/example.json +23 -0
- package/profiles/manifest.json +49 -0
- package/schemas/audit-receipt.schema.json +52 -0
- package/schemas/profile.schema.json +71 -0
- package/schemas/publishing-config.schema.json +50 -0
- package/schemas/receipt.schema.json +91 -0
- package/scripts/lib/github-glue.mjs +101 -0
- package/scripts/lib/provider.mjs +63 -0
- package/scripts/lib/providers/ghcr.mjs +112 -0
- package/scripts/lib/providers/github.mjs +52 -0
- package/scripts/lib/providers/npm.mjs +178 -0
- package/scripts/lib/providers/nuget.mjs +198 -0
- package/scripts/lib/providers/pypi.mjs +102 -0
- package/scripts/lib/receipt-writer.mjs +123 -0
- package/scripts/lib/registry.mjs +65 -0
- package/scripts/lib/shell.mjs +64 -0
- package/src/cli/exit-codes.mjs +17 -0
- package/src/cli/help.mjs +38 -0
- package/src/cli/router.mjs +121 -0
- package/src/commands/audit.mjs +238 -0
- package/src/commands/init.mjs +107 -0
- package/src/commands/plan.mjs +36 -0
- package/src/commands/providers.mjs +81 -0
- package/src/commands/publish.mjs +215 -0
- package/src/commands/verify-receipt.mjs +175 -0
- package/src/config/defaults.mjs +14 -0
- package/src/config/loader.mjs +86 -0
- package/src/config/schema.mjs +70 -0
- package/src/receipts/audit-receipt.mjs +53 -0
- package/src/receipts/index-writer.mjs +65 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NuGet provider — audit + publish.
|
|
3
|
+
*
|
|
4
|
+
* Audit: fetches metadata via NuGet search API + flat-container,
|
|
5
|
+
* detects indexing lag, and classifies drift against git state.
|
|
6
|
+
* Publish: packs .nupkg, computes SHA-256, pushes to nuget.org.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
import { readdirSync, rmSync, existsSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { Provider } from "../provider.mjs";
|
|
13
|
+
import { exec, hashFile } from "../shell.mjs";
|
|
14
|
+
|
|
15
|
+
export default class NuGetProvider extends Provider {
|
|
16
|
+
get name() { return "nuget"; }
|
|
17
|
+
|
|
18
|
+
detect(entry) {
|
|
19
|
+
return entry.ecosystem === "nuget";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async audit(entry, ctx) {
|
|
23
|
+
const meta = this.#fetchMeta(entry.name);
|
|
24
|
+
const flatVersions = this.#fetchVersions(entry.name);
|
|
25
|
+
const tags = ctx.tags.get(entry.repo) ?? [];
|
|
26
|
+
const releases = ctx.releases.get(entry.repo) ?? [];
|
|
27
|
+
|
|
28
|
+
const version = flatVersions.length > 0
|
|
29
|
+
? flatVersions[flatVersions.length - 1]
|
|
30
|
+
: (meta?.version ?? "?");
|
|
31
|
+
|
|
32
|
+
const findings = this.#classify(entry, meta, tags, releases, flatVersions);
|
|
33
|
+
return { version, findings };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Publish a NuGet package.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} entry - { name, repo, audience, ecosystem }
|
|
40
|
+
* @param {object} opts - { dryRun: boolean, cwd: string }
|
|
41
|
+
* @returns {Promise<{ success: boolean, version: string, artifacts: Array, error?: string }>}
|
|
42
|
+
*/
|
|
43
|
+
async publish(entry, opts = {}) {
|
|
44
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
45
|
+
|
|
46
|
+
// Check credentials (skip in dry-run — dotnet pack doesn't need auth)
|
|
47
|
+
if (!process.env.NUGET_API_KEY && !opts.dryRun) {
|
|
48
|
+
return { success: false, version: "", artifacts: [], error: "NUGET_API_KEY environment variable is not set" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const outputDir = join(cwd, "nupkg-output");
|
|
52
|
+
|
|
53
|
+
// dotnet pack
|
|
54
|
+
const packResult = exec("dotnet pack -c Release -o nupkg-output", { cwd });
|
|
55
|
+
if (packResult.exitCode !== 0) {
|
|
56
|
+
return { success: false, version: "", artifacts: [], error: `dotnet pack failed: ${packResult.stderr}` };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Find the .nupkg matching the package name
|
|
60
|
+
if (!existsSync(outputDir)) {
|
|
61
|
+
return { success: false, version: "", artifacts: [], error: "nupkg-output directory not created by dotnet pack" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const nupkgFiles = readdirSync(outputDir).filter(f => f.endsWith(".nupkg") && !f.endsWith(".symbols.nupkg"));
|
|
65
|
+
const targetNupkg = nupkgFiles.find(f => f.toLowerCase().startsWith(entry.name.toLowerCase() + "."));
|
|
66
|
+
|
|
67
|
+
if (!targetNupkg) {
|
|
68
|
+
// Clean up
|
|
69
|
+
try { rmSync(outputDir, { recursive: true }); } catch { /* ignore */ }
|
|
70
|
+
return {
|
|
71
|
+
success: false, version: "", artifacts: [],
|
|
72
|
+
error: `No .nupkg found matching "${entry.name}" in nupkg-output/ (found: ${nupkgFiles.join(", ") || "none"})`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Extract version from filename: PackageName.1.2.3.nupkg
|
|
77
|
+
const nupkgBasename = targetNupkg.replace(/\.nupkg$/, "");
|
|
78
|
+
const version = nupkgBasename.slice(entry.name.length + 1); // +1 for the dot
|
|
79
|
+
|
|
80
|
+
if (!version) {
|
|
81
|
+
try { rmSync(outputDir, { recursive: true }); } catch { /* ignore */ }
|
|
82
|
+
return { success: false, version: "", artifacts: [], error: `Could not parse version from ${targetNupkg}` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Compute SHA-256
|
|
86
|
+
const nupkgPath = join(outputDir, targetNupkg);
|
|
87
|
+
const { sha256, size } = hashFile(nupkgPath);
|
|
88
|
+
|
|
89
|
+
// Push (or dry-run)
|
|
90
|
+
if (!opts.dryRun) {
|
|
91
|
+
const pushResult = exec(
|
|
92
|
+
`dotnet nuget push "${nupkgPath}" --api-key "${process.env.NUGET_API_KEY}" --source https://api.nuget.org/v3/index.json --skip-duplicate`,
|
|
93
|
+
{ cwd }
|
|
94
|
+
);
|
|
95
|
+
if (pushResult.exitCode !== 0) {
|
|
96
|
+
try { rmSync(outputDir, { recursive: true }); } catch { /* ignore */ }
|
|
97
|
+
return { success: false, version, artifacts: [], error: `dotnet nuget push failed: ${pushResult.stderr}` };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Clean up
|
|
102
|
+
try { rmSync(outputDir, { recursive: true }); } catch { /* ignore */ }
|
|
103
|
+
|
|
104
|
+
// Build artifact metadata
|
|
105
|
+
const artifact = {
|
|
106
|
+
name: targetNupkg,
|
|
107
|
+
sha256,
|
|
108
|
+
size,
|
|
109
|
+
url: `https://www.nuget.org/packages/${entry.name}/${version}`,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return { success: true, version, artifacts: [artifact] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Private ───────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
#fetchMeta(id) {
|
|
118
|
+
try {
|
|
119
|
+
const url = `https://azuresearch-usnc.nuget.org/query?q=packageid:${id}&take=1`;
|
|
120
|
+
const raw = execSync(`curl -sf "${url}"`, { encoding: "utf8", timeout: 15_000 });
|
|
121
|
+
const data = JSON.parse(raw);
|
|
122
|
+
return data.data?.[0] ?? null;
|
|
123
|
+
} catch { return null; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Flat container returns actual published versions (updates faster than search). */
|
|
127
|
+
#fetchVersions(id) {
|
|
128
|
+
try {
|
|
129
|
+
const url = `https://api.nuget.org/v3-flatcontainer/${id.toLowerCase()}/index.json`;
|
|
130
|
+
const raw = execSync(`curl -sf "${url}"`, { encoding: "utf8", timeout: 15_000 });
|
|
131
|
+
return JSON.parse(raw).versions ?? [];
|
|
132
|
+
} catch { return []; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#classify(pkg, meta, tags, releases, flatVersions) {
|
|
136
|
+
const findings = [];
|
|
137
|
+
|
|
138
|
+
if (!meta) {
|
|
139
|
+
findings.push({ severity: "RED", code: "nuget-unreachable", msg: `Cannot reach ${pkg.name} on NuGet` });
|
|
140
|
+
return findings;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const searchVer = meta.version;
|
|
144
|
+
const latestFlat = flatVersions.length > 0 ? flatVersions[flatVersions.length - 1] : null;
|
|
145
|
+
|
|
146
|
+
// Detect indexing lag: flat container knows about a newer version than the search API
|
|
147
|
+
const indexingLag = latestFlat && latestFlat !== searchVer;
|
|
148
|
+
const ver = latestFlat ?? searchVer;
|
|
149
|
+
const tagName = `v${ver}`;
|
|
150
|
+
|
|
151
|
+
// Published-but-not-tagged
|
|
152
|
+
if (!tags.includes(tagName)) {
|
|
153
|
+
findings.push({ severity: "RED", code: "published-not-tagged", msg: `${pkg.name}@${ver} — no git tag ${tagName}` });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ProjectUrl — suppress during indexing lag (metadata not propagated yet)
|
|
157
|
+
if (!meta.projectUrl && !indexingLag) {
|
|
158
|
+
if (pkg.audience === "front-door") {
|
|
159
|
+
findings.push({ severity: "YELLOW", code: "missing-project-url", msg: `${pkg.name} has no projectUrl on NuGet` });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Icon — suppress during indexing lag
|
|
164
|
+
if (!meta.iconUrl && pkg.audience === "front-door" && !indexingLag) {
|
|
165
|
+
findings.push({ severity: "YELLOW", code: "missing-icon", msg: `${pkg.name} (front-door) has no icon` });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Indexing lag notice (non-failing)
|
|
169
|
+
if (indexingLag) {
|
|
170
|
+
findings.push({
|
|
171
|
+
severity: "INFO",
|
|
172
|
+
code: "pending-index",
|
|
173
|
+
msg: `${pkg.name} v${latestFlat} published but search API still shows v${searchVer} — retry in 60-120 min`
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Description
|
|
178
|
+
if (!meta.description) {
|
|
179
|
+
findings.push({ severity: "GRAY", code: "missing-description", msg: `${pkg.name} has no description` });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return findings;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
receipt(result) {
|
|
186
|
+
const [owner, name] = result.repo.split("/");
|
|
187
|
+
return {
|
|
188
|
+
schemaVersion: "1.0.0",
|
|
189
|
+
repo: { owner, name },
|
|
190
|
+
target: "nuget",
|
|
191
|
+
version: result.version,
|
|
192
|
+
packageName: result.name,
|
|
193
|
+
commitSha: result.commitSha,
|
|
194
|
+
timestamp: new Date().toISOString(),
|
|
195
|
+
artifacts: result.artifacts ?? [],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PyPI provider — audits Python packages on pypi.org.
|
|
3
|
+
*
|
|
4
|
+
* Uses the PyPI JSON API: https://pypi.org/pypi/<pkg>/json
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
import { Provider } from "../provider.mjs";
|
|
9
|
+
|
|
10
|
+
export default class PyPIProvider extends Provider {
|
|
11
|
+
get name() { return "pypi"; }
|
|
12
|
+
|
|
13
|
+
detect(entry) {
|
|
14
|
+
return entry.ecosystem === "pypi";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async audit(entry, ctx) {
|
|
18
|
+
const meta = this.#fetchMeta(entry.name);
|
|
19
|
+
const tags = ctx.tags.get(entry.repo) ?? [];
|
|
20
|
+
const releases = ctx.releases.get(entry.repo) ?? [];
|
|
21
|
+
|
|
22
|
+
if (!meta) {
|
|
23
|
+
return {
|
|
24
|
+
version: "?",
|
|
25
|
+
findings: [{ severity: "RED", code: "pypi-unreachable", msg: `Cannot reach ${entry.name} on PyPI` }],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const version = meta.info?.version ?? "?";
|
|
30
|
+
const findings = this.#classify(entry, meta, version, tags, releases);
|
|
31
|
+
return { version, findings };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Private ───────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
#fetchMeta(pkg) {
|
|
37
|
+
try {
|
|
38
|
+
const url = `https://pypi.org/pypi/${pkg}/json`;
|
|
39
|
+
const raw = execSync(`curl -sf "${url}"`, { encoding: "utf8", timeout: 15_000 });
|
|
40
|
+
return JSON.parse(raw);
|
|
41
|
+
} catch { return null; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#classify(entry, meta, version, tags, releases) {
|
|
45
|
+
const findings = [];
|
|
46
|
+
const tagName = `v${version}`;
|
|
47
|
+
|
|
48
|
+
// Published-but-not-tagged (RED)
|
|
49
|
+
if (!tags.includes(tagName)) {
|
|
50
|
+
findings.push({
|
|
51
|
+
severity: "RED",
|
|
52
|
+
code: "published-not-tagged",
|
|
53
|
+
msg: `${entry.name}@${version} — no git tag ${tagName}`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Tagged-but-not-released (YELLOW for front-door)
|
|
58
|
+
if (tags.includes(tagName) && !releases.includes(tagName) && entry.audience === "front-door") {
|
|
59
|
+
findings.push({
|
|
60
|
+
severity: "YELLOW",
|
|
61
|
+
code: "tagged-not-released",
|
|
62
|
+
msg: `${entry.name} tag ${tagName} has no GitHub Release`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Description / summary
|
|
67
|
+
const summary = meta.info?.summary ?? "";
|
|
68
|
+
if (!summary) {
|
|
69
|
+
findings.push({
|
|
70
|
+
severity: entry.audience === "front-door" ? "YELLOW" : "GRAY",
|
|
71
|
+
code: "missing-description",
|
|
72
|
+
msg: `${entry.name} has no summary on PyPI`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Homepage / project URL
|
|
77
|
+
const projectUrl = meta.info?.home_page || meta.info?.project_urls?.Homepage || "";
|
|
78
|
+
if (!projectUrl) {
|
|
79
|
+
findings.push({
|
|
80
|
+
severity: "GRAY",
|
|
81
|
+
code: "missing-homepage",
|
|
82
|
+
msg: `${entry.name} has no homepage on PyPI`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return findings;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
receipt(result) {
|
|
90
|
+
const [owner, name] = result.repo.split("/");
|
|
91
|
+
return {
|
|
92
|
+
schemaVersion: "1.0.0",
|
|
93
|
+
repo: { owner, name },
|
|
94
|
+
target: "pypi",
|
|
95
|
+
version: result.version,
|
|
96
|
+
packageName: result.name,
|
|
97
|
+
commitSha: result.commitSha,
|
|
98
|
+
timestamp: new Date().toISOString(),
|
|
99
|
+
artifacts: result.artifacts ?? [],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Receipt writer — validates and persists publish receipts.
|
|
3
|
+
*
|
|
4
|
+
* Receipts are immutable once written (append-only directory).
|
|
5
|
+
* Path: receipts/publish/<owner>--<name>/<target>/<version>.json
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
9
|
+
import { join, dirname } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const ROOT = join(__dirname, "..", "..");
|
|
14
|
+
|
|
15
|
+
const VALID_TARGETS = ["npm", "nuget", "pypi", "ghcr"];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate receipt data against the receipt schema (lightweight, no Ajv).
|
|
19
|
+
* @param {object} receipt
|
|
20
|
+
* @throws {Error} on validation failure
|
|
21
|
+
*/
|
|
22
|
+
export function validate(receipt) {
|
|
23
|
+
if (!receipt || typeof receipt !== "object") throw new Error("Receipt must be an object");
|
|
24
|
+
|
|
25
|
+
// Required top-level fields
|
|
26
|
+
const required = ["schemaVersion", "repo", "target", "version", "packageName", "commitSha", "timestamp", "artifacts"];
|
|
27
|
+
for (const field of required) {
|
|
28
|
+
if (!(field in receipt)) throw new Error(`Receipt missing required field: ${field}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// schemaVersion
|
|
32
|
+
if (receipt.schemaVersion !== "1.0.0") {
|
|
33
|
+
throw new Error(`Unknown schemaVersion: ${receipt.schemaVersion}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// repo
|
|
37
|
+
if (typeof receipt.repo !== "object" || !receipt.repo.owner || !receipt.repo.name) {
|
|
38
|
+
throw new Error("repo must have owner and name");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// target
|
|
42
|
+
if (!VALID_TARGETS.includes(receipt.target)) {
|
|
43
|
+
throw new Error(`Invalid target: ${receipt.target} (expected one of ${VALID_TARGETS.join(", ")})`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// version
|
|
47
|
+
if (typeof receipt.version !== "string" || !receipt.version) {
|
|
48
|
+
throw new Error("version must be a non-empty string");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// packageName
|
|
52
|
+
if (typeof receipt.packageName !== "string" || !receipt.packageName) {
|
|
53
|
+
throw new Error("packageName must be a non-empty string");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// commitSha — 40 hex chars
|
|
57
|
+
if (typeof receipt.commitSha !== "string" || !/^[0-9a-f]{40}$/.test(receipt.commitSha)) {
|
|
58
|
+
throw new Error("commitSha must be a 40-character lowercase hex string");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// timestamp — ISO 8601
|
|
62
|
+
if (typeof receipt.timestamp !== "string" || !receipt.timestamp) {
|
|
63
|
+
throw new Error("timestamp must be a non-empty string");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// artifacts
|
|
67
|
+
if (!Array.isArray(receipt.artifacts)) {
|
|
68
|
+
throw new Error("artifacts must be an array");
|
|
69
|
+
}
|
|
70
|
+
for (const art of receipt.artifacts) {
|
|
71
|
+
if (!art.name || typeof art.name !== "string") throw new Error("artifact.name required");
|
|
72
|
+
if (typeof art.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(art.sha256)) {
|
|
73
|
+
throw new Error(`Invalid artifact sha256: ${art.sha256}`);
|
|
74
|
+
}
|
|
75
|
+
if (typeof art.size !== "number" || art.size < 0 || !Number.isInteger(art.size)) {
|
|
76
|
+
throw new Error(`Invalid artifact size: ${art.size}`);
|
|
77
|
+
}
|
|
78
|
+
if (!art.url || typeof art.url !== "string") throw new Error("artifact.url required");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build the filesystem path for a receipt.
|
|
84
|
+
* @param {object} receipt
|
|
85
|
+
* @returns {string} Absolute path
|
|
86
|
+
*/
|
|
87
|
+
function receiptPath(receipt) {
|
|
88
|
+
const slug = `${receipt.repo.owner}--${receipt.repo.name}`;
|
|
89
|
+
return join(ROOT, "receipts", "publish", slug, receipt.target, `${receipt.version}.json`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Write a receipt to the immutable store.
|
|
94
|
+
* @param {object} receipt - Validated receipt data
|
|
95
|
+
* @returns {string} Absolute path of written receipt
|
|
96
|
+
* @throws {Error} if receipt already exists (immutability) or validation fails
|
|
97
|
+
*/
|
|
98
|
+
export function write(receipt) {
|
|
99
|
+
validate(receipt);
|
|
100
|
+
|
|
101
|
+
const filePath = receiptPath(receipt);
|
|
102
|
+
|
|
103
|
+
if (existsSync(filePath)) {
|
|
104
|
+
throw new Error(`Receipt already exists (immutable): ${filePath}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
108
|
+
writeFileSync(filePath, JSON.stringify(receipt, null, 2) + "\n");
|
|
109
|
+
return filePath;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Read an existing receipt.
|
|
114
|
+
* @param {string} repoSlug - "owner--name"
|
|
115
|
+
* @param {string} target - "npm" | "nuget" | "pypi" | "ghcr"
|
|
116
|
+
* @param {string} version
|
|
117
|
+
* @returns {object|null}
|
|
118
|
+
*/
|
|
119
|
+
export function read(repoSlug, target, version) {
|
|
120
|
+
const filePath = join(ROOT, "receipts", "publish", repoSlug, target, `${version}.json`);
|
|
121
|
+
if (!existsSync(filePath)) return null;
|
|
122
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
123
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider registry — auto-discovers and validates providers.
|
|
3
|
+
*
|
|
4
|
+
* Scans scripts/lib/providers/*.mjs, imports each default export,
|
|
5
|
+
* and validates it extends Provider with required method overrides.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readdirSync } from "node:fs";
|
|
9
|
+
import { join, dirname } from "node:path";
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
+
import { Provider } from "./provider.mjs";
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const PROVIDERS_DIR = join(__dirname, "providers");
|
|
15
|
+
|
|
16
|
+
const REQUIRED_METHODS = ["detect", "audit"];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Dynamically import all .mjs files from providers/ directory.
|
|
20
|
+
* Each must export default a class extending Provider.
|
|
21
|
+
* @returns {Promise<Provider[]>}
|
|
22
|
+
*/
|
|
23
|
+
export async function loadProviders() {
|
|
24
|
+
const files = readdirSync(PROVIDERS_DIR).filter(f => f.endsWith(".mjs"));
|
|
25
|
+
const providers = [];
|
|
26
|
+
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
const filePath = join(PROVIDERS_DIR, file);
|
|
29
|
+
const fileUrl = pathToFileURL(filePath).href;
|
|
30
|
+
const mod = await import(fileUrl);
|
|
31
|
+
const Ctor = mod.default;
|
|
32
|
+
|
|
33
|
+
if (!Ctor || typeof Ctor !== "function") {
|
|
34
|
+
throw new Error(`${file}: default export must be a class`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Verify it extends Provider
|
|
38
|
+
if (!(Ctor.prototype instanceof Provider)) {
|
|
39
|
+
throw new Error(`${file}: default export must extend Provider`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const instance = new Ctor();
|
|
43
|
+
|
|
44
|
+
// Validate required methods are overridden (not the base class stubs)
|
|
45
|
+
for (const method of REQUIRED_METHODS) {
|
|
46
|
+
if (instance[method] === Provider.prototype[method]) {
|
|
47
|
+
throw new Error(`${file}: must override ${method}()`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
providers.push(instance);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return providers;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Given an entry, return the providers that claim it via detect().
|
|
59
|
+
* @param {Provider[]} providers
|
|
60
|
+
* @param {object} entry
|
|
61
|
+
* @returns {Provider[]}
|
|
62
|
+
*/
|
|
63
|
+
export function matchProviders(providers, entry) {
|
|
64
|
+
return providers.filter(p => p.detect(entry));
|
|
65
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell utilities for publish providers.
|
|
3
|
+
* Zero dependencies — uses node:child_process, node:crypto, node:fs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync } from "node:child_process";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { readFileSync, statSync } from "node:fs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Execute a command and capture output.
|
|
12
|
+
* Does NOT throw on non-zero exit — caller decides what to do.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} cmd - Shell command to run
|
|
15
|
+
* @param {object} [opts]
|
|
16
|
+
* @param {string} [opts.cwd] - Working directory
|
|
17
|
+
* @param {number} [opts.timeout] - Timeout in ms (default 120s)
|
|
18
|
+
* @param {object} [opts.env] - Extra environment variables (merged with process.env)
|
|
19
|
+
* @returns {{ stdout: string, stderr: string, exitCode: number }}
|
|
20
|
+
*/
|
|
21
|
+
export function exec(cmd, opts = {}) {
|
|
22
|
+
try {
|
|
23
|
+
const stdout = execSync(cmd, {
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
timeout: opts.timeout ?? 120_000,
|
|
26
|
+
cwd: opts.cwd,
|
|
27
|
+
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
|
28
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
29
|
+
});
|
|
30
|
+
return { stdout, stderr: "", exitCode: 0 };
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return {
|
|
33
|
+
stdout: e.stdout ?? "",
|
|
34
|
+
stderr: e.stderr ?? e.message,
|
|
35
|
+
exitCode: e.status ?? 1,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compute SHA-256 hash and size of a file.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} filePath - Absolute path to file
|
|
44
|
+
* @returns {{ sha256: string, size: number }}
|
|
45
|
+
*/
|
|
46
|
+
export function hashFile(filePath) {
|
|
47
|
+
const data = readFileSync(filePath);
|
|
48
|
+
const sha256 = createHash("sha256").update(data).digest("hex");
|
|
49
|
+
const { size } = statSync(filePath);
|
|
50
|
+
return { sha256, size };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get the current HEAD commit SHA (40 lowercase hex chars).
|
|
55
|
+
*
|
|
56
|
+
* @param {string} [cwd] - Working directory (defaults to process.cwd())
|
|
57
|
+
* @returns {string}
|
|
58
|
+
* @throws {Error} if not in a git repo
|
|
59
|
+
*/
|
|
60
|
+
export function getCommitSha(cwd) {
|
|
61
|
+
const { stdout, exitCode } = exec("git rev-parse HEAD", { cwd });
|
|
62
|
+
if (exitCode !== 0) throw new Error("Not in a git repo or git not available");
|
|
63
|
+
return stdout.trim();
|
|
64
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named exit codes for CI-friendly CLI behavior.
|
|
3
|
+
*
|
|
4
|
+
* 0 — success
|
|
5
|
+
* 1 — reserved for uncaught exceptions (Node default)
|
|
6
|
+
* 2 — drift found (audit found RED-severity issues)
|
|
7
|
+
* 3 — config or schema error
|
|
8
|
+
* 4 — missing credentials for a requested operation
|
|
9
|
+
* 5 — one or more publishes failed
|
|
10
|
+
*/
|
|
11
|
+
export const EXIT = {
|
|
12
|
+
SUCCESS: 0,
|
|
13
|
+
DRIFT_FOUND: 2,
|
|
14
|
+
CONFIG_ERROR: 3,
|
|
15
|
+
MISSING_CREDENTIALS: 4,
|
|
16
|
+
PUBLISH_FAILURE: 5,
|
|
17
|
+
};
|
package/src/cli/help.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Help text for the mcpt-publishing CLI.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const GLOBAL_HELP = `
|
|
6
|
+
mcpt-publishing — A human-first publishing house for your repos.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
mcpt-publishing <command> [flags]
|
|
10
|
+
|
|
11
|
+
Commands:
|
|
12
|
+
audit Run publishing health audit across all registries
|
|
13
|
+
init Scaffold publishing.config.json and starter manifest
|
|
14
|
+
publish Publish packages to registries with receipts
|
|
15
|
+
providers List registered providers and their status
|
|
16
|
+
verify-receipt Validate a receipt file (schema + integrity)
|
|
17
|
+
plan Dry-run publish plan [coming soon]
|
|
18
|
+
|
|
19
|
+
Global flags:
|
|
20
|
+
--help Show help for a command
|
|
21
|
+
--version Show version
|
|
22
|
+
--json Machine-readable output (supported by all commands)
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
mcpt-publishing audit # audit all packages
|
|
26
|
+
mcpt-publishing audit --json # JSON output to stdout
|
|
27
|
+
mcpt-publishing publish --target npm --dry-run # dry-run npm publish
|
|
28
|
+
mcpt-publishing verify-receipt receipts/audit/2026-02-17.json
|
|
29
|
+
mcpt-publishing providers # list providers + env vars
|
|
30
|
+
|
|
31
|
+
Environment:
|
|
32
|
+
PUBLISHING_CONFIG Path to publishing.config.json (overrides walk-up discovery)
|
|
33
|
+
GH_TOKEN GitHub token for API access (tags, releases, issues)
|
|
34
|
+
NPM_TOKEN npm publish token (granular, publish rights)
|
|
35
|
+
NUGET_API_KEY NuGet API key
|
|
36
|
+
|
|
37
|
+
Docs: https://github.com/mcp-tool-shop/mcpt-publishing
|
|
38
|
+
`.trim();
|