@mcptoolshop/mcpt-publishing 1.0.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/LICENSE +21 -0
- package/README.md +223 -0
- package/bin/mcpt-publishing.mjs +3 -0
- package/docs/CONTRACT.md +109 -0
- package/logo.png +0 -0
- package/package.json +50 -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 +100 -0
- package/scripts/lib/providers/nuget.mjs +115 -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/src/cli/exit-codes.mjs +15 -0
- package/src/cli/help.mjs +34 -0
- package/src/cli/router.mjs +120 -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 +37 -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,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,15 @@
|
|
|
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
|
+
*/
|
|
10
|
+
export const EXIT = {
|
|
11
|
+
SUCCESS: 0,
|
|
12
|
+
DRIFT_FOUND: 2,
|
|
13
|
+
CONFIG_ERROR: 3,
|
|
14
|
+
MISSING_CREDENTIALS: 4,
|
|
15
|
+
};
|
package/src/cli/help.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Help text for the mcpt-publishing CLI.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const GLOBAL_HELP = `
|
|
6
|
+
mcpt-publishing — Publishing health auditor and receipt factory plugin.
|
|
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
|
+
plan Dry-run publish plan (shows what would happen)
|
|
15
|
+
publish Execute publish and generate receipts
|
|
16
|
+
providers List registered providers and their status
|
|
17
|
+
|
|
18
|
+
Global flags:
|
|
19
|
+
--help Show help for a command
|
|
20
|
+
--version Show version
|
|
21
|
+
--json Machine-readable output (supported by all commands)
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
mcpt-publishing audit # audit all packages, write reports
|
|
25
|
+
mcpt-publishing audit --json # JSON output to stdout
|
|
26
|
+
mcpt-publishing init # scaffold config in current directory
|
|
27
|
+
mcpt-publishing providers # list available providers
|
|
28
|
+
|
|
29
|
+
Environment:
|
|
30
|
+
PUBLISHING_CONFIG Path to publishing.config.json (overrides walk-up discovery)
|
|
31
|
+
GH_TOKEN GitHub token for API access (tags, releases, issues)
|
|
32
|
+
|
|
33
|
+
Docs: https://github.com/mcp-tool-shop/mcpt-publishing
|
|
34
|
+
`.trim();
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI router — zero-dependency subcommand parser and dispatcher.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { join, dirname } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { GLOBAL_HELP } from "./help.mjs";
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
/** Lazy-loaded command map. Keys are subcommand names. */
|
|
13
|
+
const COMMANDS = {
|
|
14
|
+
audit: () => import("../commands/audit.mjs"),
|
|
15
|
+
init: () => import("../commands/init.mjs"),
|
|
16
|
+
plan: () => import("../commands/plan.mjs"),
|
|
17
|
+
publish: () => import("../commands/publish.mjs"),
|
|
18
|
+
providers: () => import("../commands/providers.mjs"),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse CLI flags from an argv slice (after the subcommand).
|
|
23
|
+
*
|
|
24
|
+
* --flag → { flag: true }
|
|
25
|
+
* --key value → { key: "value" }
|
|
26
|
+
* bare → pushed to _positionals
|
|
27
|
+
*
|
|
28
|
+
* @param {string[]} args
|
|
29
|
+
* @returns {object}
|
|
30
|
+
*/
|
|
31
|
+
export function parseFlags(args) {
|
|
32
|
+
const flags = { _positionals: [] };
|
|
33
|
+
for (let i = 0; i < args.length; i++) {
|
|
34
|
+
const arg = args[i];
|
|
35
|
+
if (arg === "--") {
|
|
36
|
+
flags._positionals.push(...args.slice(i + 1));
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
if (arg.startsWith("--")) {
|
|
40
|
+
const key = arg.slice(2);
|
|
41
|
+
const next = args[i + 1];
|
|
42
|
+
if (!next || next.startsWith("--")) {
|
|
43
|
+
flags[key] = true;
|
|
44
|
+
} else {
|
|
45
|
+
flags[key] = next;
|
|
46
|
+
i++;
|
|
47
|
+
}
|
|
48
|
+
} else if (arg.startsWith("-") && arg.length === 2) {
|
|
49
|
+
// Short flag aliases
|
|
50
|
+
const SHORT = { h: "help", v: "version", j: "json" };
|
|
51
|
+
const expanded = SHORT[arg[1]];
|
|
52
|
+
if (expanded) flags[expanded] = true;
|
|
53
|
+
else flags._positionals.push(arg);
|
|
54
|
+
} else {
|
|
55
|
+
flags._positionals.push(arg);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return flags;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Main entry point — parse argv and dispatch to the matching command.
|
|
63
|
+
* @param {string[]} argv - process.argv
|
|
64
|
+
*/
|
|
65
|
+
export async function run(argv) {
|
|
66
|
+
const args = argv.slice(2);
|
|
67
|
+
const subcommand = args[0];
|
|
68
|
+
|
|
69
|
+
// --help / -h at global level (before subcommand lookup)
|
|
70
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
71
|
+
// If a valid subcommand precedes --help, show per-command help
|
|
72
|
+
if (subcommand && COMMANDS[subcommand]) {
|
|
73
|
+
// handled below after flag parsing
|
|
74
|
+
} else {
|
|
75
|
+
process.stdout.write(GLOBAL_HELP + "\n");
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --version at global level
|
|
81
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
82
|
+
const pkgPath = join(__dirname, "..", "..", "package.json");
|
|
83
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
84
|
+
process.stdout.write(pkg.version + "\n");
|
|
85
|
+
process.exit(0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// No subcommand or unknown → global help
|
|
89
|
+
if (!subcommand || !COMMANDS[subcommand]) {
|
|
90
|
+
if (subcommand && !subcommand.startsWith("-") && !COMMANDS[subcommand]) {
|
|
91
|
+
process.stderr.write(`Unknown command: ${subcommand}\n\n`);
|
|
92
|
+
}
|
|
93
|
+
process.stdout.write(GLOBAL_HELP + "\n");
|
|
94
|
+
process.exit(!subcommand || subcommand.startsWith("-") ? 0 : 3);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Parse flags after the subcommand
|
|
98
|
+
const flags = parseFlags(args.slice(1));
|
|
99
|
+
|
|
100
|
+
// Per-command help
|
|
101
|
+
if (flags.help) {
|
|
102
|
+
const mod = await COMMANDS[subcommand]();
|
|
103
|
+
if (mod.helpText) {
|
|
104
|
+
process.stdout.write(mod.helpText + "\n");
|
|
105
|
+
} else {
|
|
106
|
+
process.stdout.write(GLOBAL_HELP + "\n");
|
|
107
|
+
}
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Dispatch
|
|
112
|
+
try {
|
|
113
|
+
const mod = await COMMANDS[subcommand]();
|
|
114
|
+
const exitCode = await mod.execute(flags);
|
|
115
|
+
process.exit(exitCode ?? 0);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
process.stderr.write(`Error: ${e.message}\n`);
|
|
118
|
+
process.exit(3);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mcpt-publishing audit` — run publishing health audit across all registries.
|
|
3
|
+
*
|
|
4
|
+
* Loads config → reads manifest → imports providers from scripts/lib/registry.mjs
|
|
5
|
+
* → runs orchestration loop → writes reports → emits audit receipt.
|
|
6
|
+
*
|
|
7
|
+
* Exit codes:
|
|
8
|
+
* 0 — all clean
|
|
9
|
+
* 2 — RED-severity drift found
|
|
10
|
+
* 3 — config or file error
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
14
|
+
import { join, dirname } from "node:path";
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
|
+
import { loadConfig } from "../config/loader.mjs";
|
|
17
|
+
import { emitAuditReceipt } from "../receipts/audit-receipt.mjs";
|
|
18
|
+
import { EXIT } from "../cli/exit-codes.mjs";
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
|
|
22
|
+
// Ecosystem labels for markdown headings
|
|
23
|
+
const ECOSYSTEM_LABELS = {
|
|
24
|
+
npm: "npm",
|
|
25
|
+
nuget: "NuGet",
|
|
26
|
+
pypi: "PyPI",
|
|
27
|
+
ghcr: "GHCR",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const helpText = `
|
|
31
|
+
mcpt-publishing audit — Run publishing health audit.
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
mcpt-publishing audit [flags]
|
|
35
|
+
|
|
36
|
+
Flags:
|
|
37
|
+
--json Output JSON to stdout (skip markdown reports)
|
|
38
|
+
--config Explicit path to publishing.config.json
|
|
39
|
+
--help Show this help
|
|
40
|
+
|
|
41
|
+
Exit codes:
|
|
42
|
+
0 All packages clean
|
|
43
|
+
2 RED-severity drift detected (CI-friendly non-zero)
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
mcpt-publishing audit # writes reports/latest.md + .json
|
|
47
|
+
mcpt-publishing audit --json # JSON to stdout only
|
|
48
|
+
`.trim();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Execute the audit command.
|
|
52
|
+
* @param {object} flags - Parsed CLI flags
|
|
53
|
+
* @returns {number} Exit code
|
|
54
|
+
*/
|
|
55
|
+
export async function execute(flags) {
|
|
56
|
+
// Load config
|
|
57
|
+
const config = flags.config
|
|
58
|
+
? loadConfig(dirname(flags.config))
|
|
59
|
+
: loadConfig();
|
|
60
|
+
|
|
61
|
+
// Read manifest
|
|
62
|
+
const manifestPath = join(config.profilesDir, "manifest.json");
|
|
63
|
+
if (!existsSync(manifestPath)) {
|
|
64
|
+
process.stderr.write(`Error: Manifest not found at ${manifestPath}\n`);
|
|
65
|
+
process.stderr.write(`Run 'mcpt-publishing init' to scaffold the project.\n`);
|
|
66
|
+
return EXIT.CONFIG_ERROR;
|
|
67
|
+
}
|
|
68
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
69
|
+
|
|
70
|
+
// Import provider registry from scripts/lib (reuse existing working code)
|
|
71
|
+
const registryPath = join(__dirname, "..", "..", "scripts", "lib", "registry.mjs");
|
|
72
|
+
const registryUrl = pathToFileURL(registryPath).href;
|
|
73
|
+
const { loadProviders, matchProviders } = await import(registryUrl);
|
|
74
|
+
|
|
75
|
+
const providers = await loadProviders();
|
|
76
|
+
|
|
77
|
+
// Optional: filter by enabledProviders config
|
|
78
|
+
const enabled = config.enabledProviders ?? [];
|
|
79
|
+
const activeProviders = enabled.length > 0
|
|
80
|
+
? providers.filter(p => enabled.includes(p.name))
|
|
81
|
+
: providers;
|
|
82
|
+
|
|
83
|
+
// Shared context for tag/release caching
|
|
84
|
+
const ctx = {
|
|
85
|
+
tags: new Map(),
|
|
86
|
+
releases: new Map(),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Find the GitHub provider (context loader) — must run before ecosystem providers
|
|
90
|
+
const ghProvider = activeProviders.find(p => p.name === "github");
|
|
91
|
+
|
|
92
|
+
// Build results object with an array per ecosystem key present in the manifest
|
|
93
|
+
const results = {};
|
|
94
|
+
for (const key of Object.keys(manifest)) {
|
|
95
|
+
if (Array.isArray(manifest[key])) results[key] = [];
|
|
96
|
+
}
|
|
97
|
+
results.generated = new Date().toISOString();
|
|
98
|
+
|
|
99
|
+
const allFindings = [];
|
|
100
|
+
|
|
101
|
+
// Process each ecosystem section from the manifest
|
|
102
|
+
for (const [ecosystem, packages] of Object.entries(manifest)) {
|
|
103
|
+
if (!Array.isArray(packages)) continue;
|
|
104
|
+
process.stderr.write(`Auditing ${packages.length} ${ECOSYSTEM_LABELS[ecosystem] ?? ecosystem} packages...\n`);
|
|
105
|
+
|
|
106
|
+
for (const pkg of packages) {
|
|
107
|
+
const entry = { ...pkg, ecosystem };
|
|
108
|
+
|
|
109
|
+
// Ensure GitHub context (tags + releases) is loaded for this repo
|
|
110
|
+
if (ghProvider && ghProvider.detect(entry)) {
|
|
111
|
+
await ghProvider.audit(entry, ctx);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Find the ecosystem-specific provider(s)
|
|
115
|
+
const ecosystemProviders = matchProviders(activeProviders, entry).filter(p => p.name !== "github");
|
|
116
|
+
|
|
117
|
+
let version = "?";
|
|
118
|
+
const findings = [];
|
|
119
|
+
|
|
120
|
+
for (const provider of ecosystemProviders) {
|
|
121
|
+
const result = await provider.audit(entry, ctx);
|
|
122
|
+
if (result.version && result.version !== "?") version = result.version;
|
|
123
|
+
findings.push(...result.findings);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const resultEntry = {
|
|
127
|
+
name: pkg.name,
|
|
128
|
+
version,
|
|
129
|
+
repo: pkg.repo,
|
|
130
|
+
audience: pkg.audience,
|
|
131
|
+
findings,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
if (results[ecosystem]) {
|
|
135
|
+
results[ecosystem].push(resultEntry);
|
|
136
|
+
}
|
|
137
|
+
allFindings.push(...findings.map(f => ({ ...f, pkg: pkg.name, ecosystem })));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Counts
|
|
142
|
+
const red = allFindings.filter(f => f.severity === "RED");
|
|
143
|
+
const yellow = allFindings.filter(f => f.severity === "YELLOW");
|
|
144
|
+
const gray = allFindings.filter(f => f.severity === "GRAY");
|
|
145
|
+
const info = allFindings.filter(f => f.severity === "INFO");
|
|
146
|
+
results.counts = { RED: red.length, YELLOW: yellow.length, GRAY: gray.length, INFO: info.length };
|
|
147
|
+
|
|
148
|
+
// Count total packages
|
|
149
|
+
let totalPackages = 0;
|
|
150
|
+
for (const [, val] of Object.entries(results)) {
|
|
151
|
+
if (Array.isArray(val)) totalPackages += val.length;
|
|
152
|
+
}
|
|
153
|
+
results.totalPackages = totalPackages;
|
|
154
|
+
|
|
155
|
+
// JSON-only mode
|
|
156
|
+
if (flags.json) {
|
|
157
|
+
process.stdout.write(JSON.stringify(results, null, 2) + "\n");
|
|
158
|
+
emitAuditReceipt(config, results);
|
|
159
|
+
return red.length > 0 ? EXIT.DRIFT_FOUND : EXIT.SUCCESS;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Generate markdown report
|
|
163
|
+
const md = buildMarkdownReport(results, manifest, allFindings, red, yellow, gray, info);
|
|
164
|
+
|
|
165
|
+
// Write reports
|
|
166
|
+
mkdirSync(config.reportsDir, { recursive: true });
|
|
167
|
+
writeFileSync(join(config.reportsDir, "latest.md"), md);
|
|
168
|
+
writeFileSync(join(config.reportsDir, "latest.json"), JSON.stringify(results, null, 2));
|
|
169
|
+
|
|
170
|
+
const infoSuffix = info.length > 0 ? ` INFO=${info.length} (indexing — retry later)` : "";
|
|
171
|
+
process.stderr.write(`\nDone. RED=${red.length} YELLOW=${yellow.length} GRAY=${gray.length}${infoSuffix}\n`);
|
|
172
|
+
process.stderr.write(`Reports written to ${config.reportsDir}/latest.md and latest.json\n`);
|
|
173
|
+
|
|
174
|
+
// Emit audit receipt
|
|
175
|
+
emitAuditReceipt(config, results);
|
|
176
|
+
|
|
177
|
+
return red.length > 0 ? EXIT.DRIFT_FOUND : EXIT.SUCCESS;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Internal ────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
function buildMarkdownReport(results, manifest, allFindings, red, yellow, gray, info) {
|
|
183
|
+
const lines = [];
|
|
184
|
+
lines.push("# Publishing Health Report");
|
|
185
|
+
lines.push("");
|
|
186
|
+
lines.push(`> Generated: ${results.generated}`);
|
|
187
|
+
lines.push("");
|
|
188
|
+
const infoLabel = info.length > 0 ? ` | **INFO: ${info.length}** (indexing)` : "";
|
|
189
|
+
lines.push(`**RED: ${red.length}** | **YELLOW: ${yellow.length}** | **GRAY: ${gray.length}**${infoLabel}`);
|
|
190
|
+
lines.push("");
|
|
191
|
+
|
|
192
|
+
if (red.length + yellow.length + info.length > 0) {
|
|
193
|
+
lines.push("## Top Actions");
|
|
194
|
+
lines.push("");
|
|
195
|
+
for (const f of [...red, ...yellow, ...info].slice(0, 10)) {
|
|
196
|
+
lines.push(`- **${f.severity}** ${f.msg}`);
|
|
197
|
+
}
|
|
198
|
+
lines.push("");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Group by package
|
|
202
|
+
const byRepo = {};
|
|
203
|
+
for (const f of allFindings) {
|
|
204
|
+
const key = f.pkg;
|
|
205
|
+
if (!byRepo[key]) byRepo[key] = [];
|
|
206
|
+
byRepo[key].push(f);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (Object.keys(byRepo).length > 0) {
|
|
210
|
+
lines.push("## Findings by Package");
|
|
211
|
+
lines.push("");
|
|
212
|
+
for (const [pkg, findings] of Object.entries(byRepo)) {
|
|
213
|
+
lines.push(`### ${pkg}`);
|
|
214
|
+
for (const f of findings) {
|
|
215
|
+
lines.push(`- **${f.severity}** [${f.code}] ${f.msg}`);
|
|
216
|
+
}
|
|
217
|
+
lines.push("");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Summary tables per ecosystem
|
|
222
|
+
for (const [ecosystem, packages] of Object.entries(manifest)) {
|
|
223
|
+
if (!Array.isArray(packages) || packages.length === 0) continue;
|
|
224
|
+
const label = ECOSYSTEM_LABELS[ecosystem] ?? ecosystem;
|
|
225
|
+
|
|
226
|
+
lines.push(`## ${label} Packages`);
|
|
227
|
+
lines.push("");
|
|
228
|
+
lines.push("| Package | Version | Audience | Issues |");
|
|
229
|
+
lines.push("|---------|---------|----------|--------|");
|
|
230
|
+
for (const e of results[ecosystem] ?? []) {
|
|
231
|
+
const issues = e.findings.length === 0 ? "clean" : e.findings.map(f => f.severity).join(", ");
|
|
232
|
+
lines.push(`| ${e.name} | ${e.version} | ${e.audience} | ${issues} |`);
|
|
233
|
+
}
|
|
234
|
+
lines.push("");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return lines.join("\n");
|
|
238
|
+
}
|