@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,91 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/mcp-tool-shop/mcpt-publishing/schemas/receipt.schema.json",
|
|
4
|
+
"title": "Publish Receipt",
|
|
5
|
+
"description": "Immutable record of a publish event.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": [
|
|
8
|
+
"schemaVersion",
|
|
9
|
+
"repo",
|
|
10
|
+
"target",
|
|
11
|
+
"version",
|
|
12
|
+
"packageName",
|
|
13
|
+
"commitSha",
|
|
14
|
+
"timestamp",
|
|
15
|
+
"artifacts"
|
|
16
|
+
],
|
|
17
|
+
"additionalProperties": false,
|
|
18
|
+
"properties": {
|
|
19
|
+
"schemaVersion": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"const": "1.0.0",
|
|
22
|
+
"description": "Receipt schema version"
|
|
23
|
+
},
|
|
24
|
+
"repo": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"required": ["owner", "name"],
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"properties": {
|
|
29
|
+
"owner": { "type": "string" },
|
|
30
|
+
"name": { "type": "string" }
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"target": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"enum": ["npm", "nuget", "pypi", "ghcr"],
|
|
36
|
+
"description": "Registry this was published to"
|
|
37
|
+
},
|
|
38
|
+
"version": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"description": "Published version string (e.g. 1.0.0)"
|
|
41
|
+
},
|
|
42
|
+
"packageName": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "Package name as it appears on the registry"
|
|
45
|
+
},
|
|
46
|
+
"commitSha": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"pattern": "^[0-9a-f]{40}$",
|
|
49
|
+
"description": "Full 40-char commit SHA at time of publish"
|
|
50
|
+
},
|
|
51
|
+
"timestamp": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"format": "date-time",
|
|
54
|
+
"description": "ISO 8601 timestamp of publish event"
|
|
55
|
+
},
|
|
56
|
+
"artifacts": {
|
|
57
|
+
"type": "array",
|
|
58
|
+
"items": {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"required": ["name", "sha256", "size", "url"],
|
|
61
|
+
"additionalProperties": false,
|
|
62
|
+
"properties": {
|
|
63
|
+
"name": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"description": "Artifact filename (e.g. mcpt-1.0.1.tgz)"
|
|
66
|
+
},
|
|
67
|
+
"sha256": {
|
|
68
|
+
"type": "string",
|
|
69
|
+
"pattern": "^[0-9a-f]{64}$",
|
|
70
|
+
"description": "SHA-256 hash of the artifact"
|
|
71
|
+
},
|
|
72
|
+
"size": {
|
|
73
|
+
"type": "integer",
|
|
74
|
+
"minimum": 0,
|
|
75
|
+
"description": "Size in bytes"
|
|
76
|
+
},
|
|
77
|
+
"url": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"format": "uri",
|
|
80
|
+
"description": "Direct URL to the artifact on the registry"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"metadata": {
|
|
86
|
+
"type": "object",
|
|
87
|
+
"description": "Optional provider-specific metadata",
|
|
88
|
+
"additionalProperties": true
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Glue — connects receipts to GitHub Releases and the health issue.
|
|
3
|
+
*
|
|
4
|
+
* - attachReceiptToRelease() uploads receipt JSON as a release asset
|
|
5
|
+
* - updateHealthIssueWithReceipts() appends "Recent Receipts" to the pinned issue
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
import { writeFileSync, unlinkSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Upload a receipt JSON file as a GitHub Release asset.
|
|
15
|
+
* @param {string} repo - "owner/name"
|
|
16
|
+
* @param {string} tagName - e.g. "v1.0.0"
|
|
17
|
+
* @param {string} receiptPath - absolute path to the receipt JSON file
|
|
18
|
+
* @returns {{ success: boolean, url?: string, error?: string }}
|
|
19
|
+
*/
|
|
20
|
+
export function attachReceiptToRelease(repo, tagName, receiptPath) {
|
|
21
|
+
try {
|
|
22
|
+
// Verify the release exists
|
|
23
|
+
execSync(
|
|
24
|
+
`gh api repos/${repo}/releases/tags/${tagName} --jq ".id"`,
|
|
25
|
+
{ encoding: "utf8", timeout: 15_000, stdio: ["pipe", "pipe", "pipe"] }
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// Upload receipt as asset (--clobber overwrites if already attached)
|
|
29
|
+
execSync(
|
|
30
|
+
`gh release upload ${tagName} "${receiptPath}" --repo ${repo} --clobber`,
|
|
31
|
+
{ encoding: "utf8", timeout: 30_000, stdio: ["pipe", "pipe", "pipe"] }
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return { success: true, url: `https://github.com/${repo}/releases/tag/${tagName}` };
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return { success: false, error: e.message };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Update the pinned "Publishing Health" issue to include receipt links.
|
|
42
|
+
* Appends or replaces a "Recent Receipts" section in the issue body.
|
|
43
|
+
* @param {string} repo - The mcpt-publishing repo
|
|
44
|
+
* @param {Array<{target: string, version: string, packageName: string, releaseUrl: string}>} receipts
|
|
45
|
+
*/
|
|
46
|
+
export function updateHealthIssueWithReceipts(repo, receipts) {
|
|
47
|
+
try {
|
|
48
|
+
// Find the pinned health issue
|
|
49
|
+
const issueNum = execSync(
|
|
50
|
+
`gh issue list --repo "${repo}" --search "in:title Publishing Health" --state open --json number --jq ".[0].number"`,
|
|
51
|
+
{ encoding: "utf8", timeout: 15_000, stdio: ["pipe", "pipe", "pipe"] }
|
|
52
|
+
).trim();
|
|
53
|
+
|
|
54
|
+
if (!issueNum) {
|
|
55
|
+
process.stderr.write("Warning: no 'Publishing Health' issue found\n");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Get current body
|
|
60
|
+
const body = execSync(
|
|
61
|
+
`gh issue view ${issueNum} --repo "${repo}" --json body --jq ".body"`,
|
|
62
|
+
{ encoding: "utf8", timeout: 15_000, stdio: ["pipe", "pipe", "pipe"] }
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Build receipt links section
|
|
66
|
+
const receiptLines = receipts.map(r =>
|
|
67
|
+
`- **${r.target}** ${r.packageName}@${r.version} — [release](${r.releaseUrl})`
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const receiptSection = [
|
|
71
|
+
"",
|
|
72
|
+
"### Recent Receipts",
|
|
73
|
+
...receiptLines,
|
|
74
|
+
"",
|
|
75
|
+
].join("\n");
|
|
76
|
+
|
|
77
|
+
// Replace existing receipt section or append
|
|
78
|
+
let newBody;
|
|
79
|
+
if (body.includes("### Recent Receipts")) {
|
|
80
|
+
newBody = body.replace(
|
|
81
|
+
/### Recent Receipts[\s\S]*?(?=\n###|\n---|\n## |$)/,
|
|
82
|
+
receiptSection.trim() + "\n"
|
|
83
|
+
);
|
|
84
|
+
} else {
|
|
85
|
+
newBody = body.trimEnd() + "\n" + receiptSection;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Write to temp file to avoid shell escaping issues
|
|
89
|
+
const tmpFile = join(tmpdir(), `health-issue-${Date.now()}.md`);
|
|
90
|
+
writeFileSync(tmpFile, newBody);
|
|
91
|
+
|
|
92
|
+
execSync(
|
|
93
|
+
`gh issue edit ${issueNum} --repo "${repo}" --body-file "${tmpFile}"`,
|
|
94
|
+
{ encoding: "utf8", timeout: 15_000, stdio: ["pipe", "pipe", "pipe"] }
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
unlinkSync(tmpFile);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
process.stderr.write(`Warning: failed to update health issue: ${e.message}\n`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for registry providers.
|
|
3
|
+
*
|
|
4
|
+
* Every provider must extend this and override at least:
|
|
5
|
+
* - get name()
|
|
6
|
+
* - detect(entry)
|
|
7
|
+
* - audit(entry, ctx)
|
|
8
|
+
*
|
|
9
|
+
* publish() and receipt() are optional stubs for future use.
|
|
10
|
+
*/
|
|
11
|
+
export class Provider {
|
|
12
|
+
/** Machine-readable name (e.g. "npm", "nuget", "pypi", "ghcr", "github") */
|
|
13
|
+
get name() {
|
|
14
|
+
throw new Error("Provider must define get name()");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Does this provider apply to the given manifest entry?
|
|
19
|
+
* @param {object} entry - { name, repo, audience, ecosystem }
|
|
20
|
+
* @returns {boolean}
|
|
21
|
+
*/
|
|
22
|
+
detect(entry) {
|
|
23
|
+
throw new Error("not implemented");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Audit a single package. Returns version + findings.
|
|
28
|
+
* @param {object} entry - Manifest entry
|
|
29
|
+
* @param {object} ctx - Shared context { tags: Map, releases: Map }
|
|
30
|
+
* @returns {Promise<{ version: string|null, findings: Array<{severity, code, msg}> }>}
|
|
31
|
+
*/
|
|
32
|
+
async audit(entry, ctx) {
|
|
33
|
+
throw new Error("not implemented");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Plan a publish (dry-run). Override when publish support is added.
|
|
38
|
+
* @param {object} entry
|
|
39
|
+
* @returns {Promise<{ actions: string[] }>}
|
|
40
|
+
*/
|
|
41
|
+
async plan(entry) {
|
|
42
|
+
return { actions: [] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Execute a publish. Override when publish support is added.
|
|
47
|
+
* @param {object} entry
|
|
48
|
+
* @param {object} opts - { dryRun: boolean, version: string }
|
|
49
|
+
* @returns {Promise<{ success: boolean, version: string, artifacts: object[] }>}
|
|
50
|
+
*/
|
|
51
|
+
async publish(entry, opts) {
|
|
52
|
+
throw new Error("not implemented");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Transform a publish result into a receipt object.
|
|
57
|
+
* @param {object} result - Output from publish()
|
|
58
|
+
* @returns {object} Receipt data conforming to receipt.schema.json
|
|
59
|
+
*/
|
|
60
|
+
receipt(result) {
|
|
61
|
+
throw new Error("not implemented");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GHCR provider — audits container images on GitHub Container Registry.
|
|
3
|
+
*
|
|
4
|
+
* Uses `gh api` to query package versions via the GitHub Packages API.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
import { Provider } from "../provider.mjs";
|
|
9
|
+
|
|
10
|
+
export default class GhcrProvider extends Provider {
|
|
11
|
+
get name() { return "ghcr"; }
|
|
12
|
+
|
|
13
|
+
detect(entry) {
|
|
14
|
+
return entry.ecosystem === "ghcr";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async audit(entry, ctx) {
|
|
18
|
+
const tags = ctx.tags.get(entry.repo) ?? [];
|
|
19
|
+
const releases = ctx.releases.get(entry.repo) ?? [];
|
|
20
|
+
const versions = this.#fetchVersions(entry.repo, entry.name);
|
|
21
|
+
|
|
22
|
+
if (!versions) {
|
|
23
|
+
return {
|
|
24
|
+
version: "?",
|
|
25
|
+
findings: [{ severity: "RED", code: "ghcr-unreachable", msg: `Cannot reach ${entry.name} on ghcr.io` }],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (versions.length === 0) {
|
|
30
|
+
return {
|
|
31
|
+
version: "?",
|
|
32
|
+
findings: [{ severity: "RED", code: "ghcr-no-versions", msg: `${entry.name} has no versions on ghcr.io` }],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// API returns newest first — extract the latest tagged version
|
|
37
|
+
const latest = versions[0];
|
|
38
|
+
const containerTags = latest?.metadata?.container?.tags ?? [];
|
|
39
|
+
const version = containerTags.find(t => /^\d/.test(t)) ?? containerTags[0] ?? "?";
|
|
40
|
+
const findings = this.#classify(entry, version, tags, releases);
|
|
41
|
+
|
|
42
|
+
return { version, findings };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Private ───────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
#fetchVersions(repo, pkg) {
|
|
48
|
+
const owner = repo.split("/")[0];
|
|
49
|
+
|
|
50
|
+
// Try org-level endpoint first
|
|
51
|
+
try {
|
|
52
|
+
const endpoint = `/orgs/${owner}/packages/container/${encodeURIComponent(pkg)}/versions`;
|
|
53
|
+
const raw = execSync(
|
|
54
|
+
`gh api "${endpoint}" --jq "." 2>&1`,
|
|
55
|
+
{ encoding: "utf8", timeout: 15_000 }
|
|
56
|
+
);
|
|
57
|
+
return JSON.parse(raw);
|
|
58
|
+
} catch {
|
|
59
|
+
// Fall back to user-level endpoint
|
|
60
|
+
try {
|
|
61
|
+
const endpoint = `/users/${owner}/packages/container/${encodeURIComponent(pkg)}/versions`;
|
|
62
|
+
const raw = execSync(
|
|
63
|
+
`gh api "${endpoint}" --jq "." 2>&1`,
|
|
64
|
+
{ encoding: "utf8", timeout: 15_000 }
|
|
65
|
+
);
|
|
66
|
+
return JSON.parse(raw);
|
|
67
|
+
} catch { return null; }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#classify(entry, version, tags, releases) {
|
|
72
|
+
const findings = [];
|
|
73
|
+
|
|
74
|
+
if (version === "?") return findings;
|
|
75
|
+
|
|
76
|
+
const tagName = `v${version}`;
|
|
77
|
+
|
|
78
|
+
// Image exists but no matching git tag (RED)
|
|
79
|
+
if (!tags.includes(tagName)) {
|
|
80
|
+
findings.push({
|
|
81
|
+
severity: "RED",
|
|
82
|
+
code: "published-not-tagged",
|
|
83
|
+
msg: `${entry.name} image tagged ${version} — no git tag ${tagName}`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Tagged-but-not-released (YELLOW for front-door)
|
|
88
|
+
if (tags.includes(tagName) && !releases.includes(tagName) && entry.audience === "front-door") {
|
|
89
|
+
findings.push({
|
|
90
|
+
severity: "YELLOW",
|
|
91
|
+
code: "tagged-not-released",
|
|
92
|
+
msg: `${entry.name} tag ${tagName} has no GitHub Release`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return findings;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
receipt(result) {
|
|
100
|
+
const [owner, name] = result.repo.split("/");
|
|
101
|
+
return {
|
|
102
|
+
schemaVersion: "1.0.0",
|
|
103
|
+
repo: { owner, name },
|
|
104
|
+
target: "ghcr",
|
|
105
|
+
version: result.version,
|
|
106
|
+
packageName: result.name,
|
|
107
|
+
commitSha: result.commitSha,
|
|
108
|
+
timestamp: new Date().toISOString(),
|
|
109
|
+
artifacts: result.artifacts ?? [],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub provider — context loader for tags and releases.
|
|
3
|
+
*
|
|
4
|
+
* This provider doesn't produce its own findings. Its job is to populate
|
|
5
|
+
* ctx.tags and ctx.releases so that ecosystem providers (npm, nuget, etc.)
|
|
6
|
+
* can check for drift between registry and git state.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
import { Provider } from "../provider.mjs";
|
|
11
|
+
|
|
12
|
+
export default class GitHubProvider extends Provider {
|
|
13
|
+
get name() { return "github"; }
|
|
14
|
+
|
|
15
|
+
detect(entry) {
|
|
16
|
+
return !!entry.repo;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async audit(entry, ctx) {
|
|
20
|
+
const repo = entry.repo;
|
|
21
|
+
|
|
22
|
+
if (!ctx.tags.has(repo)) {
|
|
23
|
+
ctx.tags.set(repo, this.#fetchTags(repo));
|
|
24
|
+
}
|
|
25
|
+
if (!ctx.releases.has(repo)) {
|
|
26
|
+
ctx.releases.set(repo, this.#fetchReleases(repo));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// No findings — this is a context loader
|
|
30
|
+
return { version: null, findings: [] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#fetchTags(repo) {
|
|
34
|
+
try {
|
|
35
|
+
const raw = execSync(
|
|
36
|
+
`gh api repos/${repo}/tags --jq ".[].name" 2>&1`,
|
|
37
|
+
{ encoding: "utf8", timeout: 15_000 }
|
|
38
|
+
);
|
|
39
|
+
return raw.trim().split("\n").filter(Boolean);
|
|
40
|
+
} catch { return []; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#fetchReleases(repo) {
|
|
44
|
+
try {
|
|
45
|
+
const raw = execSync(
|
|
46
|
+
`gh api repos/${repo}/releases --jq ".[].tag_name" 2>&1`,
|
|
47
|
+
{ encoding: "utf8", timeout: 15_000 }
|
|
48
|
+
);
|
|
49
|
+
return raw.trim().split("\n").filter(Boolean);
|
|
50
|
+
} catch { return []; }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm provider — extracted from audit.mjs.
|
|
3
|
+
*
|
|
4
|
+
* Fetches metadata via `npm view` and classifies drift against git state.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
import { Provider } from "../provider.mjs";
|
|
9
|
+
|
|
10
|
+
export default class NpmProvider extends Provider {
|
|
11
|
+
get name() { return "npm"; }
|
|
12
|
+
|
|
13
|
+
detect(entry) {
|
|
14
|
+
return entry.ecosystem === "npm";
|
|
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
|
+
const version = meta?.["dist-tags"]?.latest ?? meta?.version ?? "?";
|
|
23
|
+
const findings = this.#classify(entry, meta, tags, releases);
|
|
24
|
+
|
|
25
|
+
return { version, findings };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Private ───────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
#fetchMeta(pkg) {
|
|
31
|
+
try {
|
|
32
|
+
const raw = execSync(`npm view ${pkg} --json 2>&1`, { encoding: "utf8", timeout: 15_000 });
|
|
33
|
+
return JSON.parse(raw);
|
|
34
|
+
} catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#classify(pkg, meta, tags, releases) {
|
|
38
|
+
const findings = [];
|
|
39
|
+
|
|
40
|
+
if (!meta) {
|
|
41
|
+
findings.push({ severity: "RED", code: "npm-unreachable", msg: `Cannot reach ${pkg.name} on npm` });
|
|
42
|
+
return findings;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ver = meta["dist-tags"]?.latest ?? meta.version;
|
|
46
|
+
const tagName = `v${ver}`;
|
|
47
|
+
|
|
48
|
+
// Published-but-not-tagged
|
|
49
|
+
if (!tags.includes(tagName)) {
|
|
50
|
+
findings.push({ severity: "RED", code: "published-not-tagged", msg: `${pkg.name}@${ver} — no git tag ${tagName}` });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Tagged-but-not-released
|
|
54
|
+
if (tags.includes(tagName) && !releases.includes(tagName) && pkg.audience === "front-door") {
|
|
55
|
+
findings.push({ severity: "YELLOW", code: "tagged-not-released", msg: `${pkg.name} tag ${tagName} has no GitHub Release` });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Repo URL check
|
|
59
|
+
const repoUrl = meta.repository?.url ?? "";
|
|
60
|
+
if (!repoUrl.includes(pkg.repo.split("/")[0])) {
|
|
61
|
+
findings.push({ severity: "RED", code: "wrong-repo-url", msg: `${pkg.name} repo URL "${repoUrl}" doesn't match expected ${pkg.repo}` });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Description
|
|
65
|
+
const desc = meta.description ?? "";
|
|
66
|
+
if (!desc || desc.startsWith("<") || desc.includes("<img")) {
|
|
67
|
+
findings.push({ severity: "RED", code: "bad-description", msg: `${pkg.name} has missing/HTML description` });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// README
|
|
71
|
+
if (meta.readme === "ERROR: No README data found!" || meta.readme === "") {
|
|
72
|
+
if (pkg.audience === "front-door") {
|
|
73
|
+
findings.push({ severity: "YELLOW", code: "missing-readme", msg: `${pkg.name} has no README on npm` });
|
|
74
|
+
} else {
|
|
75
|
+
findings.push({ severity: "GRAY", code: "missing-readme", msg: `${pkg.name} (internal) has no README on npm` });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Homepage
|
|
80
|
+
if (!meta.homepage) {
|
|
81
|
+
findings.push({ severity: "GRAY", code: "missing-homepage", msg: `${pkg.name} has no homepage` });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return findings;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
receipt(result) {
|
|
88
|
+
const [owner, name] = result.repo.split("/");
|
|
89
|
+
return {
|
|
90
|
+
schemaVersion: "1.0.0",
|
|
91
|
+
repo: { owner, name },
|
|
92
|
+
target: "npm",
|
|
93
|
+
version: result.version,
|
|
94
|
+
packageName: result.name,
|
|
95
|
+
commitSha: result.commitSha,
|
|
96
|
+
timestamp: new Date().toISOString(),
|
|
97
|
+
artifacts: result.artifacts ?? [],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NuGet provider — extracted from audit.mjs.
|
|
3
|
+
*
|
|
4
|
+
* Fetches metadata via NuGet search API + flat-container,
|
|
5
|
+
* detects indexing lag, and classifies drift against git state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
import { Provider } from "../provider.mjs";
|
|
10
|
+
|
|
11
|
+
export default class NuGetProvider extends Provider {
|
|
12
|
+
get name() { return "nuget"; }
|
|
13
|
+
|
|
14
|
+
detect(entry) {
|
|
15
|
+
return entry.ecosystem === "nuget";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async audit(entry, ctx) {
|
|
19
|
+
const meta = this.#fetchMeta(entry.name);
|
|
20
|
+
const flatVersions = this.#fetchVersions(entry.name);
|
|
21
|
+
const tags = ctx.tags.get(entry.repo) ?? [];
|
|
22
|
+
const releases = ctx.releases.get(entry.repo) ?? [];
|
|
23
|
+
|
|
24
|
+
const version = flatVersions.length > 0
|
|
25
|
+
? flatVersions[flatVersions.length - 1]
|
|
26
|
+
: (meta?.version ?? "?");
|
|
27
|
+
|
|
28
|
+
const findings = this.#classify(entry, meta, tags, releases, flatVersions);
|
|
29
|
+
return { version, findings };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Private ───────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
#fetchMeta(id) {
|
|
35
|
+
try {
|
|
36
|
+
const url = `https://azuresearch-usnc.nuget.org/query?q=packageid:${id}&take=1`;
|
|
37
|
+
const raw = execSync(`curl -sf "${url}"`, { encoding: "utf8", timeout: 15_000 });
|
|
38
|
+
const data = JSON.parse(raw);
|
|
39
|
+
return data.data?.[0] ?? null;
|
|
40
|
+
} catch { return null; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Flat container returns actual published versions (updates faster than search). */
|
|
44
|
+
#fetchVersions(id) {
|
|
45
|
+
try {
|
|
46
|
+
const url = `https://api.nuget.org/v3-flatcontainer/${id.toLowerCase()}/index.json`;
|
|
47
|
+
const raw = execSync(`curl -sf "${url}"`, { encoding: "utf8", timeout: 15_000 });
|
|
48
|
+
return JSON.parse(raw).versions ?? [];
|
|
49
|
+
} catch { return []; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#classify(pkg, meta, tags, releases, flatVersions) {
|
|
53
|
+
const findings = [];
|
|
54
|
+
|
|
55
|
+
if (!meta) {
|
|
56
|
+
findings.push({ severity: "RED", code: "nuget-unreachable", msg: `Cannot reach ${pkg.name} on NuGet` });
|
|
57
|
+
return findings;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const searchVer = meta.version;
|
|
61
|
+
const latestFlat = flatVersions.length > 0 ? flatVersions[flatVersions.length - 1] : null;
|
|
62
|
+
|
|
63
|
+
// Detect indexing lag: flat container knows about a newer version than the search API
|
|
64
|
+
const indexingLag = latestFlat && latestFlat !== searchVer;
|
|
65
|
+
const ver = latestFlat ?? searchVer;
|
|
66
|
+
const tagName = `v${ver}`;
|
|
67
|
+
|
|
68
|
+
// Published-but-not-tagged
|
|
69
|
+
if (!tags.includes(tagName)) {
|
|
70
|
+
findings.push({ severity: "RED", code: "published-not-tagged", msg: `${pkg.name}@${ver} — no git tag ${tagName}` });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ProjectUrl — suppress during indexing lag (metadata not propagated yet)
|
|
74
|
+
if (!meta.projectUrl && !indexingLag) {
|
|
75
|
+
if (pkg.audience === "front-door") {
|
|
76
|
+
findings.push({ severity: "YELLOW", code: "missing-project-url", msg: `${pkg.name} has no projectUrl on NuGet` });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Icon — suppress during indexing lag
|
|
81
|
+
if (!meta.iconUrl && pkg.audience === "front-door" && !indexingLag) {
|
|
82
|
+
findings.push({ severity: "YELLOW", code: "missing-icon", msg: `${pkg.name} (front-door) has no icon` });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Indexing lag notice (non-failing)
|
|
86
|
+
if (indexingLag) {
|
|
87
|
+
findings.push({
|
|
88
|
+
severity: "INFO",
|
|
89
|
+
code: "pending-index",
|
|
90
|
+
msg: `${pkg.name} v${latestFlat} published but search API still shows v${searchVer} — retry in 60-120 min`
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Description
|
|
95
|
+
if (!meta.description) {
|
|
96
|
+
findings.push({ severity: "GRAY", code: "missing-description", msg: `${pkg.name} has no description` });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return findings;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
receipt(result) {
|
|
103
|
+
const [owner, name] = result.repo.split("/");
|
|
104
|
+
return {
|
|
105
|
+
schemaVersion: "1.0.0",
|
|
106
|
+
repo: { owner, name },
|
|
107
|
+
target: "nuget",
|
|
108
|
+
version: result.version,
|
|
109
|
+
packageName: result.name,
|
|
110
|
+
commitSha: result.commitSha,
|
|
111
|
+
timestamp: new Date().toISOString(),
|
|
112
|
+
artifacts: result.artifacts ?? [],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|