@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.
@@ -0,0 +1,121 @@
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
+ "verify-receipt": () => import("../commands/verify-receipt.mjs"),
20
+ };
21
+
22
+ /**
23
+ * Parse CLI flags from an argv slice (after the subcommand).
24
+ *
25
+ * --flag → { flag: true }
26
+ * --key value → { key: "value" }
27
+ * bare → pushed to _positionals
28
+ *
29
+ * @param {string[]} args
30
+ * @returns {object}
31
+ */
32
+ export function parseFlags(args) {
33
+ const flags = { _positionals: [] };
34
+ for (let i = 0; i < args.length; i++) {
35
+ const arg = args[i];
36
+ if (arg === "--") {
37
+ flags._positionals.push(...args.slice(i + 1));
38
+ break;
39
+ }
40
+ if (arg.startsWith("--")) {
41
+ const key = arg.slice(2);
42
+ const next = args[i + 1];
43
+ if (!next || next.startsWith("--")) {
44
+ flags[key] = true;
45
+ } else {
46
+ flags[key] = next;
47
+ i++;
48
+ }
49
+ } else if (arg.startsWith("-") && arg.length === 2) {
50
+ // Short flag aliases
51
+ const SHORT = { h: "help", v: "version", j: "json" };
52
+ const expanded = SHORT[arg[1]];
53
+ if (expanded) flags[expanded] = true;
54
+ else flags._positionals.push(arg);
55
+ } else {
56
+ flags._positionals.push(arg);
57
+ }
58
+ }
59
+ return flags;
60
+ }
61
+
62
+ /**
63
+ * Main entry point — parse argv and dispatch to the matching command.
64
+ * @param {string[]} argv - process.argv
65
+ */
66
+ export async function run(argv) {
67
+ const args = argv.slice(2);
68
+ const subcommand = args[0];
69
+
70
+ // --help / -h at global level (before subcommand lookup)
71
+ if (args.includes("--help") || args.includes("-h")) {
72
+ // If a valid subcommand precedes --help, show per-command help
73
+ if (subcommand && COMMANDS[subcommand]) {
74
+ // handled below after flag parsing
75
+ } else {
76
+ process.stdout.write(GLOBAL_HELP + "\n");
77
+ process.exit(0);
78
+ }
79
+ }
80
+
81
+ // --version at global level
82
+ if (args.includes("--version") || args.includes("-v")) {
83
+ const pkgPath = join(__dirname, "..", "..", "package.json");
84
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
85
+ process.stdout.write(pkg.version + "\n");
86
+ process.exit(0);
87
+ }
88
+
89
+ // No subcommand or unknown → global help
90
+ if (!subcommand || !COMMANDS[subcommand]) {
91
+ if (subcommand && !subcommand.startsWith("-") && !COMMANDS[subcommand]) {
92
+ process.stderr.write(`Unknown command: ${subcommand}\n\n`);
93
+ }
94
+ process.stdout.write(GLOBAL_HELP + "\n");
95
+ process.exit(!subcommand || subcommand.startsWith("-") ? 0 : 3);
96
+ }
97
+
98
+ // Parse flags after the subcommand
99
+ const flags = parseFlags(args.slice(1));
100
+
101
+ // Per-command help
102
+ if (flags.help) {
103
+ const mod = await COMMANDS[subcommand]();
104
+ if (mod.helpText) {
105
+ process.stdout.write(mod.helpText + "\n");
106
+ } else {
107
+ process.stdout.write(GLOBAL_HELP + "\n");
108
+ }
109
+ process.exit(0);
110
+ }
111
+
112
+ // Dispatch
113
+ try {
114
+ const mod = await COMMANDS[subcommand]();
115
+ const exitCode = await mod.execute(flags);
116
+ process.exit(exitCode ?? 0);
117
+ } catch (e) {
118
+ process.stderr.write(`Error: ${e.message}\n`);
119
+ process.exit(3);
120
+ }
121
+ }
@@ -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
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `mcpt-publishing init` — scaffold publishing config and starter directories.
3
+ *
4
+ * Creates:
5
+ * - publishing.config.json (with $schema pointer)
6
+ * - profiles/manifest.json (empty skeleton)
7
+ * - receipts/ (empty dir)
8
+ * - reports/ (empty dir)
9
+ */
10
+
11
+ import { writeFileSync, mkdirSync, existsSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { EXIT } from "../cli/exit-codes.mjs";
14
+
15
+ export const helpText = `
16
+ mcpt-publishing init — Scaffold publishing config in current directory.
17
+
18
+ Usage:
19
+ mcpt-publishing init [flags]
20
+
21
+ Flags:
22
+ --force Overwrite existing publishing.config.json
23
+ --json Output result as JSON
24
+ --help Show this help
25
+
26
+ Creates:
27
+ publishing.config.json Configuration file with schema pointer
28
+ profiles/manifest.json Empty package inventory
29
+ receipts/ Receipt output directory
30
+ reports/ Report output directory
31
+ `.trim();
32
+
33
+ const STARTER_CONFIG = {
34
+ $schema: "https://github.com/mcp-tool-shop/mcpt-publishing/schemas/publishing-config.schema.json",
35
+ profilesDir: "profiles",
36
+ receiptsDir: "receipts",
37
+ reportsDir: "reports",
38
+ github: {
39
+ updateIssue: true,
40
+ attachReceipts: true,
41
+ },
42
+ enabledProviders: [],
43
+ };
44
+
45
+ const STARTER_MANIFEST = {
46
+ $comment: "Machine-readable inventory of all published packages. Source of truth for audit.",
47
+ npm: [],
48
+ nuget: [],
49
+ pypi: [],
50
+ ghcr: [],
51
+ };
52
+
53
+ /**
54
+ * Execute the init command.
55
+ * @param {object} flags - Parsed CLI flags
56
+ * @returns {number} Exit code
57
+ */
58
+ export async function execute(flags) {
59
+ const cwd = process.cwd();
60
+ const configPath = join(cwd, "publishing.config.json");
61
+ const created = [];
62
+
63
+ // Config file
64
+ if (existsSync(configPath) && !flags.force) {
65
+ if (flags.json) {
66
+ process.stdout.write(JSON.stringify({ error: "publishing.config.json already exists. Use --force to overwrite." }) + "\n");
67
+ } else {
68
+ process.stderr.write(`publishing.config.json already exists. Use --force to overwrite.\n`);
69
+ }
70
+ return EXIT.CONFIG_ERROR;
71
+ }
72
+
73
+ writeFileSync(configPath, JSON.stringify(STARTER_CONFIG, null, 2) + "\n");
74
+ created.push("publishing.config.json");
75
+
76
+ // Profiles directory + manifest
77
+ const profilesDir = join(cwd, "profiles");
78
+ mkdirSync(profilesDir, { recursive: true });
79
+ const manifestPath = join(profilesDir, "manifest.json");
80
+ if (!existsSync(manifestPath) || flags.force) {
81
+ writeFileSync(manifestPath, JSON.stringify(STARTER_MANIFEST, null, 2) + "\n");
82
+ created.push("profiles/manifest.json");
83
+ }
84
+
85
+ // Receipts directory
86
+ const receiptsDir = join(cwd, "receipts");
87
+ mkdirSync(receiptsDir, { recursive: true });
88
+ created.push("receipts/");
89
+
90
+ // Reports directory
91
+ const reportsDir = join(cwd, "reports");
92
+ mkdirSync(reportsDir, { recursive: true });
93
+ created.push("reports/");
94
+
95
+ if (flags.json) {
96
+ process.stdout.write(JSON.stringify({ created, path: cwd }) + "\n");
97
+ } else {
98
+ process.stderr.write(`Initialized mcpt-publishing in ${cwd}\n`);
99
+ for (const f of created) {
100
+ process.stderr.write(` + ${f}\n`);
101
+ }
102
+ process.stderr.write(`\nNext: edit profiles/manifest.json to add your packages, then run:\n`);
103
+ process.stderr.write(` mcpt-publishing audit\n`);
104
+ }
105
+
106
+ return EXIT.SUCCESS;
107
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `mcpt-publishing plan` — dry-run publish plan (stub).
3
+ *
4
+ * Future: compares manifest → registries → generates a publish plan
5
+ * showing what would be published, skipped, or blocked.
6
+ */
7
+
8
+ import { EXIT } from "../cli/exit-codes.mjs";
9
+
10
+ export const helpText = `
11
+ mcpt-publishing plan — Generate a dry-run publish plan.
12
+
13
+ Usage:
14
+ mcpt-publishing plan [flags]
15
+
16
+ Flags:
17
+ --json Output as JSON
18
+ --help Show this help
19
+
20
+ Status: Not yet implemented. Coming in a future release.
21
+ `.trim();
22
+
23
+ /**
24
+ * Execute the plan command (stub).
25
+ * @param {object} flags - Parsed CLI flags
26
+ * @returns {number} Exit code
27
+ */
28
+ export async function execute(flags) {
29
+ if (flags.json) {
30
+ process.stdout.write(JSON.stringify({ status: "not_implemented", message: "Plan command is not yet implemented." }) + "\n");
31
+ } else {
32
+ process.stderr.write("Plan command is not yet implemented.\n");
33
+ process.stderr.write("This will generate a dry-run publish plan in a future release.\n");
34
+ }
35
+ return EXIT.SUCCESS;
36
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `mcpt-publishing providers` — list registered providers and their status.
3
+ *
4
+ * Loads providers from scripts/lib/registry.mjs, optionally filters by
5
+ * enabledProviders config, and prints a summary table.
6
+ */
7
+
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
10
+ import { loadConfig } from "../config/loader.mjs";
11
+ import { EXIT } from "../cli/exit-codes.mjs";
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+
15
+ export const helpText = `
16
+ mcpt-publishing providers — List registered providers.
17
+
18
+ Usage:
19
+ mcpt-publishing providers [flags]
20
+
21
+ Flags:
22
+ --json Output as JSON array
23
+ --config Explicit path to publishing.config.json
24
+ --help Show this help
25
+
26
+ Output:
27
+ Shows each provider name, supported ecosystems, and enabled/disabled status.
28
+ `.trim();
29
+
30
+ /**
31
+ * Execute the providers command.
32
+ * @param {object} flags - Parsed CLI flags
33
+ * @returns {number} Exit code
34
+ */
35
+ export async function execute(flags) {
36
+ const config = flags.config
37
+ ? loadConfig(dirname(flags.config))
38
+ : loadConfig();
39
+
40
+ // Import provider registry from scripts/lib
41
+ const registryPath = join(__dirname, "..", "..", "scripts", "lib", "registry.mjs");
42
+ const registryUrl = pathToFileURL(registryPath).href;
43
+ const { loadProviders } = await import(registryUrl);
44
+
45
+ const providers = await loadProviders();
46
+ const enabled = config.enabledProviders ?? [];
47
+ const allEnabled = enabled.length === 0; // empty = all enabled
48
+
49
+ const rows = providers.map(p => {
50
+ const isEnabled = allEnabled || enabled.includes(p.name);
51
+ return {
52
+ name: p.name,
53
+ ecosystem: p.ecosystem ?? p.name,
54
+ enabled: isEnabled,
55
+ status: isEnabled ? "active" : "disabled",
56
+ };
57
+ });
58
+
59
+ if (flags.json) {
60
+ process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
61
+ return EXIT.SUCCESS;
62
+ }
63
+
64
+ // Human-readable table
65
+ process.stdout.write("\n");
66
+ process.stdout.write(" Provider Ecosystem Status\n");
67
+ process.stdout.write(" ───────── ───────── ──────\n");
68
+ for (const row of rows) {
69
+ const name = row.name.padEnd(12);
70
+ const eco = row.ecosystem.padEnd(12);
71
+ const status = row.enabled ? "active" : "disabled";
72
+ process.stdout.write(` ${name} ${eco} ${status}\n`);
73
+ }
74
+ process.stdout.write("\n");
75
+
76
+ if (!allEnabled) {
77
+ process.stdout.write(` Filter: enabledProviders = [${enabled.join(", ")}]\n\n`);
78
+ }
79
+
80
+ return EXIT.SUCCESS;
81
+ }