diff --git a/.changeset/yaml-config.md b/.changeset/yaml-config.md new file mode 100644 index 0000000..2fdbbec --- /dev/null +++ b/.changeset/yaml-config.md @@ -0,0 +1,5 @@ +--- +"@docker-doctor/cli": patch +--- + +Support `docker-doctor.config.yaml` / `.yml` config files (resolved after `.json`, and via `--config`). A JSON Schema generated from the rule set is published at https://docker-doctor.vercel.app/schema.json for editor autocomplete/validation in YAML (`# yaml-language-server: $schema=…`) and JSON (`"$schema"` key) configs. diff --git a/apps/web/app/schema.json/route.ts b/apps/web/app/schema.json/route.ts new file mode 100644 index 0000000..dfdb72c --- /dev/null +++ b/apps/web/app/schema.json/route.ts @@ -0,0 +1,61 @@ +import { allRules } from "@docker-doctor/core"; +import { NextResponse } from "next/server"; + +// Prerendered once per deploy, so the schema always matches the rule set the +// site was built against. +export const revalidate = false; + +const SEVERITY_ENUM = ["error", "warning", "info", "off"]; + +export const GET = (): NextResponse => { + const ruleProperties = Object.fromEntries( + allRules.map((rule) => [ + rule.key, + { description: rule.message, enum: SEVERITY_ENUM }, + ]) + ); + + const categoryProperties = Object.fromEntries( + [...new Set(allRules.map((rule) => rule.category))] + .toSorted() + .map((category) => [category, { enum: SEVERITY_ENUM }]) + ); + + const schema = { + $id: "https://docker-doctor.vercel.app/schema.json", + $schema: "http://json-schema.org/draft-07/schema#", + additionalProperties: false, + description: + "Configuration for the docker-doctor CLI (docker-doctor.config.{json,yaml,yml} or package.json#dockerDoctor).", + properties: { + $schema: { type: "string" }, + categories: { + additionalProperties: false, + description: "Override every rule in a category at once.", + properties: categoryProperties, + type: "object", + }, + ignore: { + additionalProperties: false, + properties: { + files: { + description: "Glob patterns for files to exclude from scanning.", + items: { type: "string" }, + type: "array", + }, + }, + type: "object", + }, + rules: { + additionalProperties: { enum: SEVERITY_ENUM }, + description: "Override the severity of an individual rule.", + properties: ruleProperties, + type: "object", + }, + }, + title: "Docker Doctor configuration", + type: "object", + }; + + return NextResponse.json(schema); +}; diff --git a/apps/web/content/docs/index.mdx b/apps/web/content/docs/index.mdx index 6917444..bb8c25d 100644 --- a/apps/web/content/docs/index.mdx +++ b/apps/web/content/docs/index.mdx @@ -34,7 +34,7 @@ Works with any project that uses Docker. } title="Fully configurable"> Per-rule and per-category severities, plus file ignores, via - `docker-doctor.config.ts`. + `docker-doctor.config.ts` — or YAML/JSON with a published schema. } title="CI-friendly"> Machine-readable `--json` output and a `--score` mode that exits non-zero diff --git a/apps/web/content/docs/reference/configuration.mdx b/apps/web/content/docs/reference/configuration.mdx index 47cc7fd..8291062 100644 --- a/apps/web/content/docs/reference/configuration.mdx +++ b/apps/web/content/docs/reference/configuration.mdx @@ -12,7 +12,9 @@ Unless you pass `--config `, docker-doctor looks for the first of these, i 3. `docker-doctor.config.mjs` 4. `docker-doctor.config.cjs` 5. `docker-doctor.config.json` -6. a `"dockerDoctor"` key in `package.json` +6. `docker-doctor.config.yaml` +7. `docker-doctor.config.yml` +8. a `"dockerDoctor"` key in `package.json` If none of these exist, docker-doctor runs with its built-in defaults. @@ -45,6 +47,34 @@ export default { The five valid `categories` keys are `Security`, `Performance`, `Best Practices`, `Compose`, and `Image Size`. +## YAML and JSON configs + +If you'd rather stay in YAML (you're writing Compose files anyway), the same shape works as `docker-doctor.config.yaml` — no `package.json` or `"type": "module"` required: + +```yaml +# yaml-language-server: $schema=https://docker-doctor.vercel.app/schema.json +rules: + "docker-doctor/no-root-user": error +categories: + "Image Size": "off" +ignore: + files: + - "examples/**/Dockerfile" +``` + +The `yaml-language-server` comment gives you autocomplete and validation in editors with the YAML extension. JSON configs get the same via a `$schema` key: + +```json +{ + "$schema": "https://docker-doctor.vercel.app/schema.json", + "rules": { + "docker-doctor/no-root-user": "error" + } +} +``` + +The schema at [`/schema.json`](https://docker-doctor.vercel.app/schema.json) is generated from the built-in rule set, so every rule key and category autocompletes with its description. + ## Typed config `@docker-doctor/cli` exports the `DockerDoctorConfig` type and a `defineConfig` helper. Which one to use depends on how you run docker-doctor: diff --git a/apps/web/package.json b/apps/web/package.json index 5a2a3e4..2e4c245 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "dependencies": { "@base-ui/react": "^1.6.0", "@databuddy/sdk": "^2.6.0", + "@docker-doctor/core": "workspace:*", "cnfast": "^0.0.8", "date-fns": "^4.4.0", "fumadocs-core": "^16.12.1", diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 6ec0af3..23ca9fd 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, diff --git a/bun.lock b/bun.lock index c887dd8..d708e54 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "dependencies": { "@base-ui/react": "^1.6.0", "@databuddy/sdk": "^2.6.0", + "@docker-doctor/core": "workspace:*", "cnfast": "^0.0.8", "date-fns": "^4.4.0", "fumadocs-core": "^16.12.1", diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 78def5a..cf99738 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -1,6 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { parse as parseYaml } from "yaml"; + import { ConfigError } from "../errors"; import type { DockerDoctorConfig } from "../schemas/config"; import { validateConfig } from "../schemas/config"; @@ -14,17 +16,29 @@ const fileExists = async (filePath: string): Promise => { } }; +const parseConfigFile = async ( + filePath: string, + format: "JSON" | "YAML", + parse: (content: string) => unknown +): Promise => { + try { + const content = await fs.readFile(filePath, "utf-8"); + return parse(content); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + throw new ConfigError({ + message: `Failed to parse config ${format}: ${msg}`, + }); + } +}; + const importConfig = async (filePath: string): Promise => { if (filePath.endsWith(".json")) { - try { - const content = await fs.readFile(filePath, "utf-8"); - return JSON.parse(content); - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : String(error); - throw new ConfigError({ - message: `Failed to parse config JSON: ${msg}`, - }); - } + return parseConfigFile(filePath, "JSON", JSON.parse); + } + + if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) { + return parseConfigFile(filePath, "YAML", parseYaml); } try { @@ -59,6 +73,8 @@ export const loadConfig = async ( "docker-doctor.config.mjs", "docker-doctor.config.cjs", "docker-doctor.config.json", + "docker-doctor.config.yaml", + "docker-doctor.config.yml", ]; /* eslint-disable no-await-in-loop */ diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index 3c3222f..8f9f974 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -5,15 +5,6 @@ import path from "node:path"; import { loadConfig } from "../src/config/loader"; import { ConfigError } from "../src/errors"; -import type { DockerDoctorConfig } from "../src/schemas/config"; - -// The `DockerDoctorConfig["categories"]` type is a full `Record` (all 5 keys), but a valid runtime config may legitimately -// set only a subset. `as unknown as DockerDoctorConfig` below silences that -// pre-existing type-vs-runtime mismatch without altering what is actually -// compared at runtime (still a plain deep-equal on the literal object). -const expectPartialCategories = (value: unknown): DockerDoctorConfig => - value as unknown as DockerDoctorConfig; describe("loadConfig", () => { let dir: string; @@ -46,9 +37,7 @@ describe("loadConfig", () => { JSON.stringify({ categories: { Security: "warning" } }) ); const config = await loadConfig(dir); - expect(config).toEqual( - expectPartialCategories({ categories: { Security: "warning" } }) - ); + expect(config).toEqual({ categories: { Security: "warning" } }); }); test("returns valid ignore.files as-is", async () => { @@ -149,11 +138,9 @@ describe("loadConfig", () => { }) ); const config = await loadConfig(dir); - expect(config).toEqual( - expectPartialCategories({ - categories: { "Best Practices": "warning" }, - }) - ); + expect(config).toEqual({ + categories: { "Best Practices": "warning" }, + }); }); test("silently strips unknown keys nested under ignore", async () => { @@ -166,4 +153,75 @@ describe("loadConfig", () => { const config = await loadConfig(dir); expect(config).toEqual({ ignore: { files: ["a"] } }); }); + + test("loads a .yaml config", async () => { + await fs.writeFile( + path.join(dir, "docker-doctor.config.yaml"), + [ + "rules:", + ' "docker-doctor/no-root-user": error', + "categories:", + ' "Image Size": "off"', + "ignore:", + " files:", + ' - "examples/**/Dockerfile"', + "", + ].join("\n") + ); + const config = await loadConfig(dir); + expect(config).toEqual({ + categories: { "Image Size": "off" }, + ignore: { files: ["examples/**/Dockerfile"] }, + rules: { "docker-doctor/no-root-user": "error" }, + }); + }); + + test("loads a .yml config", async () => { + await fs.writeFile( + path.join(dir, "docker-doctor.config.yml"), + 'rules:\n "docker-doctor/no-root-user": warning\n' + ); + const config = await loadConfig(dir); + expect(config).toEqual({ + rules: { "docker-doctor/no-root-user": "warning" }, + }); + }); + + test("prefers .json config over .yaml when both exist", async () => { + await fs.writeFile( + path.join(dir, "docker-doctor.config.json"), + JSON.stringify({ rules: { "from-json": "error" } }) + ); + await fs.writeFile( + path.join(dir, "docker-doctor.config.yaml"), + 'rules:\n "from-yaml": warning\n' + ); + const config = await loadConfig(dir); + expect(config).toEqual({ rules: { "from-json": "error" } }); + }); + + test("throws ConfigError on malformed YAML", async () => { + await fs.writeFile( + path.join(dir, "docker-doctor.config.yaml"), + "rules:\n bad: [unclosed\n" + ); + await expect(loadConfig(dir)).rejects.toThrow(ConfigError); + }); + + test("throws ConfigError on invalid severity in YAML", async () => { + await fs.writeFile( + path.join(dir, "docker-doctor.config.yaml"), + 'rules:\n "no-latest-tag": banana\n' + ); + await expect(loadConfig(dir)).rejects.toThrow(ConfigError); + }); + + test("loads a .yaml config via --config custom path", async () => { + await fs.writeFile( + path.join(dir, "custom.yaml"), + "categories:\n Security: error\n" + ); + const config = await loadConfig(dir, "custom.yaml"); + expect(config).toEqual({ categories: { Security: "error" } }); + }); }); diff --git a/packages/docker-doctor/README.md b/packages/docker-doctor/README.md index 0d2e2d1..abb89a7 100644 --- a/packages/docker-doctor/README.md +++ b/packages/docker-doctor/README.md @@ -61,7 +61,7 @@ export default { } satisfies DockerDoctorConfig; ``` -A `defineConfig` helper is also exported for projects with `@docker-doctor/cli` installed — see the [configuration docs](https://docker-doctor.vercel.app/docs/reference/configuration). +Prefer YAML? `docker-doctor.config.yaml` works too, with editor autocomplete via `# yaml-language-server: $schema=https://docker-doctor.vercel.app/schema.json`. A `defineConfig` helper is also exported for projects with `@docker-doctor/cli` installed — see the [configuration docs](https://docker-doctor.vercel.app/docs/reference/configuration). ## How the score works diff --git a/skills/docker-doctor/references/explain.md b/skills/docker-doctor/references/explain.md index 3d18d8a..3a151c4 100644 --- a/skills/docker-doctor/references/explain.md +++ b/skills/docker-doctor/references/explain.md @@ -14,7 +14,7 @@ npx @docker-doctor/cli@latest rules explain docker-doctor/no-root-user ``` 3. Pick the narrowest change that matches the user's intent (see decision guide). -4. **Hand-edit `docker-doctor.config.ts`** (or `.js`/`.mjs`/`.cjs`/`.json`, or the `dockerDoctor` key in `package.json`). There is no `rules disable`/`set`/`category` CLI subcommand — configuration is edited by hand. +4. **Hand-edit `docker-doctor.config.ts`** (or `.js`/`.mjs`/`.cjs`/`.json`/`.yaml`/`.yml`, or the `dockerDoctor` key in `package.json`). There is no `rules disable`/`set`/`category` CLI subcommand — configuration is edited by hand. 5. Validate the change did what they wanted: ```bash @@ -32,7 +32,7 @@ npx @docker-doctor/cli@latest rules explain # why it matters + how to ## Config shape -Config lives in `docker-doctor.config.{ts,js,mjs,cjs,json}` or the `dockerDoctor` key in `package.json`. Three maps: +Config lives in `docker-doctor.config.{ts,js,mjs,cjs,json,yaml,yml}` or the `dockerDoctor` key in `package.json`. Match the file format the project already uses. Three maps (same shape in every format): ```ts // docker-doctor.config.ts