@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,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
+ }