codex-plugin-doctor 1.57.0 → 1.59.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 -21
- package/README.md +20 -6
- package/dist/compatibility/claude-desktop-install-preview.js +4 -4
- package/dist/compatibility/cline-install-preview.js +4 -4
- package/dist/compatibility/compatibility-matrix.js +13 -17
- package/dist/compatibility/cursor-install-preview.js +4 -4
- package/dist/compatibility/windsurf-install-preview.js +4 -4
- package/dist/core/doctor-size.js +16 -1
- package/dist/core/init-plugin.js +2 -2
- package/dist/core/inspector-bridge.js +8 -13
- package/dist/core/mcp-config-normalizer.d.ts +18 -0
- package/dist/core/mcp-config-normalizer.js +50 -0
- package/dist/core/output-contract.js +76 -0
- package/dist/core/package-path.d.ts +1 -0
- package/dist/core/package-path.js +25 -0
- package/dist/core/plugin-components.d.ts +7 -0
- package/dist/core/plugin-components.js +169 -0
- package/dist/core/plugin-hooks.d.ts +2 -0
- package/dist/core/plugin-hooks.js +190 -0
- package/dist/core/runtime-plan.js +97 -16
- package/dist/core/runtime-probe.js +3 -8
- package/dist/core/shell-completion.js +29 -0
- package/dist/core/submission-assets.d.ts +6 -0
- package/dist/core/submission-assets.js +430 -0
- package/dist/core/submission-preflight.d.ts +34 -0
- package/dist/core/submission-preflight.js +289 -0
- package/dist/core/submission-ruleset.d.ts +56 -0
- package/dist/core/submission-ruleset.js +38 -0
- package/dist/core/submission-skill-metadata.d.ts +9 -0
- package/dist/core/submission-skill-metadata.js +301 -0
- package/dist/core/validate-plugin.js +39 -17
- package/dist/domain/types.d.ts +34 -0
- package/dist/domain/types.js +13 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/mcp/generic-mcp-doctor.js +45 -29
- package/dist/reporting/render-submission-report.d.ts +5 -0
- package/dist/reporting/render-submission-report.js +68 -0
- package/dist/rules/rule-catalog.js +130 -4
- package/dist/run-cli.js +96 -0
- package/dist/security/security-audit.d.ts +1 -0
- package/dist/security/security-audit.js +104 -7
- package/examples/README.md +94 -94
- package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
- package/examples/codex-doctor-generic-mcp/server.js +1 -1
- package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-risky/.mcp.json +12 -12
- package/examples/codex-doctor-risky/mock-server.js +1 -1
- package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
- package/examples/codex-doctor-runtime/.mcp.json +9 -9
- package/examples/codex-doctor-runtime/mock-server.js +237 -237
- package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
- package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
- package/package.json +5 -1
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Furkan Tasci
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Furkan Tasci
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -131,8 +131,21 @@ codex-plugin-doctor registry preflight path/to/server.json --allow-network --req
|
|
|
131
131
|
Preflight checks the exact npm package version, then the exact Registry version
|
|
132
132
|
and latest Registry record on fixed public hosts. An existing exact Registry
|
|
133
133
|
version is an immutable collision and must be replaced by a version bump. The
|
|
134
|
-
command is advisory: it never authenticates, publishes, or changes npm or
|
|
135
|
-
Registry records. See [MCP Registry Publication Preflight](./docs/architecture/mcp-registry-publication-preflight.md).
|
|
134
|
+
command is advisory: it never authenticates, publishes, or changes npm or
|
|
135
|
+
Registry records. See [MCP Registry Publication Preflight](./docs/architecture/mcp-registry-publication-preflight.md).
|
|
136
|
+
|
|
137
|
+
### Public Directory Submission Preflight
|
|
138
|
+
|
|
139
|
+
Check a plugin package for deterministic public-directory submission issues without contacting a portal:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
codex-plugin-doctor doctor submission <path>
|
|
143
|
+
codex-plugin-doctor doctor submission <path> --json
|
|
144
|
+
codex-plugin-doctor doctor submission <path> --markdown
|
|
145
|
+
codex-plugin-doctor doctor submission <path> --require-ready
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The preflight is offline and non-executing: it does not submit a package, make network requests, start MCP servers, verify domains, or handle OAuth credentials. Its automatic `status` is `pass` or `fail`; a passing automatic result remains `manual_review_required` until portal-only review is complete. It never claims directory acceptance. See [Public Directory Submission Preflight](./docs/architecture/public-directory-submission-preflight.md).
|
|
136
149
|
|
|
137
150
|
Output formats:
|
|
138
151
|
|
|
@@ -480,9 +493,9 @@ jobs:
|
|
|
480
493
|
runs-on: ubuntu-latest
|
|
481
494
|
steps:
|
|
482
495
|
- uses: actions/checkout@v5
|
|
483
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
484
|
-
with:
|
|
485
|
-
version: "1.
|
|
496
|
+
- uses: Esquetta/CodexPluginDoctor@v1.59.0
|
|
497
|
+
with:
|
|
498
|
+
version: "1.59.0"
|
|
486
499
|
path: .
|
|
487
500
|
runtime: "true"
|
|
488
501
|
policy: codex-publish
|
|
@@ -494,7 +507,7 @@ jobs:
|
|
|
494
507
|
review-bundle-verify: "true"
|
|
495
508
|
```
|
|
496
509
|
|
|
497
|
-
The action writes `codex-plugin-doctor-summary.md`, `codex-plugin-doctor-report.json`, `codex-plugin-doctor-action-manifest.json`, optional `codex-plugin-doctor.sarif`, optional validation corpus and quality metrics reports, optional `output-contract.json`, and optional signed `review-bundle/` files to `codex-plugin-doctor-reports`, appends the Markdown report to the GitHub Actions step summary, uploads the report directory as an artifact, and then returns the real validation exit code. Review bundle generation requires a signing key environment variable such as `CODEX_PLUGIN_DOCTOR_SIGNING_KEY`. For runtime probing, SARIF output, corpus quality regression gates, corpus and contract artifacts, review bundle artifacts, installed plugin cache checks, CI policy presets, and pinned release examples, see [GitHub Action Usage](./docs/guides/github-action.md).
|
|
510
|
+
The action writes `codex-plugin-doctor-summary.md`, `codex-plugin-doctor-report.json`, `codex-plugin-doctor-action-manifest.json`, optional `codex-plugin-doctor.sarif`, optional validation corpus and quality metrics reports, optional `output-contract.json`, and optional signed `review-bundle/` files to `codex-plugin-doctor-reports`, appends the Markdown report to the GitHub Actions step summary, uploads the report directory as an artifact, and then returns the real validation exit code. Set `submission: "true"` to add offline submission preflight reports; set `require-submission-ready: "true"` only with that opt-in to make automatic blockers fail the job. Review bundle generation requires a signing key environment variable such as `CODEX_PLUGIN_DOCTOR_SIGNING_KEY`. For runtime probing, SARIF output, corpus quality regression gates, corpus and contract artifacts, review bundle artifacts, installed plugin cache checks, CI policy presets, and pinned release examples, see [GitHub Action Usage](./docs/guides/github-action.md).
|
|
498
511
|
|
|
499
512
|
To self-test this repository after cloning it:
|
|
500
513
|
|
|
@@ -518,6 +531,7 @@ The validator is tuned against local fixtures and real marketplace-style plugin
|
|
|
518
531
|
- [Real-World Validation Workflow](./docs/guides/real-world-validation.md)
|
|
519
532
|
- [Examples](./examples/README.md)
|
|
520
533
|
- [Rule Catalog](./docs/rules/catalog.md)
|
|
534
|
+
- [Official Plugin Components](./docs/architecture/official-plugin-components.md)
|
|
521
535
|
|
|
522
536
|
Recent validation waves covered:
|
|
523
537
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
|
|
3
4
|
import { getClaudeDesktopConfigPath, readMcpConfigPath } from "./compatibility-matrix.js";
|
|
4
5
|
function isRecord(value) {
|
|
5
6
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -38,16 +39,15 @@ export async function buildClaudeDesktopInstallPreview(targetPath, environment =
|
|
|
38
39
|
if (!mcpConfigPath) {
|
|
39
40
|
throw new Error("No MCP config found for install preview.");
|
|
40
41
|
}
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
if (!isRecord(servers) || Object.keys(servers).length === 0) {
|
|
42
|
+
const normalizedConfig = normalizeMcpConfig(JSON.parse(await readFile(mcpConfigPath, "utf8")));
|
|
43
|
+
if (!normalizedConfig.ok) {
|
|
44
44
|
throw new Error("MCP config does not contain a non-empty `mcpServers` object.");
|
|
45
45
|
}
|
|
46
46
|
return {
|
|
47
47
|
targetPath: rootPath,
|
|
48
48
|
configPath,
|
|
49
49
|
snippet: {
|
|
50
|
-
mcpServers: Object.fromEntries(Object.entries(servers).map(([serverName, serverConfig]) => [
|
|
50
|
+
mcpServers: Object.fromEntries(Object.entries(normalizedConfig.servers).map(([serverName, serverConfig]) => [
|
|
51
51
|
serverName,
|
|
52
52
|
normalizeServerConfig(serverConfig, rootPath)
|
|
53
53
|
]))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
|
|
3
4
|
import { getClineMcpConfigPath, readMcpConfigPath } from "./compatibility-matrix.js";
|
|
4
5
|
function isRecord(value) {
|
|
5
6
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -34,16 +35,15 @@ export async function buildClineInstallPreview(targetPath, environment = {}) {
|
|
|
34
35
|
if (!mcpConfigPath) {
|
|
35
36
|
throw new Error("No MCP config found for install preview.");
|
|
36
37
|
}
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
if (!isRecord(servers) || Object.keys(servers).length === 0) {
|
|
38
|
+
const normalizedConfig = normalizeMcpConfig(JSON.parse(await readFile(mcpConfigPath, "utf8")));
|
|
39
|
+
if (!normalizedConfig.ok) {
|
|
40
40
|
throw new Error("MCP config does not contain a non-empty `mcpServers` object.");
|
|
41
41
|
}
|
|
42
42
|
return {
|
|
43
43
|
targetPath: rootPath,
|
|
44
44
|
configPath: getClineMcpConfigPath(environment),
|
|
45
45
|
snippet: {
|
|
46
|
-
mcpServers: Object.fromEntries(Object.entries(servers).map(([serverName, serverConfig]) => [
|
|
46
|
+
mcpServers: Object.fromEntries(Object.entries(normalizedConfig.servers).map(([serverName, serverConfig]) => [
|
|
47
47
|
serverName,
|
|
48
48
|
normalizeServerConfig(serverConfig, rootPath)
|
|
49
49
|
]))
|
|
@@ -2,7 +2,9 @@ import { stat } from "node:fs/promises";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { validatePlugin } from "../core/validate-plugin.js";
|
|
5
|
+
import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
|
|
5
6
|
import { readJsonFile } from "../core/read-json-file.js";
|
|
7
|
+
import { resolveContainedPackagePath } from "../core/package-path.js";
|
|
6
8
|
async function fileExists(targetPath) {
|
|
7
9
|
try {
|
|
8
10
|
const details = await stat(targetPath);
|
|
@@ -30,16 +32,15 @@ function statusFromCheckResult(result) {
|
|
|
30
32
|
}
|
|
31
33
|
return "pass";
|
|
32
34
|
}
|
|
33
|
-
function isPathWithinRoot(rootPath, candidatePath) {
|
|
34
|
-
const relativePath = path.relative(rootPath, candidatePath);
|
|
35
|
-
return (relativePath === "" ||
|
|
36
|
-
(!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
|
|
37
|
-
}
|
|
38
35
|
export async function readMcpConfigPath(targetPath) {
|
|
39
36
|
const rootPath = path.resolve(targetPath);
|
|
40
37
|
const directMcpPath = path.join(rootPath, ".mcp.json");
|
|
41
38
|
if (await fileExists(directMcpPath)) {
|
|
42
|
-
|
|
39
|
+
const containedDirectMcpPath = await resolveContainedPackagePath(rootPath, directMcpPath);
|
|
40
|
+
if (!containedDirectMcpPath) {
|
|
41
|
+
throw new Error("MCP config path resolves outside the package root.");
|
|
42
|
+
}
|
|
43
|
+
return containedDirectMcpPath;
|
|
43
44
|
}
|
|
44
45
|
const manifestPath = path.join(rootPath, ".codex-plugin", "plugin.json");
|
|
45
46
|
if (!(await fileExists(manifestPath))) {
|
|
@@ -55,8 +56,8 @@ export async function readMcpConfigPath(targetPath) {
|
|
|
55
56
|
if (typeof manifest.mcpServers !== "string") {
|
|
56
57
|
return null;
|
|
57
58
|
}
|
|
58
|
-
const mcpConfigPath =
|
|
59
|
-
if (!
|
|
59
|
+
const mcpConfigPath = await resolveContainedPackagePath(rootPath, manifest.mcpServers);
|
|
60
|
+
if (!mcpConfigPath) {
|
|
60
61
|
throw new Error("Manifest MCP config path resolves outside the package root.");
|
|
61
62
|
}
|
|
62
63
|
return mcpConfigPath;
|
|
@@ -87,11 +88,8 @@ async function checkGenericMcp(targetPath) {
|
|
|
87
88
|
}
|
|
88
89
|
try {
|
|
89
90
|
const parsed = await readJsonFile(mcpConfigPath);
|
|
90
|
-
const
|
|
91
|
-
if (
|
|
92
|
-
servers === null ||
|
|
93
|
-
Array.isArray(servers) ||
|
|
94
|
-
Object.keys(servers).length === 0) {
|
|
91
|
+
const normalizedConfig = normalizeMcpConfig(parsed);
|
|
92
|
+
if (!normalizedConfig.ok) {
|
|
95
93
|
return {
|
|
96
94
|
client: "Generic MCP",
|
|
97
95
|
status: "fail",
|
|
@@ -122,10 +120,8 @@ async function readMcpServerNames(targetPath) {
|
|
|
122
120
|
}
|
|
123
121
|
try {
|
|
124
122
|
const parsed = await readJsonFile(mcpConfigPath);
|
|
125
|
-
const
|
|
126
|
-
return
|
|
127
|
-
? Object.keys(servers)
|
|
128
|
-
: [];
|
|
123
|
+
const normalizedConfig = normalizeMcpConfig(parsed);
|
|
124
|
+
return normalizedConfig.ok ? Object.keys(normalizedConfig.servers) : [];
|
|
129
125
|
}
|
|
130
126
|
catch {
|
|
131
127
|
return [];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
|
|
3
4
|
import { getCursorMcpConfigPath, readMcpConfigPath } from "./compatibility-matrix.js";
|
|
4
5
|
function isRecord(value) {
|
|
5
6
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -34,16 +35,15 @@ export async function buildCursorInstallPreview(targetPath, environment = {}) {
|
|
|
34
35
|
if (!mcpConfigPath) {
|
|
35
36
|
throw new Error("No MCP config found for install preview.");
|
|
36
37
|
}
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
if (!isRecord(servers) || Object.keys(servers).length === 0) {
|
|
38
|
+
const normalizedConfig = normalizeMcpConfig(JSON.parse(await readFile(mcpConfigPath, "utf8")));
|
|
39
|
+
if (!normalizedConfig.ok) {
|
|
40
40
|
throw new Error("MCP config does not contain a non-empty `mcpServers` object.");
|
|
41
41
|
}
|
|
42
42
|
return {
|
|
43
43
|
targetPath: rootPath,
|
|
44
44
|
configPath: await getCursorMcpConfigPath(rootPath, environment),
|
|
45
45
|
snippet: {
|
|
46
|
-
mcpServers: Object.fromEntries(Object.entries(servers).map(([serverName, serverConfig]) => [
|
|
46
|
+
mcpServers: Object.fromEntries(Object.entries(normalizedConfig.servers).map(([serverName, serverConfig]) => [
|
|
47
47
|
serverName,
|
|
48
48
|
normalizeServerConfig(serverConfig, rootPath)
|
|
49
49
|
]))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { normalizeMcpConfig } from "../core/mcp-config-normalizer.js";
|
|
3
4
|
import { getWindsurfMcpConfigPath, readMcpConfigPath } from "./compatibility-matrix.js";
|
|
4
5
|
function isRecord(value) {
|
|
5
6
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -34,16 +35,15 @@ export async function buildWindsurfInstallPreview(targetPath, environment = {})
|
|
|
34
35
|
if (!mcpConfigPath) {
|
|
35
36
|
throw new Error("No MCP config found for install preview.");
|
|
36
37
|
}
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
if (!isRecord(servers) || Object.keys(servers).length === 0) {
|
|
38
|
+
const normalizedConfig = normalizeMcpConfig(JSON.parse(await readFile(mcpConfigPath, "utf8")));
|
|
39
|
+
if (!normalizedConfig.ok) {
|
|
40
40
|
throw new Error("MCP config does not contain a non-empty `mcpServers` object.");
|
|
41
41
|
}
|
|
42
42
|
return {
|
|
43
43
|
targetPath: rootPath,
|
|
44
44
|
configPath: getWindsurfMcpConfigPath(environment),
|
|
45
45
|
snippet: {
|
|
46
|
-
mcpServers: Object.fromEntries(Object.entries(servers).map(([serverName, serverConfig]) => [
|
|
46
|
+
mcpServers: Object.fromEntries(Object.entries(normalizedConfig.servers).map(([serverName, serverConfig]) => [
|
|
47
47
|
serverName,
|
|
48
48
|
normalizeServerConfig(serverConfig, rootPath)
|
|
49
49
|
]))
|
package/dist/core/doctor-size.js
CHANGED
|
@@ -15,6 +15,20 @@ const skippedDirs = new Set([
|
|
|
15
15
|
const largeFileThreshold = 1024 * 1024;
|
|
16
16
|
const packageSizeWarnThreshold = 10 * 1024 * 1024;
|
|
17
17
|
const packageSizeFailThreshold = 50 * 1024 * 1024;
|
|
18
|
+
function npmPackCommand() {
|
|
19
|
+
if (process.platform === "win32") {
|
|
20
|
+
const npmExecPath = process.env.npm_execpath;
|
|
21
|
+
// Only use the inherited npm CLI path when it is an absolute npm entrypoint.
|
|
22
|
+
const npmCliPath = npmExecPath && path.isAbsolute(npmExecPath) && path.basename(npmExecPath).toLowerCase() === "npm-cli.js"
|
|
23
|
+
? npmExecPath
|
|
24
|
+
: path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
|
|
25
|
+
return {
|
|
26
|
+
command: process.execPath,
|
|
27
|
+
args: [npmCliPath, "pack", "--dry-run"]
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return { command: "npm", args: ["pack", "--dry-run"] };
|
|
31
|
+
}
|
|
18
32
|
function formatSize(bytes) {
|
|
19
33
|
if (bytes < 1024)
|
|
20
34
|
return `${bytes} B`;
|
|
@@ -79,7 +93,8 @@ export async function buildDoctorSize(targetPath, options = {}) {
|
|
|
79
93
|
if (options.npmPack) {
|
|
80
94
|
try {
|
|
81
95
|
const packOutput = await new Promise((resolve, reject) => {
|
|
82
|
-
|
|
96
|
+
const npmPack = npmPackCommand();
|
|
97
|
+
execFile(npmPack.command, npmPack.args, { cwd: resolvedPath, timeout: 30000 }, (error, stdout, stderr) => {
|
|
83
98
|
if (error) {
|
|
84
99
|
reject(new Error(stderr.trim() || error.message));
|
|
85
100
|
return;
|
package/dist/core/init-plugin.js
CHANGED
|
@@ -162,8 +162,8 @@ export async function initPluginPackage(targetPath, options = {}) {
|
|
|
162
162
|
name: packageName,
|
|
163
163
|
version: "0.1.0",
|
|
164
164
|
description: "A Codex plugin package scaffolded by Codex Plugin Doctor.",
|
|
165
|
-
skills: "skills",
|
|
166
|
-
...(template === "skill-only" ? {} : { mcpServers: "
|
|
165
|
+
skills: "./skills",
|
|
166
|
+
...(template === "skill-only" ? {} : { mcpServers: "./.mcp.json" })
|
|
167
167
|
}, null, 2)}\n`, "utf8");
|
|
168
168
|
await writeFile(skillPath, buildSkillMarkdown(template), "utf8");
|
|
169
169
|
if (template === "mcp-stdio" || template === "full-runtime") {
|
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { readJsonFile } from "./read-json-file.js";
|
|
3
3
|
import { discoverPackage } from "./discover-package.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
7
|
-
function isPathWithinRoot(rootPath, candidatePath) {
|
|
8
|
-
const relativePath = path.relative(rootPath, candidatePath);
|
|
9
|
-
return (relativePath === "" ||
|
|
10
|
-
(!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
|
|
11
|
-
}
|
|
4
|
+
import { normalizeMcpConfig } from "./mcp-config-normalizer.js";
|
|
5
|
+
import { resolveContainedPackagePath } from "./package-path.js";
|
|
12
6
|
function buildFailure(targetPath, message, mcpConfigPath = null) {
|
|
13
7
|
return {
|
|
14
8
|
schemaVersion: "1.0.0",
|
|
@@ -28,9 +22,9 @@ export async function buildDoctorInspectorReport(targetPath, options = {}) {
|
|
|
28
22
|
if (!discoveredPackage?.manifest.mcpServers) {
|
|
29
23
|
return buildFailure(targetPath, "The target package does not declare an MCP server config in `.codex-plugin/plugin.json`.");
|
|
30
24
|
}
|
|
31
|
-
const mcpConfigPath =
|
|
32
|
-
if (!
|
|
33
|
-
return buildFailure(discoveredPackage.rootPath, "The target package points the MCP server config outside the package root.",
|
|
25
|
+
const mcpConfigPath = await resolveContainedPackagePath(discoveredPackage.rootPath, discoveredPackage.manifest.mcpServers);
|
|
26
|
+
if (!mcpConfigPath) {
|
|
27
|
+
return buildFailure(discoveredPackage.rootPath, "The target package points the MCP server config outside the package root.", null);
|
|
34
28
|
}
|
|
35
29
|
let parsedConfig;
|
|
36
30
|
try {
|
|
@@ -39,10 +33,11 @@ export async function buildDoctorInspectorReport(targetPath, options = {}) {
|
|
|
39
33
|
catch {
|
|
40
34
|
return buildFailure(discoveredPackage.rootPath, "The MCP server config could not be parsed as JSON.", mcpConfigPath);
|
|
41
35
|
}
|
|
42
|
-
|
|
36
|
+
const normalizedConfig = normalizeMcpConfig(parsedConfig);
|
|
37
|
+
if (!normalizedConfig.ok) {
|
|
43
38
|
return buildFailure(discoveredPackage.rootPath, "The MCP server config does not contain a valid `mcpServers` object.", mcpConfigPath);
|
|
44
39
|
}
|
|
45
|
-
const serverNames = Object.keys(
|
|
40
|
+
const serverNames = Object.keys(normalizedConfig.servers).sort();
|
|
46
41
|
const selectedServerName = options.serverName ?? serverNames[0] ?? null;
|
|
47
42
|
if (!selectedServerName || !serverNames.includes(selectedServerName)) {
|
|
48
43
|
return buildFailure(discoveredPackage.rootPath, options.serverName
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type McpServerConfig = Record<string, unknown>;
|
|
2
|
+
export type McpServerMap = Record<string, McpServerConfig>;
|
|
3
|
+
export type McpConfigLayout = "direct" | "snake_case_wrapper" | "camel_case_wrapper";
|
|
4
|
+
export type McpConfigNormalizationResult = {
|
|
5
|
+
ok: true;
|
|
6
|
+
layout: McpConfigLayout;
|
|
7
|
+
servers: McpServerMap;
|
|
8
|
+
} | {
|
|
9
|
+
ok: false;
|
|
10
|
+
reason: "ambiguous_shape";
|
|
11
|
+
field: "root";
|
|
12
|
+
} | {
|
|
13
|
+
ok: false;
|
|
14
|
+
reason: "invalid_shape";
|
|
15
|
+
field: "root" | "mcp_servers" | "mcpServers" | "server";
|
|
16
|
+
invalidServerNames?: string[];
|
|
17
|
+
};
|
|
18
|
+
export declare function normalizeMcpConfig(value: unknown): McpConfigNormalizationResult;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
function isPlainObject(value) {
|
|
2
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
const prototype = Object.getPrototypeOf(value);
|
|
6
|
+
return prototype === Object.prototype || prototype === null;
|
|
7
|
+
}
|
|
8
|
+
function normalizeServerMap(value, field, layout) {
|
|
9
|
+
if (!isPlainObject(value) || Object.keys(value).length === 0) {
|
|
10
|
+
return { ok: false, reason: "invalid_shape", field };
|
|
11
|
+
}
|
|
12
|
+
const invalidServerNames = [];
|
|
13
|
+
const servers = Object.create(null);
|
|
14
|
+
for (const [name, server] of Object.entries(value)) {
|
|
15
|
+
if (!isPlainObject(server)) {
|
|
16
|
+
invalidServerNames.push(name);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
servers[name] = server;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (invalidServerNames.length > 0) {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
reason: "invalid_shape",
|
|
26
|
+
field: "server",
|
|
27
|
+
invalidServerNames: invalidServerNames.sort()
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return { ok: true, layout, servers };
|
|
31
|
+
}
|
|
32
|
+
export function normalizeMcpConfig(value) {
|
|
33
|
+
if (!isPlainObject(value)) {
|
|
34
|
+
return { ok: false, reason: "invalid_shape", field: "root" };
|
|
35
|
+
}
|
|
36
|
+
const hasSnakeCaseWrapper = Object.hasOwn(value, "mcp_servers");
|
|
37
|
+
const hasCamelCaseWrapper = Object.hasOwn(value, "mcpServers");
|
|
38
|
+
if (hasSnakeCaseWrapper && hasCamelCaseWrapper) {
|
|
39
|
+
return { ok: false, reason: "ambiguous_shape", field: "root" };
|
|
40
|
+
}
|
|
41
|
+
if (hasSnakeCaseWrapper || hasCamelCaseWrapper) {
|
|
42
|
+
if (Object.keys(value).length !== 1) {
|
|
43
|
+
return { ok: false, reason: "ambiguous_shape", field: "root" };
|
|
44
|
+
}
|
|
45
|
+
return hasSnakeCaseWrapper
|
|
46
|
+
? normalizeServerMap(value.mcp_servers, "mcp_servers", "snake_case_wrapper")
|
|
47
|
+
: normalizeServerMap(value.mcpServers, "mcpServers", "camel_case_wrapper");
|
|
48
|
+
}
|
|
49
|
+
return normalizeServerMap(value, "root", "direct");
|
|
50
|
+
}
|
|
@@ -186,6 +186,82 @@ const publicSchemaDefinitions = [
|
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
},
|
|
189
|
+
{
|
|
190
|
+
id: "doctor.submission.json",
|
|
191
|
+
command: "codex-plugin-doctor doctor submission <path> --json",
|
|
192
|
+
required: [
|
|
193
|
+
"schemaVersion",
|
|
194
|
+
"rulesetVersion",
|
|
195
|
+
"targetType",
|
|
196
|
+
"status",
|
|
197
|
+
"readiness",
|
|
198
|
+
"summary",
|
|
199
|
+
"checks",
|
|
200
|
+
"findings",
|
|
201
|
+
"manualChecklist"
|
|
202
|
+
],
|
|
203
|
+
properties: {
|
|
204
|
+
rulesetVersion: { const: "openai-directory-2026-08-15" },
|
|
205
|
+
targetType: { type: "string", enum: ["skills-only", "mcp-backed"] },
|
|
206
|
+
status: { type: "string", enum: ["pass", "fail"] },
|
|
207
|
+
readiness: { type: "string", enum: ["blocked", "manual_review_required"] },
|
|
208
|
+
summary: {
|
|
209
|
+
type: "object",
|
|
210
|
+
required: ["passed", "warnings", "blockers", "manualChecks"],
|
|
211
|
+
properties: {
|
|
212
|
+
passed: { type: "integer", minimum: 0 },
|
|
213
|
+
warnings: { type: "integer", minimum: 0 },
|
|
214
|
+
blockers: { type: "integer", minimum: 0 },
|
|
215
|
+
manualChecks: { type: "integer", minimum: 0 }
|
|
216
|
+
},
|
|
217
|
+
additionalProperties: false
|
|
218
|
+
},
|
|
219
|
+
checks: {
|
|
220
|
+
type: "array",
|
|
221
|
+
items: {
|
|
222
|
+
type: "object",
|
|
223
|
+
required: ["id", "status", "findingIds"],
|
|
224
|
+
properties: {
|
|
225
|
+
id: { type: "string", enum: ["listing", "components", "assets", "skills"] },
|
|
226
|
+
status: { type: "string", enum: ["pass", "warn", "fail"] },
|
|
227
|
+
findingIds: { type: "array", items: { type: "string" } }
|
|
228
|
+
},
|
|
229
|
+
additionalProperties: false
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
findings: {
|
|
233
|
+
type: "array",
|
|
234
|
+
items: {
|
|
235
|
+
type: "object",
|
|
236
|
+
required: ["id", "severity", "message"],
|
|
237
|
+
properties: {
|
|
238
|
+
id: { type: "string", pattern: "^plugin\\.submission\\." },
|
|
239
|
+
severity: { type: "string", enum: ["warn", "fail"] },
|
|
240
|
+
message: { type: "string" },
|
|
241
|
+
portalCode: { type: "string" },
|
|
242
|
+
evidence: {
|
|
243
|
+
type: "object",
|
|
244
|
+
additionalProperties: { type: ["string", "number", "boolean", "null"] }
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
additionalProperties: false
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
manualChecklist: {
|
|
251
|
+
type: "array",
|
|
252
|
+
items: {
|
|
253
|
+
type: "object",
|
|
254
|
+
required: ["id", "label", "state"],
|
|
255
|
+
properties: {
|
|
256
|
+
id: { type: "string" },
|
|
257
|
+
label: { type: "string" },
|
|
258
|
+
state: { type: "string", enum: ["required", "not_applicable"] }
|
|
259
|
+
},
|
|
260
|
+
additionalProperties: false
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
},
|
|
189
265
|
{
|
|
190
266
|
id: "doctor.installed.check.json",
|
|
191
267
|
command: "codex-plugin-doctor check --installed --json",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function resolveContainedPackagePath(rootPath: string, candidatePath: string): Promise<string | null>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
function isPathWithinRoot(rootPath, candidatePath) {
|
|
4
|
+
const relativePath = path.relative(rootPath, candidatePath);
|
|
5
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
6
|
+
}
|
|
7
|
+
export async function resolveContainedPackagePath(rootPath, candidatePath) {
|
|
8
|
+
const resolvedRootPath = path.resolve(rootPath);
|
|
9
|
+
const resolvedCandidatePath = path.resolve(resolvedRootPath, candidatePath);
|
|
10
|
+
if (!isPathWithinRoot(resolvedRootPath, resolvedCandidatePath)) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const [canonicalRootPath, canonicalCandidatePath] = await Promise.all([
|
|
15
|
+
realpath(resolvedRootPath),
|
|
16
|
+
realpath(resolvedCandidatePath)
|
|
17
|
+
]);
|
|
18
|
+
return isPathWithinRoot(canonicalRootPath, canonicalCandidatePath)
|
|
19
|
+
? resolvedCandidatePath
|
|
20
|
+
: null;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return resolvedCandidatePath;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DiscoveredPackage, Finding } from "../domain/types.js";
|
|
2
|
+
export interface SafePackagePath {
|
|
3
|
+
path: string;
|
|
4
|
+
packagePath: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function resolveSafePackagePath(rootPath: string, value: string): Promise<SafePackagePath | null>;
|
|
7
|
+
export declare function validatePluginComponents(discoveredPackage: DiscoveredPackage): Promise<Finding[]>;
|