@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,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/mcp-tool-shop/mcpt-publishing/schemas/publishing-config.schema.json",
|
|
4
|
+
"title": "Publishing Config",
|
|
5
|
+
"description": "Configuration for the mcpt-publishing CLI.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": {
|
|
10
|
+
"type": "string"
|
|
11
|
+
},
|
|
12
|
+
"profilesDir": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"default": "profiles",
|
|
15
|
+
"description": "Path to profiles directory (relative to config file or absolute)"
|
|
16
|
+
},
|
|
17
|
+
"receiptsDir": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"default": "receipts",
|
|
20
|
+
"description": "Path to receipts output directory"
|
|
21
|
+
},
|
|
22
|
+
"reportsDir": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"default": "reports",
|
|
25
|
+
"description": "Path to reports output directory"
|
|
26
|
+
},
|
|
27
|
+
"github": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"additionalProperties": false,
|
|
30
|
+
"properties": {
|
|
31
|
+
"updateIssue": {
|
|
32
|
+
"type": "boolean",
|
|
33
|
+
"default": true,
|
|
34
|
+
"description": "Update the pinned Publishing Health issue after audit"
|
|
35
|
+
},
|
|
36
|
+
"attachReceipts": {
|
|
37
|
+
"type": "boolean",
|
|
38
|
+
"default": true,
|
|
39
|
+
"description": "Attach receipt JSON files to GitHub Releases"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"enabledProviders": {
|
|
44
|
+
"type": "array",
|
|
45
|
+
"items": { "type": "string" },
|
|
46
|
+
"default": [],
|
|
47
|
+
"description": "Allowlist of provider names. Empty array = all providers enabled."
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -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,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm provider — audit + publish.
|
|
3
|
+
*
|
|
4
|
+
* Audit: fetches metadata via `npm view` and classifies drift against git state.
|
|
5
|
+
* Publish: packs tarball, computes SHA-256, publishes to registry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
import { readFileSync, unlinkSync, existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { Provider } from "../provider.mjs";
|
|
12
|
+
import { exec, hashFile } from "../shell.mjs";
|
|
13
|
+
|
|
14
|
+
export default class NpmProvider extends Provider {
|
|
15
|
+
get name() { return "npm"; }
|
|
16
|
+
|
|
17
|
+
detect(entry) {
|
|
18
|
+
return entry.ecosystem === "npm";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async audit(entry, ctx) {
|
|
22
|
+
const meta = this.#fetchMeta(entry.name);
|
|
23
|
+
const tags = ctx.tags.get(entry.repo) ?? [];
|
|
24
|
+
const releases = ctx.releases.get(entry.repo) ?? [];
|
|
25
|
+
|
|
26
|
+
const version = meta?.["dist-tags"]?.latest ?? meta?.version ?? "?";
|
|
27
|
+
const findings = this.#classify(entry, meta, tags, releases);
|
|
28
|
+
|
|
29
|
+
return { version, findings };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Publish an npm package.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} entry - { name, repo, audience, ecosystem }
|
|
36
|
+
* @param {object} opts - { dryRun: boolean, cwd: string }
|
|
37
|
+
* @returns {Promise<{ success: boolean, version: string, artifacts: Array, error?: string }>}
|
|
38
|
+
*/
|
|
39
|
+
async publish(entry, opts = {}) {
|
|
40
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
41
|
+
|
|
42
|
+
// Check credentials (skip in dry-run — npm pack doesn't need auth)
|
|
43
|
+
if (!process.env.NPM_TOKEN && !opts.dryRun) {
|
|
44
|
+
return { success: false, version: "", artifacts: [], error: "NPM_TOKEN environment variable is not set" };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Read version from package.json
|
|
48
|
+
const pkgJsonPath = join(cwd, "package.json");
|
|
49
|
+
if (!existsSync(pkgJsonPath)) {
|
|
50
|
+
return { success: false, version: "", artifacts: [], error: `No package.json found in ${cwd}` };
|
|
51
|
+
}
|
|
52
|
+
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
|
|
53
|
+
const version = pkgJson.version;
|
|
54
|
+
|
|
55
|
+
if (!version) {
|
|
56
|
+
return { success: false, version: "", artifacts: [], error: "No version field in package.json" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// npm pack → get tarball
|
|
60
|
+
const packResult = exec("npm pack --json", { cwd });
|
|
61
|
+
if (packResult.exitCode !== 0) {
|
|
62
|
+
return { success: false, version, artifacts: [], error: `npm pack failed: ${packResult.stderr}` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let packInfo;
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(packResult.stdout);
|
|
68
|
+
packInfo = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
69
|
+
} catch {
|
|
70
|
+
return { success: false, version, artifacts: [], error: "Failed to parse npm pack output" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const tarball = packInfo.filename;
|
|
74
|
+
const tarballPath = join(cwd, tarball);
|
|
75
|
+
|
|
76
|
+
// Compute SHA-256
|
|
77
|
+
const { sha256, size } = hashFile(tarballPath);
|
|
78
|
+
|
|
79
|
+
// Publish (or dry-run)
|
|
80
|
+
const publishCmd = opts.dryRun
|
|
81
|
+
? "npm publish --dry-run --access public"
|
|
82
|
+
: "npm publish --access public";
|
|
83
|
+
|
|
84
|
+
const pubResult = exec(publishCmd, { cwd });
|
|
85
|
+
if (pubResult.exitCode !== 0) {
|
|
86
|
+
// Clean up tarball on failure
|
|
87
|
+
try { unlinkSync(tarballPath); } catch { /* ignore */ }
|
|
88
|
+
return { success: false, version, artifacts: [], error: `npm publish failed: ${pubResult.stderr}` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Clean up tarball
|
|
92
|
+
try { unlinkSync(tarballPath); } catch { /* ignore */ }
|
|
93
|
+
|
|
94
|
+
// Build artifact metadata
|
|
95
|
+
const scopedName = entry.name.replace(/^@/, "").replace(/\//, "-");
|
|
96
|
+
const artifact = {
|
|
97
|
+
name: tarball,
|
|
98
|
+
sha256,
|
|
99
|
+
size,
|
|
100
|
+
url: `https://www.npmjs.com/package/${entry.name}/v/${version}`,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return { success: true, version, artifacts: [artifact] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Private ───────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
#fetchMeta(pkg) {
|
|
109
|
+
try {
|
|
110
|
+
const raw = execSync(`npm view ${pkg} --json 2>&1`, { encoding: "utf8", timeout: 15_000 });
|
|
111
|
+
return JSON.parse(raw);
|
|
112
|
+
} catch { return null; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#classify(pkg, meta, tags, releases) {
|
|
116
|
+
const findings = [];
|
|
117
|
+
|
|
118
|
+
if (!meta) {
|
|
119
|
+
findings.push({ severity: "RED", code: "npm-unreachable", msg: `Cannot reach ${pkg.name} on npm` });
|
|
120
|
+
return findings;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const ver = meta["dist-tags"]?.latest ?? meta.version;
|
|
124
|
+
const tagName = `v${ver}`;
|
|
125
|
+
|
|
126
|
+
// Published-but-not-tagged
|
|
127
|
+
if (!tags.includes(tagName)) {
|
|
128
|
+
findings.push({ severity: "RED", code: "published-not-tagged", msg: `${pkg.name}@${ver} — no git tag ${tagName}` });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Tagged-but-not-released
|
|
132
|
+
if (tags.includes(tagName) && !releases.includes(tagName) && pkg.audience === "front-door") {
|
|
133
|
+
findings.push({ severity: "YELLOW", code: "tagged-not-released", msg: `${pkg.name} tag ${tagName} has no GitHub Release` });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Repo URL check
|
|
137
|
+
const repoUrl = meta.repository?.url ?? "";
|
|
138
|
+
if (!repoUrl.includes(pkg.repo.split("/")[0])) {
|
|
139
|
+
findings.push({ severity: "RED", code: "wrong-repo-url", msg: `${pkg.name} repo URL "${repoUrl}" doesn't match expected ${pkg.repo}` });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Description
|
|
143
|
+
const desc = meta.description ?? "";
|
|
144
|
+
if (!desc || desc.startsWith("<") || desc.includes("<img")) {
|
|
145
|
+
findings.push({ severity: "RED", code: "bad-description", msg: `${pkg.name} has missing/HTML description` });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// README
|
|
149
|
+
if (meta.readme === "ERROR: No README data found!" || meta.readme === "") {
|
|
150
|
+
if (pkg.audience === "front-door") {
|
|
151
|
+
findings.push({ severity: "YELLOW", code: "missing-readme", msg: `${pkg.name} has no README on npm` });
|
|
152
|
+
} else {
|
|
153
|
+
findings.push({ severity: "GRAY", code: "missing-readme", msg: `${pkg.name} (internal) has no README on npm` });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Homepage
|
|
158
|
+
if (!meta.homepage) {
|
|
159
|
+
findings.push({ severity: "GRAY", code: "missing-homepage", msg: `${pkg.name} has no homepage` });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return findings;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
receipt(result) {
|
|
166
|
+
const [owner, name] = result.repo.split("/");
|
|
167
|
+
return {
|
|
168
|
+
schemaVersion: "1.0.0",
|
|
169
|
+
repo: { owner, name },
|
|
170
|
+
target: "npm",
|
|
171
|
+
version: result.version,
|
|
172
|
+
packageName: result.name,
|
|
173
|
+
commitSha: result.commitSha,
|
|
174
|
+
timestamp: new Date().toISOString(),
|
|
175
|
+
artifacts: result.artifacts ?? [],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|