@mintlify/cli 4.0.677 → 4.0.678

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mintlify/cli",
3
- "version": "4.0.677",
3
+ "version": "4.0.678",
4
4
  "description": "The Mintlify CLI",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"
@@ -48,6 +48,7 @@
48
48
  "chalk": "^5.2.0",
49
49
  "detect-port": "^1.5.1",
50
50
  "fs-extra": "^11.2.0",
51
+ "gray-matter": "^4.0.3",
51
52
  "ink": "^5.2.1",
52
53
  "inquirer": "^12.3.0",
53
54
  "js-yaml": "^4.1.0",
@@ -73,5 +74,5 @@
73
74
  "vitest": "^2.0.4",
74
75
  "vitest-mock-process": "^1.0.4"
75
76
  },
76
- "gitHead": "1c9e78df2827cd5d3bc0683f3b794e071133ab32"
77
+ "gitHead": "94b16fd814301e31e27a26a0fcd622c7f2c748cb"
77
78
  }
package/src/cli.tsx CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  terminate,
28
28
  readLocalOpenApiFile,
29
29
  } from './helpers.js';
30
+ import { migrateMdx } from './migrateMdx.js';
30
31
  import { update } from './update.js';
31
32
 
32
33
  export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
@@ -228,6 +229,15 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
228
229
  await upgradeConfig();
229
230
  }
230
231
  )
