@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,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mcpt-publishing publish` — publish packages to registries with receipts.
|
|
3
|
+
*
|
|
4
|
+
* Loads config → reads manifest → filters by --repo/--target → pre-flight
|
|
5
|
+
* credential check → executes publishes → writes receipts → updates index.
|
|
6
|
+
*
|
|
7
|
+
* Exit codes:
|
|
8
|
+
* 0 — all publishes succeeded (or no packages matched)
|
|
9
|
+
* 4 — missing credentials
|
|
10
|
+
* 5 — one or more publishes failed
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
14
|
+
import { join, dirname, resolve } from "node:path";
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
|
+
import { loadConfig } from "../config/loader.mjs";
|
|
17
|
+
import { updatePublishEntry } from "../receipts/index-writer.mjs";
|
|
18
|
+
import { EXIT } from "../cli/exit-codes.mjs";
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
|
|
22
|
+
export const helpText = `
|
|
23
|
+
mcpt-publishing publish — Publish packages to registries and generate receipts.
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
mcpt-publishing publish [flags]
|
|
27
|
+
|
|
28
|
+
Flags:
|
|
29
|
+
--repo <owner/name> Filter to packages from this repo
|
|
30
|
+
--target <registry> Filter to a specific registry (npm, nuget)
|
|
31
|
+
--cwd <path> Working directory for the repo (default: cwd)
|
|
32
|
+
--dry-run Do everything except the actual registry push
|
|
33
|
+
--json Output results as JSON
|
|
34
|
+
--help Show this help
|
|
35
|
+
|
|
36
|
+
Exit codes:
|
|
37
|
+
0 All publishes succeeded
|
|
38
|
+
4 Missing credentials (NPM_TOKEN or NUGET_API_KEY)
|
|
39
|
+
5 One or more publishes failed
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
mcpt-publishing publish --target npm --dry-run
|
|
43
|
+
mcpt-publishing publish --repo mcp-tool-shop-org/mcpt --target npm
|
|
44
|
+
mcpt-publishing publish --repo mcp-tool-shop-org/soundboard-maui --target nuget --cwd /path/to/repo
|
|
45
|
+
`.trim();
|
|
46
|
+
|
|
47
|
+
/** Required env vars per provider. */
|
|
48
|
+
const CRED_MAP = {
|
|
49
|
+
npm: "NPM_TOKEN",
|
|
50
|
+
nuget: "NUGET_API_KEY",
|
|
51
|
+
pypi: "PYPI_TOKEN",
|
|
52
|
+
ghcr: "GITHUB_TOKEN",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Execute the publish command.
|
|
57
|
+
* @param {object} flags - Parsed CLI flags
|
|
58
|
+
* @returns {number} Exit code
|
|
59
|
+
*/
|
|
60
|
+
export async function execute(flags) {
|
|
61
|
+
// 1. Load config
|
|
62
|
+
const config = flags.config
|
|
63
|
+
? loadConfig(dirname(flags.config))
|
|
64
|
+
: loadConfig();
|
|
65
|
+
|
|
66
|
+
// 2. Read manifest
|
|
67
|
+
const manifestPath = join(config.profilesDir, "manifest.json");
|
|
68
|
+
if (!existsSync(manifestPath)) {
|
|
69
|
+
process.stderr.write(`Error: Manifest not found at ${manifestPath}\n`);
|
|
70
|
+
process.stderr.write(`Run 'mcpt-publishing init' to scaffold the project.\n`);
|
|
71
|
+
return EXIT.CONFIG_ERROR;
|
|
72
|
+
}
|
|
73
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
74
|
+
|
|
75
|
+
// 3. Load providers
|
|
76
|
+
const registryPath = join(__dirname, "..", "..", "scripts", "lib", "registry.mjs");
|
|
77
|
+
const registryUrl = pathToFileURL(registryPath).href;
|
|
78
|
+
const { loadProviders, matchProviders } = await import(registryUrl);
|
|
79
|
+
const providers = await loadProviders();
|
|
80
|
+
|
|
81
|
+
// 4. Build task list from manifest
|
|
82
|
+
const tasks = [];
|
|
83
|
+
for (const [ecosystem, packages] of Object.entries(manifest)) {
|
|
84
|
+
if (!Array.isArray(packages)) continue;
|
|
85
|
+
for (const pkg of packages) {
|
|
86
|
+
const entry = { ...pkg, ecosystem };
|
|
87
|
+
|
|
88
|
+
// Apply filters
|
|
89
|
+
if (flags.repo && entry.repo !== flags.repo) continue;
|
|
90
|
+
if (flags.target && ecosystem !== flags.target) continue;
|
|
91
|
+
if (entry.deprecated) continue;
|
|
92
|
+
|
|
93
|
+
// Find ecosystem providers (skip github context loader)
|
|
94
|
+
const matched = matchProviders(providers, entry).filter(p => p.name !== "github");
|
|
95
|
+
for (const provider of matched) {
|
|
96
|
+
tasks.push({ entry, provider });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (tasks.length === 0) {
|
|
102
|
+
if (flags.json) {
|
|
103
|
+
process.stdout.write(JSON.stringify({ results: [], failures: 0, message: "No packages matched filters" }) + "\n");
|
|
104
|
+
} else {
|
|
105
|
+
process.stderr.write("No packages matched the given filters.\n");
|
|
106
|
+
if (flags.repo) process.stderr.write(` --repo: ${flags.repo}\n`);
|
|
107
|
+
if (flags.target) process.stderr.write(` --target: ${flags.target}\n`);
|
|
108
|
+
}
|
|
109
|
+
return EXIT.SUCCESS;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 5. Pre-flight credential check
|
|
113
|
+
const neededCreds = new Set();
|
|
114
|
+
for (const { provider } of tasks) {
|
|
115
|
+
const envVar = CRED_MAP[provider.name];
|
|
116
|
+
if (envVar && !process.env[envVar]) {
|
|
117
|
+
neededCreds.add(envVar);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (neededCreds.size > 0 && !flags["dry-run"]) {
|
|
122
|
+
for (const envVar of neededCreds) {
|
|
123
|
+
process.stderr.write(`Error: ${envVar} environment variable is not set.\n`);
|
|
124
|
+
}
|
|
125
|
+
return EXIT.MISSING_CREDENTIALS;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 6. Execute publishes sequentially
|
|
129
|
+
const cwd = flags.cwd ? resolve(flags.cwd) : process.cwd();
|
|
130
|
+
const dryRun = !!flags["dry-run"];
|
|
131
|
+
const results = [];
|
|
132
|
+
let failures = 0;
|
|
133
|
+
|
|
134
|
+
// Import receipt writer and shell utils
|
|
135
|
+
const receiptWriterPath = join(__dirname, "..", "..", "scripts", "lib", "receipt-writer.mjs");
|
|
136
|
+
const shellPath = join(__dirname, "..", "..", "scripts", "lib", "shell.mjs");
|
|
137
|
+
const { write: writeReceipt } = await import(pathToFileURL(receiptWriterPath).href);
|
|
138
|
+
const { getCommitSha } = await import(pathToFileURL(shellPath).href);
|
|
139
|
+
|
|
140
|
+
for (const { entry, provider } of tasks) {
|
|
141
|
+
const label = `${entry.name} → ${provider.name}`;
|
|
142
|
+
process.stderr.write(`\nPublishing ${label}${dryRun ? " (dry-run)" : ""}...\n`);
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const result = await provider.publish(entry, { dryRun, cwd });
|
|
146
|
+
|
|
147
|
+
if (!result.success) {
|
|
148
|
+
process.stderr.write(` FAIL: ${result.error}\n`);
|
|
149
|
+
failures++;
|
|
150
|
+
results.push({ name: entry.name, target: provider.name, success: false, error: result.error });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
process.stderr.write(` OK: ${entry.name}@${result.version}\n`);
|
|
155
|
+
|
|
156
|
+
// Write receipt (skip in dry-run)
|
|
157
|
+
if (!dryRun) {
|
|
158
|
+
try {
|
|
159
|
+
const commitSha = getCommitSha(cwd);
|
|
160
|
+
const receiptData = provider.receipt({
|
|
161
|
+
...entry,
|
|
162
|
+
version: result.version,
|
|
163
|
+
commitSha,
|
|
164
|
+
artifacts: result.artifacts,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const receiptFile = writeReceipt(receiptData);
|
|
168
|
+
updatePublishEntry(config.receiptsDir, receiptData);
|
|
169
|
+
process.stderr.write(` Receipt: ${receiptFile}\n`);
|
|
170
|
+
|
|
171
|
+
// GitHub glue: attach to release (opt-in)
|
|
172
|
+
if (config.github?.attachReceipts) {
|
|
173
|
+
try {
|
|
174
|
+
const gluePath = join(__dirname, "..", "..", "scripts", "lib", "github-glue.mjs");
|
|
175
|
+
const { attachReceiptToRelease } = await import(pathToFileURL(gluePath).href);
|
|
176
|
+
const tagName = `v${result.version}`;
|
|
177
|
+
attachReceiptToRelease(entry.repo, tagName, receiptFile);
|
|
178
|
+
process.stderr.write(` Attached to release ${tagName}\n`);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
// GitHub glue failures are non-fatal
|
|
181
|
+
process.stderr.write(` Warning: GitHub glue failed: ${e.message}\n`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch (e) {
|
|
185
|
+
process.stderr.write(` Warning: Receipt write failed: ${e.message}\n`);
|
|
186
|
+
// Receipt failure doesn't fail the publish — the package IS published
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
results.push({
|
|
191
|
+
name: entry.name,
|
|
192
|
+
target: provider.name,
|
|
193
|
+
success: true,
|
|
194
|
+
version: result.version,
|
|
195
|
+
artifacts: result.artifacts,
|
|
196
|
+
dryRun,
|
|
197
|
+
});
|
|
198
|
+
} catch (e) {
|
|
199
|
+
process.stderr.write(` ERROR: ${e.message}\n`);
|
|
200
|
+
failures++;
|
|
201
|
+
results.push({ name: entry.name, target: provider.name, success: false, error: e.message });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 7. Summary
|
|
206
|
+
const succeeded = results.filter(r => r.success).length;
|
|
207
|
+
|
|
208
|
+
if (flags.json) {
|
|
209
|
+
process.stdout.write(JSON.stringify({ results, succeeded, failures, dryRun }, null, 2) + "\n");
|
|
210
|
+
} else {
|
|
211
|
+
process.stderr.write(`\nDone. ${succeeded} succeeded, ${failures} failed.${dryRun ? " (dry-run)" : ""}\n`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return failures > 0 ? EXIT.PUBLISH_FAILURE : EXIT.SUCCESS;
|
|
215
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mcpt-publishing verify-receipt` — validate a receipt file.
|
|
3
|
+
*
|
|
4
|
+
* Checks:
|
|
5
|
+
* 1. File exists and is readable
|
|
6
|
+
* 2. Valid JSON
|
|
7
|
+
* 3. Schema validation (publish or audit receipt)
|
|
8
|
+
* 4. SHA-256 integrity hash (for reference/verification)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
12
|
+
import { resolve } from "node:path";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { EXIT } from "../cli/exit-codes.mjs";
|
|
15
|
+
|
|
16
|
+
export const helpText = `
|
|
17
|
+
mcpt-publishing verify-receipt — Validate a receipt file.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
mcpt-publishing verify-receipt <path> [flags]
|
|
21
|
+
|
|
22
|
+
Arguments:
|
|
23
|
+
<path> Path to the receipt JSON file
|
|
24
|
+
|
|
25
|
+
Flags:
|
|
26
|
+
--json Output result as JSON
|
|
27
|
+
--help Show this help
|
|
28
|
+
|
|
29
|
+
Checks:
|
|
30
|
+
1. File exists and is readable
|
|
31
|
+
2. Valid JSON parse
|
|
32
|
+
3. Schema validation (publish or audit receipt)
|
|
33
|
+
4. SHA-256 content integrity hash
|
|
34
|
+
|
|
35
|
+
Exit codes:
|
|
36
|
+
0 Receipt is valid
|
|
37
|
+
3 Validation failed
|
|
38
|
+
`.trim();
|
|
39
|
+
|
|
40
|
+
/** Required fields for audit receipts. */
|
|
41
|
+
const AUDIT_REQUIRED = ["schemaVersion", "type", "timestamp", "counts", "totalPackages"];
|
|
42
|
+
|
|
43
|
+
/** Required fields for publish receipts. */
|
|
44
|
+
const PUBLISH_REQUIRED = ["schemaVersion", "repo", "target", "version", "packageName", "commitSha", "timestamp", "artifacts"];
|
|
45
|
+
|
|
46
|
+
const VALID_TARGETS = ["npm", "nuget", "pypi", "ghcr"];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Execute the verify-receipt command.
|
|
50
|
+
* @param {object} flags - Parsed CLI flags
|
|
51
|
+
* @returns {number} Exit code
|
|
52
|
+
*/
|
|
53
|
+
export async function execute(flags) {
|
|
54
|
+
const receiptPath = flags._positionals[0];
|
|
55
|
+
|
|
56
|
+
if (!receiptPath) {
|
|
57
|
+
process.stderr.write("Error: receipt path is required.\n");
|
|
58
|
+
process.stderr.write("Usage: mcpt-publishing verify-receipt <path>\n");
|
|
59
|
+
return EXIT.CONFIG_ERROR;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const absPath = resolve(receiptPath);
|
|
63
|
+
const checks = [];
|
|
64
|
+
|
|
65
|
+
// Check 1: File exists
|
|
66
|
+
if (!existsSync(absPath)) {
|
|
67
|
+
checks.push({ check: "exists", pass: false, msg: `File not found: ${absPath}` });
|
|
68
|
+
return output(checks, flags);
|
|
69
|
+
}
|
|
70
|
+
checks.push({ check: "exists", pass: true });
|
|
71
|
+
|
|
72
|
+
// Check 2: Valid JSON
|
|
73
|
+
let receipt;
|
|
74
|
+
try {
|
|
75
|
+
receipt = JSON.parse(readFileSync(absPath, "utf8"));
|
|
76
|
+
checks.push({ check: "json", pass: true });
|
|
77
|
+
} catch (e) {
|
|
78
|
+
checks.push({ check: "json", pass: false, msg: `Invalid JSON: ${e.message}` });
|
|
79
|
+
return output(checks, flags);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check 3: Schema validation
|
|
83
|
+
// Detect receipt type: audit receipts have type="audit", publish receipts have "target"
|
|
84
|
+
if (receipt.type === "audit") {
|
|
85
|
+
const missing = AUDIT_REQUIRED.filter(f => !(f in receipt));
|
|
86
|
+
if (missing.length > 0) {
|
|
87
|
+
checks.push({ check: "schema", pass: false, type: "audit", msg: `Missing fields: ${missing.join(", ")}` });
|
|
88
|
+
} else {
|
|
89
|
+
checks.push({ check: "schema", pass: true, type: "audit" });
|
|
90
|
+
}
|
|
91
|
+
} else if (receipt.target || receipt.packageName) {
|
|
92
|
+
// Publish receipt — validate against full schema
|
|
93
|
+
const missing = PUBLISH_REQUIRED.filter(f => !(f in receipt));
|
|
94
|
+
if (missing.length > 0) {
|
|
95
|
+
checks.push({ check: "schema", pass: false, type: "publish", msg: `Missing fields: ${missing.join(", ")}` });
|
|
96
|
+
} else {
|
|
97
|
+
// Deep validation
|
|
98
|
+
const errors = validatePublishReceipt(receipt);
|
|
99
|
+
if (errors.length > 0) {
|
|
100
|
+
checks.push({ check: "schema", pass: false, type: "publish", msg: errors.join("; ") });
|
|
101
|
+
} else {
|
|
102
|
+
checks.push({ check: "schema", pass: true, type: "publish" });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
checks.push({ check: "schema", pass: false, msg: "Unknown receipt type (no 'type' or 'target' field)" });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check 4: Compute SHA-256 integrity hash
|
|
110
|
+
const content = readFileSync(absPath);
|
|
111
|
+
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
112
|
+
checks.push({ check: "integrity", pass: true, sha256 });
|
|
113
|
+
|
|
114
|
+
return output(checks, flags);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Validate publish receipt fields beyond presence checks.
|
|
119
|
+
* @param {object} r - Receipt object
|
|
120
|
+
* @returns {string[]} Error messages (empty = valid)
|
|
121
|
+
*/
|
|
122
|
+
function validatePublishReceipt(r) {
|
|
123
|
+
const errors = [];
|
|
124
|
+
|
|
125
|
+
if (r.schemaVersion !== "1.0.0") {
|
|
126
|
+
errors.push(`Unknown schemaVersion: ${r.schemaVersion}`);
|
|
127
|
+
}
|
|
128
|
+
if (typeof r.repo !== "object" || !r.repo.owner || !r.repo.name) {
|
|
129
|
+
errors.push("repo must have owner and name");
|
|
130
|
+
}
|
|
131
|
+
if (!VALID_TARGETS.includes(r.target)) {
|
|
132
|
+
errors.push(`Invalid target: ${r.target}`);
|
|
133
|
+
}
|
|
134
|
+
if (typeof r.commitSha !== "string" || !/^[0-9a-f]{40}$/.test(r.commitSha)) {
|
|
135
|
+
errors.push("commitSha must be 40 lowercase hex characters");
|
|
136
|
+
}
|
|
137
|
+
if (!Array.isArray(r.artifacts)) {
|
|
138
|
+
errors.push("artifacts must be an array");
|
|
139
|
+
} else {
|
|
140
|
+
for (const art of r.artifacts) {
|
|
141
|
+
if (!art.name) errors.push("artifact missing name");
|
|
142
|
+
if (typeof art.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(art.sha256)) {
|
|
143
|
+
errors.push(`artifact ${art.name ?? "?"} has invalid sha256`);
|
|
144
|
+
}
|
|
145
|
+
if (typeof art.size !== "number" || art.size < 0) {
|
|
146
|
+
errors.push(`artifact ${art.name ?? "?"} has invalid size`);
|
|
147
|
+
}
|
|
148
|
+
if (!art.url) errors.push(`artifact ${art.name ?? "?"} missing url`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return errors;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Output check results and return exit code.
|
|
157
|
+
*/
|
|
158
|
+
function output(checks, flags) {
|
|
159
|
+
const allPass = checks.every(c => c.pass);
|
|
160
|
+
|
|
161
|
+
if (flags.json) {
|
|
162
|
+
process.stdout.write(JSON.stringify({ valid: allPass, checks }, null, 2) + "\n");
|
|
163
|
+
} else {
|
|
164
|
+
for (const c of checks) {
|
|
165
|
+
const icon = c.pass ? "PASS" : "FAIL";
|
|
166
|
+
const detail = c.msg ? ` — ${c.msg}` : "";
|
|
167
|
+
const extra = c.sha256 ? ` (sha256: ${c.sha256.slice(0, 16)}...)` : "";
|
|
168
|
+
const typeNote = c.type ? ` [${c.type}]` : "";
|
|
169
|
+
process.stderr.write(` ${icon} ${c.check}${typeNote}${detail}${extra}\n`);
|
|
170
|
+
}
|
|
171
|
+
process.stderr.write(`\n${allPass ? "Receipt is valid." : "Receipt validation FAILED."}\n`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return allPass ? EXIT.SUCCESS : EXIT.CONFIG_ERROR;
|
|
175
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default config values used when no publishing.config.json is found
|
|
3
|
+
* or when fields are omitted from the config file.
|
|
4
|
+
*/
|
|
5
|
+
export const DEFAULTS = {
|
|
6
|
+
profilesDir: "profiles",
|
|
7
|
+
receiptsDir: "receipts",
|
|
8
|
+
reportsDir: "reports",
|
|
9
|
+
github: {
|
|
10
|
+
updateIssue: true,
|
|
11
|
+
attachReceipts: true,
|
|
12
|
+
},
|
|
13
|
+
enabledProviders: [], // empty = all providers enabled
|
|
14
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config loader — discovers and parses publishing.config.json.
|
|
3
|
+
*
|
|
4
|
+
* Discovery order:
|
|
5
|
+
* 1. PUBLISHING_CONFIG env var (explicit path)
|
|
6
|
+
* 2. Walk up from startDir looking for publishing.config.json
|
|
7
|
+
* 3. Fall back to defaults rooted at startDir
|
|
8
|
+
*
|
|
9
|
+
* All directory paths (profilesDir, receiptsDir, reportsDir) resolve
|
|
10
|
+
* relative to the config file location, not process.cwd().
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
14
|
+
import { join, dirname, resolve, isAbsolute } from "node:path";
|
|
15
|
+
import { DEFAULTS } from "./defaults.mjs";
|
|
16
|
+
import { validateConfig } from "./schema.mjs";
|
|
17
|
+
|
|
18
|
+
const CONFIG_FILENAME = "publishing.config.json";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load config from the filesystem.
|
|
22
|
+
* @param {string} [startDir=process.cwd()] - Directory to start searching from
|
|
23
|
+
* @returns {object} Resolved config with absolute paths
|
|
24
|
+
*/
|
|
25
|
+
export function loadConfig(startDir = process.cwd()) {
|
|
26
|
+
// 1. Env override takes priority
|
|
27
|
+
const envPath = process.env.PUBLISHING_CONFIG;
|
|
28
|
+
if (envPath) {
|
|
29
|
+
const resolved = resolve(envPath);
|
|
30
|
+
if (!existsSync(resolved)) {
|
|
31
|
+
throw new Error(`PUBLISHING_CONFIG points to non-existent file: ${resolved}`);
|
|
32
|
+
}
|
|
33
|
+
return parseAndResolve(resolved);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. Walk up from startDir
|
|
37
|
+
let dir = resolve(startDir);
|
|
38
|
+
while (true) {
|
|
39
|
+
const candidate = join(dir, CONFIG_FILENAME);
|
|
40
|
+
if (existsSync(candidate)) {
|
|
41
|
+
return parseAndResolve(candidate);
|
|
42
|
+
}
|
|
43
|
+
const parent = dirname(dir);
|
|
44
|
+
if (parent === dir) break; // filesystem root reached
|
|
45
|
+
dir = parent;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 3. No config found — return defaults rooted at startDir
|
|
49
|
+
const resolvedStart = resolve(startDir);
|
|
50
|
+
return {
|
|
51
|
+
...DEFAULTS,
|
|
52
|
+
github: { ...DEFAULTS.github },
|
|
53
|
+
profilesDir: join(resolvedStart, DEFAULTS.profilesDir),
|
|
54
|
+
receiptsDir: join(resolvedStart, DEFAULTS.receiptsDir),
|
|
55
|
+
reportsDir: join(resolvedStart, DEFAULTS.reportsDir),
|
|
56
|
+
_configDir: resolvedStart,
|
|
57
|
+
_configFile: null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse a config file and resolve all paths relative to its location.
|
|
63
|
+
* @param {string} filePath - Absolute path to the config file
|
|
64
|
+
* @returns {object}
|
|
65
|
+
*/
|
|
66
|
+
function parseAndResolve(filePath) {
|
|
67
|
+
const raw = JSON.parse(readFileSync(filePath, "utf8"));
|
|
68
|
+
validateConfig(raw);
|
|
69
|
+
|
|
70
|
+
const configDir = dirname(filePath);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
...DEFAULTS,
|
|
74
|
+
...raw,
|
|
75
|
+
github: { ...DEFAULTS.github, ...raw.github },
|
|
76
|
+
profilesDir: resolvePath(configDir, raw.profilesDir ?? DEFAULTS.profilesDir),
|
|
77
|
+
receiptsDir: resolvePath(configDir, raw.receiptsDir ?? DEFAULTS.receiptsDir),
|
|
78
|
+
reportsDir: resolvePath(configDir, raw.reportsDir ?? DEFAULTS.reportsDir),
|
|
79
|
+
_configDir: configDir,
|
|
80
|
+
_configFile: filePath,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolvePath(base, p) {
|
|
85
|
+
return isAbsolute(p) ? p : resolve(base, p);
|
|
86
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight config validation (zero dependencies).
|
|
3
|
+
*
|
|
4
|
+
* Validates publishing.config.json structure and types.
|
|
5
|
+
* Throws on invalid input with a clear message.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const KNOWN_KEYS = new Set([
|
|
9
|
+
"$schema", "profilesDir", "receiptsDir", "reportsDir",
|
|
10
|
+
"github", "enabledProviders",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const KNOWN_GITHUB_KEYS = new Set([
|
|
14
|
+
"updateIssue", "attachReceipts",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate a parsed config object.
|
|
19
|
+
* @param {object} config
|
|
20
|
+
* @throws {Error} on invalid config
|
|
21
|
+
*/
|
|
22
|
+
export function validateConfig(config) {
|
|
23
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
24
|
+
throw new Error("Config must be a JSON object");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Check for unknown top-level keys
|
|
28
|
+
for (const key of Object.keys(config)) {
|
|
29
|
+
if (!KNOWN_KEYS.has(key)) {
|
|
30
|
+
throw new Error(`Unknown config key: "${key}"`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// String path fields
|
|
35
|
+
for (const field of ["profilesDir", "receiptsDir", "reportsDir"]) {
|
|
36
|
+
if (field in config && typeof config[field] !== "string") {
|
|
37
|
+
throw new Error(`Config "${field}" must be a string`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// github section
|
|
42
|
+
if ("github" in config) {
|
|
43
|
+
if (typeof config.github !== "object" || Array.isArray(config.github)) {
|
|
44
|
+
throw new Error('Config "github" must be an object');
|
|
45
|
+
}
|
|
46
|
+
for (const key of Object.keys(config.github)) {
|
|
47
|
+
if (!KNOWN_GITHUB_KEYS.has(key)) {
|
|
48
|
+
throw new Error(`Unknown config github key: "${key}"`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if ("updateIssue" in config.github && typeof config.github.updateIssue !== "boolean") {
|
|
52
|
+
throw new Error('Config "github.updateIssue" must be a boolean');
|
|
53
|
+
}
|
|
54
|
+
if ("attachReceipts" in config.github && typeof config.github.attachReceipts !== "boolean") {
|
|
55
|
+
throw new Error('Config "github.attachReceipts" must be a boolean');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// enabledProviders
|
|
60
|
+
if ("enabledProviders" in config) {
|
|
61
|
+
if (!Array.isArray(config.enabledProviders)) {
|
|
62
|
+
throw new Error('Config "enabledProviders" must be an array');
|
|
63
|
+
}
|
|
64
|
+
for (const p of config.enabledProviders) {
|
|
65
|
+
if (typeof p !== "string") {
|
|
66
|
+
throw new Error('Config "enabledProviders" entries must be strings');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit receipt builder — emits a receipt after each audit run.
|
|
3
|
+
*
|
|
4
|
+
* Path: receipts/audit/<YYYY-MM-DD>.json
|
|
5
|
+
* Date-keyed: latest run of the day overwrites (not immutable like publish receipts).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { updateReceiptsIndex } from "./index-writer.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build and write an audit receipt from audit results.
|
|
14
|
+
* @param {object} config - Resolved config (needs config.receiptsDir)
|
|
15
|
+
* @param {object} results - Audit results { npm:[], nuget:[], counts:{}, generated }
|
|
16
|
+
* @returns {string} Path to the written receipt file
|
|
17
|
+
*/
|
|
18
|
+
export function emitAuditReceipt(config, results) {
|
|
19
|
+
const auditDir = join(config.receiptsDir, "audit");
|
|
20
|
+
mkdirSync(auditDir, { recursive: true });
|
|
21
|
+
|
|
22
|
+
// Count packages per ecosystem
|
|
23
|
+
const ecosystems = {};
|
|
24
|
+
let totalPackages = 0;
|
|
25
|
+
for (const [key, val] of Object.entries(results)) {
|
|
26
|
+
if (Array.isArray(val)) {
|
|
27
|
+
ecosystems[key] = val.length;
|
|
28
|
+
totalPackages += val.length;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const receipt = {
|
|
33
|
+
schemaVersion: "1.0.0",
|
|
34
|
+
type: "audit",
|
|
35
|
+
timestamp: new Date().toISOString(),
|
|
36
|
+
counts: results.counts,
|
|
37
|
+
ecosystems,
|
|
38
|
+
totalPackages,
|
|
39
|
+
reportFiles: {
|
|
40
|
+
json: "reports/latest.json",
|
|
41
|
+
markdown: "reports/latest.md",
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const date = receipt.timestamp.slice(0, 10); // YYYY-MM-DD
|
|
46
|
+
const filePath = join(auditDir, `${date}.json`);
|
|
47
|
+
writeFileSync(filePath, JSON.stringify(receipt, null, 2) + "\n");
|
|
48
|
+
|
|
49
|
+
// Update the receipts index
|
|
50
|
+
updateReceiptsIndex(config.receiptsDir, receipt);
|
|
51
|
+
|
|
52
|
+
return filePath;
|
|
53
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Receipts index maintainer — keeps receipts/index.json current.
|
|
3
|
+
*
|
|
4
|
+
* The index provides a single-file view of:
|
|
5
|
+
* - Latest audit run (date, counts, totalPackages)
|
|
6
|
+
* - Latest publish per target/package (version, timestamp, commitSha)
|
|
7
|
+
*
|
|
8
|
+
* The Receipt Factory site can consume this file to render dashboards.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { join, dirname } from "node:path";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Update the index with the latest audit receipt data.
|
|
16
|
+
* @param {string} receiptsDir - Absolute path to receipts directory
|
|
17
|
+
* @param {object} auditReceipt - The audit receipt object
|
|
18
|
+
*/
|
|
19
|
+
export function updateReceiptsIndex(receiptsDir, auditReceipt) {
|
|
20
|
+
const index = loadIndex(receiptsDir);
|
|
21
|
+
|
|
22
|
+
index.latestAudit = {
|
|
23
|
+
date: auditReceipt.timestamp,
|
|
24
|
+
counts: auditReceipt.counts,
|
|
25
|
+
totalPackages: auditReceipt.totalPackages,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
saveIndex(receiptsDir, index);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Update the index with a publish receipt entry.
|
|
33
|
+
* @param {string} receiptsDir - Absolute path to receipts directory
|
|
34
|
+
* @param {object} publishReceipt - A publish receipt object
|
|
35
|
+
*/
|
|
36
|
+
export function updatePublishEntry(receiptsDir, publishReceipt) {
|
|
37
|
+
const index = loadIndex(receiptsDir);
|
|
38
|
+
|
|
39
|
+
if (!index.publish) index.publish = {};
|
|
40
|
+
|
|
41
|
+
const key = `${publishReceipt.target}/${publishReceipt.packageName}`;
|
|
42
|
+
index.publish[key] = {
|
|
43
|
+
version: publishReceipt.version,
|
|
44
|
+
timestamp: publishReceipt.timestamp,
|
|
45
|
+
commitSha: publishReceipt.commitSha,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
saveIndex(receiptsDir, index);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Internal ────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function loadIndex(receiptsDir) {
|
|
54
|
+
const indexPath = join(receiptsDir, "index.json");
|
|
55
|
+
if (existsSync(indexPath)) {
|
|
56
|
+
return JSON.parse(readFileSync(indexPath, "utf8"));
|
|
57
|
+
}
|
|
58
|
+
return { latestAudit: null, publish: {} };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function saveIndex(receiptsDir, index) {
|
|
62
|
+
mkdirSync(receiptsDir, { recursive: true });
|
|
63
|
+
const indexPath = join(receiptsDir, "index.json");
|
|
64
|
+
writeFileSync(indexPath, JSON.stringify(index, null, 2) + "\n");
|
|
65
|
+
}
|