@readme/cli 0.0.26
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/README.md +55 -0
- package/bin/readme.js +8 -0
- package/package.json +58 -0
- package/src/bootstrap.js +97 -0
- package/src/cli.js +189 -0
- package/src/commands/dev.js +119 -0
- package/src/commands/eyes.js +37 -0
- package/src/commands/import.js +2565 -0
- package/src/commands/lint.js +70 -0
- package/src/commands/oas-sync.js +364 -0
- package/src/commands/oas-validate.js +208 -0
- package/src/commands/play.js +17 -0
- package/src/commands/pretty.js +133 -0
- package/src/commands/setup.js +256 -0
- package/src/commands/versions.js +81 -0
- package/src/dev/.next/app-build-manifest.json +20 -0
- package/src/dev/.next/build-manifest.json +31 -0
- package/src/dev/.next/cache/.rscinfo +1 -0
- package/src/dev/.next/cache/next-devtools-config.json +1 -0
- package/src/dev/.next/cache/webpack/client-development/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/10.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/11.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/2.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/3.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/3.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/client-development/4.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/5.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/5.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/client-development/6.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/7.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/7.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/client-development/8.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/9.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz.old +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz.old +0 -0
- package/src/dev/.next/cache/webpack/server-development/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/10.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/11.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/12.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/13.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/14.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/15.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/2.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/2.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/3.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/3.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/4.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/5.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/6.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/6.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/7.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/7.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/8.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/9.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/9.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/index.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
- package/src/dev/.next/package.json +1 -0
- package/src/dev/.next/prerender-manifest.json +11 -0
- package/src/dev/.next/react-loadable-manifest.json +1 -0
- package/src/dev/.next/routes-manifest.json +1 -0
- package/src/dev/.next/server/app/[...slug]/page.js +360 -0
- package/src/dev/.next/server/app/[...slug]/page_client-reference-manifest.js +1 -0
- package/src/dev/.next/server/app/page.js +349 -0
- package/src/dev/.next/server/app/page_client-reference-manifest.js +1 -0
- package/src/dev/.next/server/app-paths-manifest.json +3 -0
- package/src/dev/.next/server/edge-runtime-webpack.js +1151 -0
- package/src/dev/.next/server/interception-route-rewrite-manifest.js +1 -0
- package/src/dev/.next/server/middleware-build-manifest.js +33 -0
- package/src/dev/.next/server/middleware-manifest.json +32 -0
- package/src/dev/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/src/dev/.next/server/middleware.js +1113 -0
- package/src/dev/.next/server/next-font-manifest.js +1 -0
- package/src/dev/.next/server/next-font-manifest.json +1 -0
- package/src/dev/.next/server/pages-manifest.json +5 -0
- package/src/dev/.next/server/server-reference-manifest.js +1 -0
- package/src/dev/.next/server/server-reference-manifest.json +5 -0
- package/src/dev/.next/server/static/webpack/633457081244afec._.hot-update.json +1 -0
- package/src/dev/.next/server/vendor-chunks/@readme.js +25 -0
- package/src/dev/.next/server/vendor-chunks/@swc.js +55 -0
- package/src/dev/.next/server/vendor-chunks/next.js +3659 -0
- package/src/dev/.next/server/webpack-runtime.js +209 -0
- package/src/dev/.next/static/chunks/app/[...slug]/loading.js +28 -0
- package/src/dev/.next/static/chunks/app/[...slug]/page.js +28 -0
- package/src/dev/.next/static/chunks/app/layout.js +171 -0
- package/src/dev/.next/static/chunks/app/page.js +28 -0
- package/src/dev/.next/static/chunks/app-pages-internals.js +182 -0
- package/src/dev/.next/static/chunks/main-app.js +1882 -0
- package/src/dev/.next/static/chunks/polyfills.js +1 -0
- package/src/dev/.next/static/chunks/webpack.js +1393 -0
- package/src/dev/.next/static/css/app/layout.css +559 -0
- package/src/dev/.next/static/development/_buildManifest.js +1 -0
- package/src/dev/.next/static/development/_ssgManifest.js +1 -0
- package/src/dev/.next/static/webpack/633457081244afec._.hot-update.json +1 -0
- package/src/dev/.next/static/webpack/ec52a3fce0f78db0.webpack.hot-update.json +1 -0
- package/src/dev/.next/static/webpack/webpack.ec52a3fce0f78db0.hot-update.js +12 -0
- package/src/dev/.next/trace +21 -0
- package/src/dev/.next/types/app/[...slug]/page.ts +84 -0
- package/src/dev/.next/types/app/layout.ts +84 -0
- package/src/dev/.next/types/app/page.ts +84 -0
- package/src/dev/.next/types/cache-life.d.ts +141 -0
- package/src/dev/.next/types/package.json +1 -0
- package/src/dev/.next/types/routes.d.ts +55 -0
- package/src/dev/app/Sidebar.js +149 -0
- package/src/dev/app/[...slug]/loading.js +16 -0
- package/src/dev/app/[...slug]/page.js +43 -0
- package/src/dev/app/globals.css +167 -0
- package/src/dev/app/layout.js +73 -0
- package/src/dev/app/page.js +19 -0
- package/src/dev/lib/docs.js +337 -0
- package/src/dev/middleware.js +7 -0
- package/src/dev/next.config.mjs +22 -0
- package/src/index.js +12 -0
- package/src/prompts/index.js +352 -0
- package/src/utils/claude.js +15 -0
- package/src/utils/eyes.js +365 -0
- package/src/utils/git.js +143 -0
- package/src/utils/lint.js +99 -0
- package/src/utils/reporter.js +319 -0
- package/src/utils/setup-templates.js +323 -0
- package/src/utils/styles.js +50 -0
- package/src/utils/tamagotchi.js +1139 -0
- package/src/utils/tips.js +90 -0
- package/src/validators/components.js +230 -0
- package/src/validators/content.js +53 -0
- package/src/validators/duplicates.js +45 -0
- package/src/validators/frontmatter.js +247 -0
- package/src/validators/links.js +68 -0
- package/src/validators/nesting.js +50 -0
- package/src/validators/numbering.js +136 -0
- package/src/validators/oas-reference.js +126 -0
- package/src/validators/oas-schema.js +106 -0
- package/src/validators/ordering.js +121 -0
- package/src/validators/recipes.js +143 -0
- package/vendor/TOOLS.md +19 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { collectFiles, runValidators } from '../utils/lint.js';
|
|
3
|
+
import { createHumanReporter, createJsonReporter, createGithubReporter } from '../utils/reporter.js';
|
|
4
|
+
import { printHeader, isAgenticCli } from '../utils/eyes.js';
|
|
5
|
+
import * as styles from '../utils/styles.js';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const pkg = require('../../package.json');
|
|
9
|
+
|
|
10
|
+
export const command = 'lint';
|
|
11
|
+
export const order = 1;
|
|
12
|
+
export const aliases = ['validate'];
|
|
13
|
+
export const category = 'Linting';
|
|
14
|
+
export const description = 'Lint and validate your ReadMe docs';
|
|
15
|
+
|
|
16
|
+
export function args(cmd) {
|
|
17
|
+
cmd.option('--json', 'Output results as JSON (for CI and automation)');
|
|
18
|
+
cmd.option('--github', 'Output a GitHub PR comment body as markdown');
|
|
19
|
+
cmd.option('--fix', 'Automatically fix issues where possible');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run the linter programmatically. Returns the collected files and validator
|
|
24
|
+
* results with no console output and no process.exit — callers can format and
|
|
25
|
+
* exit on their own terms.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} [opts]
|
|
28
|
+
* @param {string} [opts.cwd] Repo root to lint (defaults to process.cwd()).
|
|
29
|
+
* @param {boolean} [opts.fix] Apply autofixes where validators support it.
|
|
30
|
+
* @param {(file: string) => void} [opts.onFile] Called per file before validation.
|
|
31
|
+
* @param {() => void} [opts.onBeforeCrossFile] Called once before cross-file checks run.
|
|
32
|
+
* @returns {Promise<{ gitRoot: string, files: string[], results: object[], hasErrors: boolean }>}
|
|
33
|
+
*/
|
|
34
|
+
export async function lint({ cwd, fix = false, onFile, onBeforeCrossFile } = {}) {
|
|
35
|
+
const gitRoot = cwd || process.cwd();
|
|
36
|
+
const files = collectFiles(gitRoot);
|
|
37
|
+
const results = await runValidators(files, gitRoot, { onFile, onBeforeCrossFile, fix });
|
|
38
|
+
const hasErrors = results.some((r) => r.severity !== 'warning');
|
|
39
|
+
return { gitRoot, files, results, hasErrors };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function run(options, _cmd, ctx) {
|
|
43
|
+
const { gitRoot } = ctx;
|
|
44
|
+
|
|
45
|
+
if (!options.json && !options.github && !isAgenticCli()) {
|
|
46
|
+
printHeader({ version: pkg.version, binName: styles.binName(), indent: ' ' });
|
|
47
|
+
console.log();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const reporter = options.github
|
|
51
|
+
? createGithubReporter()
|
|
52
|
+
: options.json
|
|
53
|
+
? createJsonReporter()
|
|
54
|
+
: createHumanReporter();
|
|
55
|
+
|
|
56
|
+
const { files, results, hasErrors } = await lint({
|
|
57
|
+
cwd: gitRoot,
|
|
58
|
+
fix: options.fix,
|
|
59
|
+
onFile: (f) => reporter.onFile(f),
|
|
60
|
+
onBeforeCrossFile: () => reporter.pause(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
reporter.finish(files.length, results, files, { fix: options.fix, gitRoot });
|
|
64
|
+
|
|
65
|
+
if (hasErrors) {
|
|
66
|
+
// Wait for stdout to flush (important when piped to a file), then exit
|
|
67
|
+
await new Promise((resolve) => process.stdout.write('', resolve));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import matter from 'gray-matter';
|
|
5
|
+
import * as styles from '../utils/styles.js';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const yaml = require('js-yaml');
|
|
9
|
+
|
|
10
|
+
export const command = 'oas:sync';
|
|
11
|
+
export const order = 2;
|
|
12
|
+
export const category = 'OAS Tooling';
|
|
13
|
+
export const description = 'Sync reference pages with OpenAPI specs';
|
|
14
|
+
|
|
15
|
+
const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Check if this is a ReadMeConfig spec (internal ReadMe pages — skip title/excerpt updates).
|
|
19
|
+
*/
|
|
20
|
+
function isReadMeConfig(spec) {
|
|
21
|
+
return spec.info?.title === 'ReadMeConfig';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Find OAS files at the root of reference/ (JSON or YAML).
|
|
26
|
+
*/
|
|
27
|
+
export function findOasFiles(refDir) {
|
|
28
|
+
const entries = fs.readdirSync(refDir);
|
|
29
|
+
const oasFiles = [];
|
|
30
|
+
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
if (!/\.(json|yaml|yml)$/i.test(entry)) continue;
|
|
33
|
+
const filePath = path.join(refDir, entry);
|
|
34
|
+
if (!fs.statSync(filePath).isFile()) continue;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
38
|
+
const parsed = entry.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw);
|
|
39
|
+
|
|
40
|
+
if (parsed && (parsed.openapi || parsed.swagger)) {
|
|
41
|
+
oasFiles.push({ filename: entry, spec: parsed });
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// Skip files that can't be parsed.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return oasFiles;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generate a synthetic operationId from the HTTP method and path.
|
|
53
|
+
* Matches the algorithm used by the `oas` package for specs without operationIds.
|
|
54
|
+
*/
|
|
55
|
+
function generateOperationId(method, pathStr) {
|
|
56
|
+
const sanitized = pathStr
|
|
57
|
+
.replace(/[^a-zA-Z0-9]/g, '-')
|
|
58
|
+
.replace(/--+/g, '-')
|
|
59
|
+
.replace(/^-|-$/g, '')
|
|
60
|
+
.toLowerCase();
|
|
61
|
+
return `${method.toLowerCase()}_${sanitized}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extract operations from an OAS spec.
|
|
66
|
+
* Returns a Map of operationId -> { summary, description, tag, operationId }.
|
|
67
|
+
* For operations without an operationId, a synthetic one is generated from the method and path.
|
|
68
|
+
*/
|
|
69
|
+
export function extractOperations(spec) {
|
|
70
|
+
const ops = new Map();
|
|
71
|
+
const paths = spec.paths || {};
|
|
72
|
+
|
|
73
|
+
for (const [pathStr, methods] of Object.entries(paths)) {
|
|
74
|
+
for (const [method, operation] of Object.entries(methods)) {
|
|
75
|
+
if (!HTTP_METHODS.has(method)) continue;
|
|
76
|
+
|
|
77
|
+
const operationId = operation.operationId || generateOperationId(method, pathStr);
|
|
78
|
+
|
|
79
|
+
ops.set(operationId, {
|
|
80
|
+
operationId,
|
|
81
|
+
summary: operation.summary || null,
|
|
82
|
+
description: operation.description || null,
|
|
83
|
+
tag: (operation.tags && operation.tags[0]) || null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return ops;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Scan existing .md files under reference/ recursively and collect those
|
|
93
|
+
* with api.file + api.operationId frontmatter.
|
|
94
|
+
*/
|
|
95
|
+
export function collectExistingPages(refDir) {
|
|
96
|
+
const pages = [];
|
|
97
|
+
|
|
98
|
+
function walk(dir) {
|
|
99
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
100
|
+
const full = path.join(dir, entry);
|
|
101
|
+
const stat = fs.statSync(full);
|
|
102
|
+
if (stat.isDirectory()) {
|
|
103
|
+
walk(full);
|
|
104
|
+
} else if (entry.endsWith('.md')) {
|
|
105
|
+
try {
|
|
106
|
+
const content = fs.readFileSync(full, 'utf-8');
|
|
107
|
+
const { data } = matter(content);
|
|
108
|
+
if (data.api && data.api.file && data.api.operationId) {
|
|
109
|
+
pages.push({
|
|
110
|
+
filePath: full,
|
|
111
|
+
relativePath: path.relative(refDir, full),
|
|
112
|
+
data,
|
|
113
|
+
content,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Skip unparseable files.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
walk(refDir);
|
|
124
|
+
return pages;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Values that YAML interprets as non-strings and need quoting in _order.yaml.
|
|
128
|
+
const YAML_UNSAFE = /^(?:\d+\.?\d*|true|false|yes|no|on|off|null|~)$/i;
|
|
129
|
+
function yamlSafeSlug(slug) {
|
|
130
|
+
return YAML_UNSAFE.test(slug) ? `"${slug}"` : slug;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseOrderYaml(content) {
|
|
134
|
+
return content
|
|
135
|
+
.split('\n')
|
|
136
|
+
.map((line) => line.trim())
|
|
137
|
+
.filter((line) => line.startsWith('- '))
|
|
138
|
+
.map((line) => line.slice(2).trim().replace(/^["'](.+)["']$/, '$1'));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function writeOrderYaml(filePath, slugs) {
|
|
142
|
+
const content = slugs.map((s) => `- ${yamlSafeSlug(s)}`).join('\n') + '\n';
|
|
143
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
144
|
+
fs.writeFileSync(filePath, content);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function addToOrder(orderPath, slug) {
|
|
148
|
+
if (fs.existsSync(orderPath)) {
|
|
149
|
+
const content = fs.readFileSync(orderPath, 'utf-8');
|
|
150
|
+
const slugs = parseOrderYaml(content);
|
|
151
|
+
if (!slugs.includes(slug)) {
|
|
152
|
+
slugs.push(slug);
|
|
153
|
+
writeOrderYaml(orderPath, slugs);
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
writeOrderYaml(orderPath, [slug]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function removeFromOrder(orderPath, slug) {
|
|
161
|
+
if (!fs.existsSync(orderPath)) return;
|
|
162
|
+
const content = fs.readFileSync(orderPath, 'utf-8');
|
|
163
|
+
const slugs = parseOrderYaml(content).filter((s) => s !== slug);
|
|
164
|
+
if (slugs.length > 0) {
|
|
165
|
+
writeOrderYaml(orderPath, slugs);
|
|
166
|
+
} else {
|
|
167
|
+
fs.unlinkSync(orderPath);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function buildPageContent({ oasFilename, operationId, summary, description }) {
|
|
172
|
+
const frontmatter = {
|
|
173
|
+
title: summary || operationId,
|
|
174
|
+
api: {
|
|
175
|
+
file: oasFilename,
|
|
176
|
+
operationId,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
if (description) {
|
|
181
|
+
frontmatter.excerpt = description;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return matter.stringify('', frontmatter);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Run the sync for a single OAS file. Returns changes for that file.
|
|
189
|
+
*/
|
|
190
|
+
function syncOneOas(refDir, oasFilename, spec) {
|
|
191
|
+
const specOps = extractOperations(spec);
|
|
192
|
+
const infoTitle = spec.info?.title || path.basename(oasFilename, path.extname(oasFilename));
|
|
193
|
+
const skipUpdates = isReadMeConfig(spec);
|
|
194
|
+
|
|
195
|
+
const existingPages = collectExistingPages(refDir).filter(
|
|
196
|
+
(p) => p.data.api.file === oasFilename,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const pagesByOpId = new Map();
|
|
200
|
+
for (const page of existingPages) {
|
|
201
|
+
pagesByOpId.set(page.data.api.operationId, page);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const changes = { added: [], deleted: [], updated: [] };
|
|
205
|
+
|
|
206
|
+
// Deletes: pages referencing operations that no longer exist.
|
|
207
|
+
for (const [opId, page] of pagesByOpId) {
|
|
208
|
+
if (!specOps.has(opId)) {
|
|
209
|
+
fs.unlinkSync(page.filePath);
|
|
210
|
+
|
|
211
|
+
const pageDir = path.dirname(page.filePath);
|
|
212
|
+
const slug = path.basename(page.filePath, '.md');
|
|
213
|
+
removeFromOrder(path.join(pageDir, '_order.yaml'), slug);
|
|
214
|
+
|
|
215
|
+
changes.deleted.push(page.relativePath);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Adds + Updates.
|
|
220
|
+
for (const [opId, op] of specOps) {
|
|
221
|
+
const existing = pagesByOpId.get(opId);
|
|
222
|
+
const tag = op.tag || 'Other';
|
|
223
|
+
|
|
224
|
+
if (!existing) {
|
|
225
|
+
const pageDir = path.join(refDir, infoTitle, tag);
|
|
226
|
+
fs.mkdirSync(pageDir, { recursive: true });
|
|
227
|
+
|
|
228
|
+
const pagePath = path.join(pageDir, `${opId}.md`);
|
|
229
|
+
const content = buildPageContent({
|
|
230
|
+
oasFilename,
|
|
231
|
+
operationId: opId,
|
|
232
|
+
summary: op.summary,
|
|
233
|
+
description: op.description,
|
|
234
|
+
});
|
|
235
|
+
fs.writeFileSync(pagePath, content);
|
|
236
|
+
|
|
237
|
+
addToOrder(path.join(pageDir, '_order.yaml'), opId);
|
|
238
|
+
addToOrder(path.join(refDir, infoTitle, '_order.yaml'), tag);
|
|
239
|
+
|
|
240
|
+
changes.added.push(path.relative(refDir, pagePath));
|
|
241
|
+
} else if (!skipUpdates) {
|
|
242
|
+
const expectedTitle = op.summary || opId;
|
|
243
|
+
const expectedExcerpt = op.description || null;
|
|
244
|
+
const currentTitle = existing.data.title;
|
|
245
|
+
const currentExcerpt = existing.data.excerpt || null;
|
|
246
|
+
|
|
247
|
+
const titleChanged = currentTitle !== expectedTitle;
|
|
248
|
+
const excerptChanged = currentExcerpt !== expectedExcerpt;
|
|
249
|
+
|
|
250
|
+
if (titleChanged || excerptChanged) {
|
|
251
|
+
const updated = { ...existing.data };
|
|
252
|
+
const updateDetails = [];
|
|
253
|
+
|
|
254
|
+
if (titleChanged) {
|
|
255
|
+
updated.title = expectedTitle;
|
|
256
|
+
updateDetails.push('title');
|
|
257
|
+
}
|
|
258
|
+
if (excerptChanged) {
|
|
259
|
+
if (expectedExcerpt) {
|
|
260
|
+
updated.excerpt = expectedExcerpt;
|
|
261
|
+
} else {
|
|
262
|
+
delete updated.excerpt;
|
|
263
|
+
}
|
|
264
|
+
updateDetails.push('excerpt');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const body = matter(existing.content).content;
|
|
268
|
+
const newContent = matter.stringify(body, updated);
|
|
269
|
+
fs.writeFileSync(existing.filePath, newContent);
|
|
270
|
+
|
|
271
|
+
changes.updated.push(`${existing.relativePath} (${updateDetails.join(', ')})`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return changes;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Sync reference pages with the OpenAPI spec(s) under `<gitRoot>/reference/`.
|
|
281
|
+
*
|
|
282
|
+
* Pure programmatic API: returns per-file change descriptors and prints
|
|
283
|
+
* nothing. Used by the CLI command, the lint --fix flow, and external
|
|
284
|
+
* callers.
|
|
285
|
+
*
|
|
286
|
+
* @param {string | { cwd?: string }} input Repo root path, or `{ cwd }` object.
|
|
287
|
+
* @returns {null | Array<{ filename: string, spec: object, opCount: number,
|
|
288
|
+
* changes: { added: string[], deleted: string[], updated: string[] } }>}
|
|
289
|
+
* Returns null if there's no reference/ dir or no specs.
|
|
290
|
+
*/
|
|
291
|
+
export function syncOas(input) {
|
|
292
|
+
const gitRoot = typeof input === 'string' ? input : (input?.cwd || process.cwd());
|
|
293
|
+
const refDir = path.join(gitRoot, 'reference');
|
|
294
|
+
if (!fs.existsSync(refDir)) return null;
|
|
295
|
+
|
|
296
|
+
const oasFiles = findOasFiles(refDir);
|
|
297
|
+
if (oasFiles.length === 0) return null;
|
|
298
|
+
|
|
299
|
+
const allChanges = [];
|
|
300
|
+
|
|
301
|
+
for (const { filename, spec } of oasFiles) {
|
|
302
|
+
const ops = extractOperations(spec);
|
|
303
|
+
const changes = syncOneOas(refDir, filename, spec);
|
|
304
|
+
allChanges.push({ filename, spec, opCount: ops.size, changes });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return allChanges;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function run(_options, _cmd, ctx) {
|
|
311
|
+
const { gitRoot } = ctx;
|
|
312
|
+
const refDir = path.join(gitRoot, 'reference');
|
|
313
|
+
|
|
314
|
+
if (!fs.existsSync(refDir)) {
|
|
315
|
+
styles.error('No reference/ directory found.');
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const results = syncOas(gitRoot);
|
|
320
|
+
|
|
321
|
+
if (!results) {
|
|
322
|
+
styles.info('No OpenAPI spec files found in reference/.');
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let totalAdded = 0;
|
|
327
|
+
let totalDeleted = 0;
|
|
328
|
+
let totalUpdated = 0;
|
|
329
|
+
|
|
330
|
+
for (const { filename, spec, opCount, changes } of results) {
|
|
331
|
+
const title = spec.info?.title || filename;
|
|
332
|
+
const hasChanges = changes.added.length + changes.deleted.length + changes.updated.length > 0;
|
|
333
|
+
|
|
334
|
+
const dot = hasChanges ? styles.warn('●') : styles.success('●');
|
|
335
|
+
console.log();
|
|
336
|
+
console.log(` ${dot} ${styles.bold(title)} ${styles.dim(`(${filename} · ${opCount} ${opCount === 1 ? 'endpoint' : 'endpoints'})`)}`);
|
|
337
|
+
|
|
338
|
+
if (!hasChanges) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (const file of changes.added) {
|
|
343
|
+
console.log(` ${styles.success('+')} Added ${file}`);
|
|
344
|
+
}
|
|
345
|
+
for (const file of changes.deleted) {
|
|
346
|
+
console.log(` ${styles.err('−')} Deleted ${file}`);
|
|
347
|
+
}
|
|
348
|
+
for (const file of changes.updated) {
|
|
349
|
+
console.log(` ${styles.warn('~')} Updated ${file}`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
totalAdded += changes.added.length;
|
|
353
|
+
totalDeleted += changes.deleted.length;
|
|
354
|
+
totalUpdated += changes.updated.length;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
console.log();
|
|
358
|
+
const total = totalAdded + totalDeleted + totalUpdated;
|
|
359
|
+
if (total === 0) {
|
|
360
|
+
styles.ok('Reference pages are already in sync.');
|
|
361
|
+
} else {
|
|
362
|
+
styles.ok(`Synced: ${totalAdded} added, ${totalDeleted} deleted, ${totalUpdated} updated.`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import OASNormalize from 'oas-normalize';
|
|
4
|
+
import { findOasFiles, extractOperations } from './oas-sync.js';
|
|
5
|
+
import * as styles from '../utils/styles.js';
|
|
6
|
+
|
|
7
|
+
export const command = 'oas:validate';
|
|
8
|
+
export const order = 3;
|
|
9
|
+
export const category = 'OAS Tooling';
|
|
10
|
+
export const description = 'Validate OpenAPI spec files';
|
|
11
|
+
|
|
12
|
+
export function args(cmd) {
|
|
13
|
+
cmd.option('--dereference', 'Also dereference all $ref pointers (matches ReadMe server validation)');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Walk a parsed spec object and find $ref values that look malformed.
|
|
18
|
+
* Valid internal refs start with "#/", valid external refs don't start with "#".
|
|
19
|
+
* Catches things like "#components/schemas/Foo" (missing the slash after #).
|
|
20
|
+
*/
|
|
21
|
+
function findBadRefs(obj, pointer = '') {
|
|
22
|
+
const issues = [];
|
|
23
|
+
if (obj && typeof obj === 'object') {
|
|
24
|
+
if (typeof obj.$ref === 'string') {
|
|
25
|
+
const ref = obj.$ref;
|
|
26
|
+
if (ref.startsWith('#') && !ref.startsWith('#/')) {
|
|
27
|
+
issues.push({ path: pointer, ref });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
31
|
+
if (key === '$ref') continue;
|
|
32
|
+
issues.push(...findBadRefs(value, `${pointer}/${key}`));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return issues;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const RULES = {
|
|
39
|
+
openapi: {
|
|
40
|
+
'array-without-items': 'warning',
|
|
41
|
+
'duplicate-non-request-body-parameters': 'warning',
|
|
42
|
+
'duplicate-operation-id': 'warning',
|
|
43
|
+
'non-optional-path-parameters': 'warning',
|
|
44
|
+
'path-parameters-not-in-parameters': 'warning',
|
|
45
|
+
'path-parameters-not-in-path': 'warning',
|
|
46
|
+
},
|
|
47
|
+
swagger: {
|
|
48
|
+
'array-without-items': 'warning',
|
|
49
|
+
'duplicate-non-request-body-parameters': 'warning',
|
|
50
|
+
'duplicate-operation-id': 'warning',
|
|
51
|
+
'non-optional-path-parameters': 'warning',
|
|
52
|
+
'path-parameters-not-in-parameters': 'warning',
|
|
53
|
+
'path-parameters-not-in-path': 'warning',
|
|
54
|
+
'unknown-required-schema-property': 'warning',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate all OAS files under `<gitRoot>/reference/`. Pure programmatic API:
|
|
60
|
+
* returns per-file results without printing or exiting.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} [opts]
|
|
63
|
+
* @param {string} [opts.cwd] Repo root (defaults to process.cwd()).
|
|
64
|
+
* @param {boolean} [opts.dereference] Also dereference $ref pointers (matches ReadMe server validation).
|
|
65
|
+
* @returns {Promise<null | {
|
|
66
|
+
* fileCount: number,
|
|
67
|
+
* totalErrors: number,
|
|
68
|
+
* totalWarnings: number,
|
|
69
|
+
* totalValid: number,
|
|
70
|
+
* results: Array<{
|
|
71
|
+
* filename: string,
|
|
72
|
+
* title: string,
|
|
73
|
+
* version: string,
|
|
74
|
+
* opCount: number,
|
|
75
|
+
* errors: Array<{ message: string }>,
|
|
76
|
+
* warnings: Array<{ message: string }>,
|
|
77
|
+
* valid: boolean,
|
|
78
|
+
* }>,
|
|
79
|
+
* }>}
|
|
80
|
+
* Returns null if there's no reference/ dir or no spec files.
|
|
81
|
+
*/
|
|
82
|
+
export async function validateOas({ cwd, dereference = false } = {}) {
|
|
83
|
+
const gitRoot = cwd || process.cwd();
|
|
84
|
+
const refDir = path.join(gitRoot, 'reference');
|
|
85
|
+
if (!fs.existsSync(refDir)) return null;
|
|
86
|
+
|
|
87
|
+
const oasFiles = findOasFiles(refDir);
|
|
88
|
+
if (oasFiles.length === 0) return null;
|
|
89
|
+
|
|
90
|
+
const results = [];
|
|
91
|
+
let totalErrors = 0;
|
|
92
|
+
let totalWarnings = 0;
|
|
93
|
+
let totalValid = 0;
|
|
94
|
+
|
|
95
|
+
for (const { filename, spec } of oasFiles) {
|
|
96
|
+
const filePath = path.join(refDir, filename);
|
|
97
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
98
|
+
const title = spec.info?.title || filename;
|
|
99
|
+
const version = spec.openapi || spec.swagger || 'unknown';
|
|
100
|
+
const opCount = extractOperations(spec).size;
|
|
101
|
+
|
|
102
|
+
let normalizerResult;
|
|
103
|
+
try {
|
|
104
|
+
const normalizer = new OASNormalize(raw, { enablePaths: false });
|
|
105
|
+
normalizerResult = await normalizer.validate({
|
|
106
|
+
shouldThrowIfInvalid: false,
|
|
107
|
+
parser: { validate: { rules: RULES } },
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const errors = [{ message: err.message || String(err) }];
|
|
111
|
+
results.push({ filename, title, version, opCount, errors, warnings: [], valid: false });
|
|
112
|
+
totalErrors += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const errors = normalizerResult.valid ? [] : (normalizerResult.errors || []);
|
|
117
|
+
const warnings = normalizerResult.warnings || [];
|
|
118
|
+
|
|
119
|
+
if (dereference) {
|
|
120
|
+
try {
|
|
121
|
+
const normalizer = new OASNormalize(raw, { enablePaths: false });
|
|
122
|
+
await normalizer.deref();
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const msg = err.message || String(err);
|
|
125
|
+
errors.push({ message: `Dereference failed: ${msg}` });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Catches malformed pointers like "#components/..." (missing slash) that
|
|
130
|
+
// oas-normalize accepts.
|
|
131
|
+
const badRefs = findBadRefs(spec);
|
|
132
|
+
for (const { path: refPath, ref } of badRefs) {
|
|
133
|
+
errors.push({ message: `Malformed $ref: "${ref}" at ${refPath} (should start with "#/")` });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
totalErrors += errors.length;
|
|
137
|
+
totalWarnings += warnings.length;
|
|
138
|
+
const valid = errors.length === 0;
|
|
139
|
+
if (valid) totalValid += 1;
|
|
140
|
+
|
|
141
|
+
results.push({ filename, title, version, opCount, errors, warnings, valid });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { fileCount: oasFiles.length, totalErrors, totalWarnings, totalValid, results };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Validate OAS files and print results (used by the CLI command).
|
|
149
|
+
* Returns the same aggregate shape `validateOas` does.
|
|
150
|
+
*
|
|
151
|
+
* @deprecated Prefer `validateOas` for programmatic use; this helper prints to
|
|
152
|
+
* stdout. Kept exported for backward compatibility.
|
|
153
|
+
*/
|
|
154
|
+
export async function validateOasFiles(gitRoot, { dereference = false } = {}) {
|
|
155
|
+
const summary = await validateOas({ cwd: gitRoot, dereference });
|
|
156
|
+
if (!summary) return null;
|
|
157
|
+
|
|
158
|
+
for (const r of summary.results) {
|
|
159
|
+
const meta = `${r.filename} · ${r.version} · ${r.opCount} ${r.opCount === 1 ? 'endpoint' : 'endpoints'}`;
|
|
160
|
+
const dot = r.errors.length > 0
|
|
161
|
+
? styles.err('●')
|
|
162
|
+
: r.warnings.length > 0 ? styles.warn('●') : styles.success('●');
|
|
163
|
+
|
|
164
|
+
console.log();
|
|
165
|
+
console.log(` ${dot} ${styles.bold(r.title)} ${styles.dim(`(${meta})`)}`);
|
|
166
|
+
|
|
167
|
+
if (r.errors.length === 0 && r.warnings.length === 0) {
|
|
168
|
+
console.log(` ${styles.success('Valid')}`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
for (const e of r.errors) console.log(` ${styles.err('✘')} ${e.message}`);
|
|
172
|
+
for (const w of r.warnings) console.log(` ${styles.warn('⚠')} ${w.message}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const { totalErrors, totalWarnings, totalValid, fileCount } = summary;
|
|
176
|
+
return { totalErrors, totalWarnings, totalValid, fileCount };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function run(options, _cmd, ctx) {
|
|
180
|
+
const { gitRoot } = ctx;
|
|
181
|
+
const refDir = path.join(gitRoot, 'reference');
|
|
182
|
+
|
|
183
|
+
if (!fs.existsSync(refDir)) {
|
|
184
|
+
styles.error('No reference/ directory found.');
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const result = await validateOasFiles(gitRoot, { dereference: options.dereference });
|
|
189
|
+
|
|
190
|
+
if (!result) {
|
|
191
|
+
styles.info('No OpenAPI spec files found in reference/.');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const { totalErrors, totalWarnings, totalValid, fileCount } = result;
|
|
196
|
+
|
|
197
|
+
console.log();
|
|
198
|
+
if (totalErrors > 0) {
|
|
199
|
+
const parts = [`${totalErrors} ${totalErrors === 1 ? 'error' : 'errors'}`];
|
|
200
|
+
if (totalWarnings > 0) parts.push(`${totalWarnings} ${totalWarnings === 1 ? 'warning' : 'warnings'}`);
|
|
201
|
+
styles.error(`${parts.join(' and ')} across ${fileCount} ${fileCount === 1 ? 'spec' : 'specs'}.`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
} else if (totalWarnings > 0) {
|
|
204
|
+
styles.warning(`${totalValid} of ${fileCount} specs valid with ${totalWarnings} ${totalWarnings === 1 ? 'warning' : 'warnings'}.`);
|
|
205
|
+
} else {
|
|
206
|
+
styles.ok(`${fileCount} ${fileCount === 1 ? 'spec' : 'specs'} validated — all good!`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const command = 'play';
|
|
2
|
+
export const description = 'Visit your pet';
|
|
3
|
+
export const hidden = true;
|
|
4
|
+
export const skipBootstrap = true;
|
|
5
|
+
export const order = 99;
|
|
6
|
+
export const category = 'Other';
|
|
7
|
+
|
|
8
|
+
export async function run(variant) {
|
|
9
|
+
if (variant === 'reset') {
|
|
10
|
+
const { resetPet } = await import('../utils/tamagotchi.js');
|
|
11
|
+
resetPet();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { startGame } = await import('../utils/tamagotchi.js');
|
|
16
|
+
await startGame();
|
|
17
|
+
}
|