232
+ .command(
233
+ 'migrate-mdx',
234
+ 'migrate MDX OpenAPI endpoint pages to x-mint extensions and docs.json',
235
+ () => undefined,
236
+ async () => {
237
+ await migrateMdx();
238
+ await terminate(0);
239
+ }
240
+ )
231
241
  .command(
232
242
  ['version', 'v'],
233
243
  'display the current version of the CLI and client',
@@ -0,0 +1,463 @@
1
+ import { potentiallyParseOpenApiString } from '@mintlify/common';
2
+ import { getConfigObj, getConfigPath } from '@mintlify/prebuild';
3
+ import { addLog, ErrorLog, SuccessLog } from '@mintlify/previewing';
4
+ import {
5
+ divisions,
6
+ DocsConfig,
7
+ NavigationConfig,
8
+ validateDocsConfig,
9
+ XMint,
10
+ XMcp,
11
+ } from '@mintlify/validation';
12
+ import fs from 'fs';
13
+ import { outputFile } from 'fs-extra';
14
+ import matter from 'gray-matter';
15
+ import inquirer from 'inquirer';
16
+ import yaml from 'js-yaml';
17
+ import { OpenAPI, OpenAPIV3 } from 'openapi-types';
18
+ import path from 'path';
19
+
20
+ import { CMD_EXEC_PATH } from './constants.js';
21
+
22
+ const specCache: Record<string, OpenAPI.Document> = {};
23
+
24
+ const candidateSpecCache: Record<string, OpenAPI.Document> = {};
25
+
26
+ const specLocks = new Map<string, Promise<void>>();
27
+
28
+ async function withSpecLock(specPath: string, task: () => Promise<void>) {
29
+ const key = path.resolve(specPath);
30
+ const previous = specLocks.get(key) ?? Promise.resolve();
31
+ let releaseNext: () => void;
32
+ const next = new Promise<void>((resolve) => {
33
+ releaseNext = resolve;
34
+ });
35
+ specLocks.set(key, next);
36
+
37
+ await previous;
38
+ try {
39
+ await task();
40
+ } finally {
41
+ releaseNext!();
42
+ }
43
+ }
44
+
45
+ let inquirerLockQueue: Promise<void> = Promise.resolve();
46
+
47
+ async function withInquirerLock<T>(task: () => Promise<T>): Promise<T> {
48
+ const previous = inquirerLockQueue;
49
+ let releaseNext: () => void;
50
+ const next = new Promise<void>((resolve) => {
51
+ releaseNext = resolve;
52
+ });
53
+ inquirerLockQueue = next;
54
+ await previous;
55
+ try {
56
+ return await task();
57
+ } finally {
58
+ releaseNext!();
59
+ }
60
+ }
61
+
62
+ export async function migrateMdx() {
63
+ const docsConfigPath = await getConfigPath(CMD_EXEC_PATH, 'docs');
64
+
65
+ if (!docsConfigPath) {
66
+ addLog(<ErrorLog message="docs.json not found in current directory" />);
67
+ return;
68
+ }
69
+
70
+ const docsConfigObj = await getConfigObj(CMD_EXEC_PATH, 'docs');
71
+
72
+ const validationResults = await validateDocsConfig(docsConfigObj as DocsConfig);
73
+ if (!validationResults.success) {
74
+ addLog(<ErrorLog message="docs.json is invalid" />);
75
+ return;
76
+ }
77
+
78
+ const validatedDocsConfig = validationResults.data as DocsConfig;
79
+ const docsConfig = docsConfigObj as DocsConfig;
80
+
81
+ await buildCandidateSpecCacheIfNeeded(CMD_EXEC_PATH);
82
+
83
+ const updatedNavigation = await processNav(validatedDocsConfig.navigation);
84
+
85
+ docsConfig.navigation = updatedNavigation;
86
+ await outputFile(docsConfigPath, JSON.stringify(docsConfig, null, 2));
87
+ addLog(<SuccessLog message="docs.json updated" />);
88
+
89
+ for (const specPath in specCache) {
90
+ const specObj = specCache[specPath] as OpenAPI.Document;
91
+ const ext = path.extname(specPath).toLowerCase();
92
+ const stringified = ext === '.json' ? JSON.stringify(specObj, null, 2) : yaml.dump(specObj);
93
+ await outputFile(specPath, stringified);
94
+ addLog(<SuccessLog message={`updated ${path.relative(CMD_EXEC_PATH, specPath)}`} />);
95
+ }
96
+
97
+ addLog(<SuccessLog message="migration complete" />);
98
+ }
99
+
100
+ async function processNav(nav: NavigationConfig): Promise<NavigationConfig> {
101
+ let newNav: NavigationConfig = { ...nav };
102
+
103
+ if ('pages' in newNav) {
104
+ newNav.pages = await Promise.all(
105
+ newNav.pages.map(async (page) => {
106
+ if (typeof page === 'object' && page !== null && 'group' in page) {
107
+ return processNav(page);
108
+ }
109
+ if (typeof page === 'string' && !/\s/.test(page)) {
110
+ const mdxCandidatePath = path.join(CMD_EXEC_PATH, `${page}.mdx`);
111
+ if (!fs.existsSync(mdxCandidatePath)) {
112
+ return page;
113
+ }
114
+ const { data, content } = matter(await fs.promises.readFile(mdxCandidatePath, 'utf-8'));
115
+ const frontmatter = data as Record<string, string>;
116
+ if (!frontmatter.openapi) {
117
+ return page;
118
+ }
119
+ const parsed = potentiallyParseOpenApiString(frontmatter.openapi);
120
+ if (!parsed) {
121
+ addLog(
122
+ <ErrorLog
123
+ message={`invalid openapi frontmatter in ${mdxCandidatePath}: ${frontmatter.openapi}`}
124
+ />
125
+ );
126
+ return page;
127
+ }
128
+
129
+ const { filename, method, endpoint: endpointPath } = parsed;
130
+ let specPath = filename;
131
+
132
+ if (specPath && URL.canParse(specPath)) {
133
+ return page;
134
+ }
135
+
136
+ if (!specPath) {
137
+ const methodLower = method.toLowerCase();
138
+ const matchingSpecs = await findMatchingOpenApiSpecs(
139
+ {
140
+ method: methodLower,
141
+ endpointPath,
142
+ },
143
+ candidateSpecCache
144
+ );
145
+
146
+ if (matchingSpecs.length === 0) {
147
+ addLog(
148
+ <ErrorLog
149
+ message={`no OpenAPI spec found for ${method.toUpperCase()} ${endpointPath} in repository`}
150
+ />
151
+ );
152
+ return page;
153
+ }
154
+
155
+ if (matchingSpecs.length === 1) {
156
+ specPath = path.relative(CMD_EXEC_PATH, matchingSpecs[0]!);
157
+ } else {
158
+ const answer = await withInquirerLock(() =>
159
+ inquirer.prompt([
160
+ {
161
+ type: 'list',
162
+ name: 'chosen',
163
+ message: `multiple OpenAPI specs found for ${method.toUpperCase()} ${endpointPath}. which one should be used for ${path.relative(
164
+ CMD_EXEC_PATH,
165
+ mdxCandidatePath
166
+ )}?`,
167
+ choices: matchingSpecs.map((p) => ({
168
+ name: path.relative(CMD_EXEC_PATH, p),
169
+ value: path.relative(CMD_EXEC_PATH, p),
170
+ })),
171
+ },
172
+ ])
173
+ );
174
+ specPath = answer.chosen as string;
175
+ }
176
+ }
177
+
178
+ const href = `/${page}`;
179
+ const pageName = specPath ? `${specPath} ${method} ${endpointPath}` : frontmatter.openapi;
180
+ delete frontmatter.openapi;
181
+ await withSpecLock(path.resolve(specPath), () =>
182
+ migrateToXMint({
183
+ specPath,
184
+ method,
185
+ endpointPath,
186
+ frontmatter,
187
+ content,
188
+ href,
189
+ })
190
+ );
191
+
192
+ try {
193
+ await fs.promises.unlink(mdxCandidatePath);
194
+ } catch (err) {
195
+ addLog(
196
+ <ErrorLog
197
+ message={`failed to delete ${mdxCandidatePath}: ${(err as Error).message}`}
198
+ />
199
+ );
200
+ }
201
+
202
+ return pageName;
203
+ }
204
+ return page;
205
+ })
206
+ );
207
+ }
208
+
209
+ for (const division of ['groups', ...divisions]) {
210
+ if (division in newNav) {
211
+ const items = newNav[division as keyof typeof newNav] as NavigationConfig[];
212
+ newNav = {
213
+ ...newNav,
214
+ [division]: await Promise.all(items.map((item) => processNav(item))),
215
+ };
216
+ }
217
+ }
218
+
219
+ return newNav;
220
+ }
221
+
222
+ async function migrateToXMint(args: {
223
+ specPath: string;
224
+ method: string;
225
+ endpointPath: string;
226
+ frontmatter: Record<string, string>;
227
+ content: string;
228
+ href: string;
229
+ }) {
230
+ const { specPath, method, endpointPath, frontmatter, content, href } = args;
231
+
232
+ if (!fs.existsSync(specPath)) {
233
+ addLog(<ErrorLog message={`spec file not found: ${specPath}`} />);
234
+ return;
235
+ }
236
+
237
+ let specObj: OpenAPI.Document;
238
+ if (path.resolve(specPath) in specCache) {
239
+ specObj = specCache[path.resolve(specPath)] as OpenAPI.Document;
240
+ } else {
241
+ const pathname = path.join(CMD_EXEC_PATH, specPath);
242
+ const file = await fs.promises.readFile(pathname, 'utf-8');
243
+ const ext = path.extname(specPath).toLowerCase();
244
+ if (ext === '.json') {
245
+ specObj = JSON.parse(file) as OpenAPI.Document;
246
+ } else if (ext === '.yml' || ext === '.yaml') {
247
+ specObj = yaml.load(file) as OpenAPI.Document;
248
+ } else {
249
+ addLog(<ErrorLog message={`unsupported spec file extension: ${specPath}`} />);
250
+ return;
251
+ }
252
+ }
253
+
254
+ const methodLower = method.toLowerCase();
255
+
256
+ if (
257
+ !editXMint(specObj, endpointPath, methodLower, {
258
+ metadata: Object.keys(frontmatter).length > 0 ? frontmatter : undefined,
259
+ content: content.length > 0 ? content : undefined,
260
+ href,
261
+ })
262
+ ) {
263
+ addLog(
264
+ <ErrorLog
265
+ message={`operation not found in spec: ${method.toUpperCase()} ${endpointPath} in ${specPath}`}
266
+ />
267
+ );
268
+ return;
269
+ }
270
+
271
+ specCache[path.resolve(specPath)] = specObj;
272
+ }
273
+
274
+ function editXMint(
275
+ document: OpenAPI.Document,
276
+ path: string,
277
+ method: string,
278
+ newXMint: XMint
279
+ ): boolean {
280
+ if (method === 'webhook') {
281
+ return editWebhookXMint(document, path, newXMint);
282
+ }
283
+
284
+ if (!document.paths || !document.paths[path]) {
285
+ return false;
286
+ }
287
+
288
+ const pathItem = document.paths[path] as OpenAPIV3.PathItemObject;
289
+ const normalizedMethod = method.toLowerCase() as keyof OpenAPIV3.PathItemObject;
290
+
291
+ if (!pathItem[normalizedMethod]) {
292
+ return false;
293
+ }
294
+
295
+ const operation = pathItem[normalizedMethod] as OpenAPI.Operation<{
296
+ 'x-mint'?: XMint;
297
+ 'x-mcp'?: XMcp;
298
+ }>;
299
+ operation['x-mint'] = newXMint;
300
+
301
+ if ('x-mcp' in operation && !('mcp' in operation['x-mint'])) {
302
+ operation['x-mint']['mcp'] = operation['x-mcp'];
303
+ delete operation['x-mcp'];
304
+ }
305
+
306
+ return true;
307
+ }
308
+
309
+ function editWebhookXMint(document: OpenAPI.Document, path: string, newXMint: XMint): boolean {
310
+ const webhookObject = (
311
+ document as OpenAPIV3.Document & {
312
+ webhooks?: Record<string, OpenAPIV3.PathItemObject>;
313
+ }
314
+ ).webhooks?.[path];
315
+ if (!webhookObject || typeof webhookObject !== 'object') {
316
+ return false;
317
+ }
318
+
319
+ if (!webhookObject['post']) {
320
+ return false;
321
+ }
322
+
323
+ const operation = webhookObject['post'] as OpenAPI.Operation<{
324
+ 'x-mint'?: XMint;
325
+ 'x-mcp'?: XMcp;
326
+ }>;
327
+ operation['x-mint'] = newXMint;
328
+
329
+ if ('x-mcp' in operation && !('mcp' in operation['x-mint'])) {
330
+ operation['x-mint']['mcp'] = operation['x-mcp'];
331
+ delete operation['x-mcp'];
332
+ }
333
+ return true;
334
+ }
335
+
336
+ async function findMatchingOpenApiSpecs(
337
+ args: {
338
+ method: string;
339
+ endpointPath: string;
340
+ },
341
+ docsByPath?: Record<string, OpenAPI.Document>
342
+ ): Promise<string[]> {
343
+ const { method, endpointPath } = args;
344
+ const docsEntries: Array<[string, OpenAPI.Document | undefined]> = docsByPath
345
+ ? Object.entries(docsByPath)
346
+ : (await collectOpenApiFiles(CMD_EXEC_PATH)).map((absPath) => [absPath, undefined]);
347
+ const normalizedMethod = method.toLowerCase();
348
+
349
+ const endpointVariants = new Set<string>([endpointPath]);
350
+ if (!endpointPath.startsWith('/')) {
351
+ endpointVariants.add(`/${endpointPath}`);
352
+ } else {
353
+ endpointVariants.add(endpointPath.replace(/^\/+/, ''));
354
+ }
355
+
356
+ const matches: string[] = [];
357
+
358
+ for (const [absPath, maybeDoc] of docsEntries) {
359
+ try {
360
+ const doc = maybeDoc || (await loadOpenApiDocument(absPath));
361
+ if (!doc) continue;
362
+
363
+ if (normalizedMethod === 'webhook') {
364
+ const webhooks = (
365
+ doc as OpenAPIV3.Document & {
366
+ webhooks?: Record<string, OpenAPIV3.PathItemObject>;
367
+ }
368
+ ).webhooks;
369
+ if (!webhooks) continue;
370
+ for (const key of Object.keys(webhooks)) {
371
+ if (endpointVariants.has(key)) {
372
+ const pathItem = webhooks[key];
373
+ if (pathItem && typeof pathItem === 'object' && 'post' in pathItem && pathItem.post) {
374
+ matches.push(absPath);
375
+ break;
376
+ }
377
+ }
378
+ }
379
+ continue;
380
+ }
381
+
382
+ if (!doc.paths) continue;
383
+ for (const variant of endpointVariants) {
384
+ const pathItem = (doc.paths as Record<string, unknown>)[variant] as
385
+ | OpenAPIV3.PathItemObject
386
+ | undefined;
387
+ if (!pathItem) continue;
388
+ const hasOperation = !!(pathItem as Record<string, unknown>)[
389
+ normalizedMethod as keyof OpenAPIV3.PathItemObject
390
+ ];
391
+ if (hasOperation) {
392
+ matches.push(absPath);
393
+ break;
394
+ }
395
+ }
396
+ } catch {}
397
+ }
398
+
399
+ return matches.map((abs) => path.resolve(abs)).filter((v, i, a) => a.indexOf(v) === i);
400
+ }
401
+
402
+ async function collectOpenApiFiles(rootDir: string): Promise<string[]> {
403
+ const results: string[] = [];
404
+ const excludedDirs = new Set([
405
+ 'node_modules',
406
+ '.git',
407
+ 'dist',
408
+ 'build',
409
+ '.next',
410
+ '.vercel',
411
+ 'out',
412
+ 'coverage',
413
+ 'tmp',
414
+ 'temp',
415
+ ]);
416
+
417
+ async function walk(currentDir: string) {
418
+ const entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
419
+ for (const entry of entries) {
420
+ const abs = path.join(currentDir, entry.name);
421
+ if (entry.isDirectory()) {
422
+ if (excludedDirs.has(entry.name)) continue;
423
+ await walk(abs);
424
+ } else if (entry.isFile()) {
425
+ if (/\.(ya?ml|json)$/i.test(entry.name)) {
426
+ results.push(abs);
427
+ }
428
+ }
429
+ }
430
+ }
431
+
432
+ await walk(rootDir);
433
+ return results;
434
+ }
435
+
436
+ async function loadOpenApiDocument(absPath: string): Promise<OpenAPI.Document | undefined> {
437
+ try {
438
+ const file = await fs.promises.readFile(absPath, 'utf-8');
439
+ const ext = path.extname(absPath).toLowerCase();
440
+ let doc: OpenAPI.Document | undefined;
441
+ if (ext === '.json') {
442
+ doc = JSON.parse(file) as OpenAPI.Document;
443
+ } else if (ext === '.yml' || ext === '.yaml') {
444
+ doc = yaml.load(file) as OpenAPI.Document;
445
+ }
446
+ return doc;
447
+ } catch {
448
+ return undefined;
449
+ }
450
+ }
451
+
452
+ async function buildCandidateSpecCacheIfNeeded(rootDir: string) {
453
+ if (Object.keys(candidateSpecCache).length > 0) return;
454
+ const files = await collectOpenApiFiles(rootDir);
455
+ await Promise.all(
456
+ files.map(async (abs) => {
457
+ const doc = await loadOpenApiDocument(abs);
458
+ if (doc) {
459
+ candidateSpecCache[path.resolve(abs)] = doc;
460
+ }
461
+ })
462
+ );
463
+ }