@mintlify/cli 4.0.1434 → 4.0.1437
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/bin/cli.js +10 -4
- package/bin/mintTest.js +96 -0
- package/bin/mintTestNav.js +35 -0
- package/bin/mintTestTree.js +94 -0
- package/bin/mintTestUi.js +610 -0
- package/bin/status.js +11 -4
- package/bin/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +10 -9
- package/src/cli.tsx +15 -9
- package/src/mintTest.tsx +102 -0
- package/src/mintTestNav.ts +31 -0
- package/src/mintTestTree.ts +134 -0
- package/src/mintTestUi.tsx +988 -0
- package/src/status.tsx +11 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mintlify/cli",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.1437",
|
|
4
4
|
"description": "The Mintlify CLI",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=18.0.0"
|
|
@@ -45,12 +45,13 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@inquirer/prompts": "7.9.0",
|
|
48
|
-
"@mintlify/
|
|
49
|
-
"@mintlify/
|
|
50
|
-
"@mintlify/
|
|
51
|
-
"@mintlify/
|
|
52
|
-
"@mintlify/
|
|
53
|
-
"@mintlify/
|
|
48
|
+
"@mintlify/agent-harness": "0.0.3",
|
|
49
|
+
"@mintlify/common": "1.0.1111",
|
|
50
|
+
"@mintlify/link-rot": "3.0.1313",
|
|
51
|
+
"@mintlify/models": "0.0.351",
|
|
52
|
+
"@mintlify/prebuild": "1.0.1265",
|
|
53
|
+
"@mintlify/previewing": "4.0.1337",
|
|
54
|
+
"@mintlify/validation": "0.1.842",
|
|
54
55
|
"adm-zip": "0.6.0",
|
|
55
56
|
"chalk": "5.2.0",
|
|
56
57
|
"color": "4.2.3",
|
|
@@ -82,7 +83,7 @@
|
|
|
82
83
|
"keytar": "7.9.0"
|
|
83
84
|
},
|
|
84
85
|
"devDependencies": {
|
|
85
|
-
"@mintlify/editor": "0.0.
|
|
86
|
+
"@mintlify/editor": "0.0.328",
|
|
86
87
|
"@mintlify/ts-config": "2.0.2",
|
|
87
88
|
"@tsconfig/recommended": "1.0.2",
|
|
88
89
|
"@types/detect-port": "1.3.2",
|
|
@@ -101,5 +102,5 @@
|
|
|
101
102
|
"vitest": "2.1.9",
|
|
102
103
|
"vitest-mock-process": "1.0.4"
|
|
103
104
|
},
|
|
104
|
-
"gitHead": "
|
|
105
|
+
"gitHead": "246494b8852335337e48cd0518b54abb2fb3f774"
|
|
105
106
|
}
|
package/src/cli.tsx
CHANGED
|
@@ -44,6 +44,7 @@ import { login } from './login.js';
|
|
|
44
44
|
import { logout } from './logout.js';
|
|
45
45
|
import { mdxLinter } from './mdxLinter.js';
|
|
46
46
|
import { createTelemetryMiddleware } from './middlewares/telemetryMiddleware.js';
|
|
47
|
+
import { mintTestHandler } from './mintTest.js';
|
|
47
48
|
import { checkOpenApiFile, getOpenApiFilenamesFromDocsConfig } from './openApiCheck.js';
|
|
48
49
|
import { resolveExplicitFiles } from './resolveFiles.js';
|
|
49
50
|
import { scoreHandler } from './score/index.js';
|
|
@@ -85,15 +86,18 @@ const showWelcome = async (packageName: string): Promise<void> => {
|
|
|
85
86
|
};
|
|
86
87
|
|
|
87
88
|
export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
|
|
88
|
-
|
|
89
|
+
const commandArguments = hideBin(process.argv);
|
|
90
|
+
if (shouldShowWelcome(commandArguments)) {
|
|
89
91
|
return showWelcome(packageName);
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
const telemetryMiddleware = createTelemetryMiddleware();
|
|
93
|
-
|
|
95
|
+
if (commandArguments.find((argument) => !argument.startsWith('-')) !== 'test') {
|
|
96
|
+
render(<Logs />);
|
|
97
|
+
}
|
|
94
98
|
|
|
95
99
|
return (
|
|
96
|
-
yargs(
|
|
100
|
+
yargs(commandArguments)
|
|
97
101
|
.scriptName(packageName)
|
|
98
102
|
.option('telemetry', {
|
|
99
103
|
type: 'boolean',
|
|
@@ -239,6 +243,14 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
|
|
|
239
243
|
});
|
|
240
244
|
}
|
|
241
245
|
)
|
|
246
|
+
.command(
|
|
247
|
+
'test',
|
|
248
|
+
'Test runnable code blocks with a local coding agent',
|
|
249
|
+
(yargs) => yargs.usage('usage: mint test'),
|
|
250
|
+
async () => {
|
|
251
|
+
await mintTestHandler();
|
|
252
|
+
}
|
|
253
|
+
)
|
|
242
254
|
.command(
|
|
243
255
|
'export',
|
|
244
256
|
'Export a static site for air-gapped deployment',
|
|
@@ -762,12 +774,6 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
|
|
|
762
774
|
() => undefined,
|
|
763
775
|
comingSoon('ai', packageName)
|
|
764
776
|
)
|
|
765
|
-
.command(
|
|
766
|
-
'test',
|
|
767
|
-
'[Coming soon] Test your documentation (run mint test to vote)',
|
|
768
|
-
() => undefined,
|
|
769
|
-
comingSoon('test', packageName)
|
|
770
|
-
)
|
|
771
777
|
.command(
|
|
772
778
|
'mcp',
|
|
773
779
|
'[Coming soon] MCP server for documentation (run mint mcp to vote)',
|
package/src/mintTest.tsx
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_COMMAND_TIMEOUT_MS,
|
|
3
|
+
DEFAULT_CONCURRENCY,
|
|
4
|
+
clearRunSave,
|
|
5
|
+
discoverCodeBlocks,
|
|
6
|
+
loadRunSave,
|
|
7
|
+
runCodeTests,
|
|
8
|
+
type RunSave,
|
|
9
|
+
} from '@mintlify/agent-harness';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { CMD_EXEC_PATH, findDocsRoot, isAI, terminate } from './helpers.js';
|
|
13
|
+
import { getAccessToken } from './keyring.js';
|
|
14
|
+
import { fileInNavPaths, loadDocsNavScope } from './mintTestNav.js';
|
|
15
|
+
import { countReport, formatDuration, runMintTestUi } from './mintTestUi.js';
|
|
16
|
+
import { getCliStatus } from './status.js';
|
|
17
|
+
|
|
18
|
+
export async function mintTestHandler(): Promise<void> {
|
|
19
|
+
try {
|
|
20
|
+
const accessToken = await getAccessToken().catch(() => null);
|
|
21
|
+
if (!accessToken) {
|
|
22
|
+
process.stderr.write('not logged in. Run `mint login` to authenticate.\n');
|
|
23
|
+
await terminate(1);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const cliStatus = await getCliStatus(accessToken);
|
|
27
|
+
if (cliStatus?.org.name.toLowerCase() !== 'mintlify') {
|
|
28
|
+
process.stderr.write('mint test is not available for your organization.\n');
|
|
29
|
+
await terminate(1);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const targetPath = await findDocsRoot(CMD_EXEC_PATH);
|
|
33
|
+
const interactive =
|
|
34
|
+
process.stdin.isTTY === true &&
|
|
35
|
+
process.stdout.isTTY === true &&
|
|
36
|
+
!isAI() &&
|
|
37
|
+
!process.env.CI &&
|
|
38
|
+
process.env.CLI_TEST_MODE !== 'true';
|
|
39
|
+
|
|
40
|
+
const { docsRoot, pages: discoveredPages } = await discoverCodeBlocks(targetPath);
|
|
41
|
+
const scope = await loadDocsNavScope(docsRoot);
|
|
42
|
+
const pages = scope
|
|
43
|
+
? discoveredPages.filter((page) => fileInNavPaths(page.file, scope.navPaths))
|
|
44
|
+
: discoveredPages;
|
|
45
|
+
const autoSelectedFiles = (
|
|
46
|
+
scope ? pages.filter((page) => fileInNavPaths(page.file, scope.autoSelectedPaths)) : pages
|
|
47
|
+
).map((page) => page.file);
|
|
48
|
+
const totalPages = scope ? scope.navPaths.size : pages.length;
|
|
49
|
+
const outputDirectory = path.join(docsRoot, 'tests', 'mint-test');
|
|
50
|
+
|
|
51
|
+
let save: RunSave | null = await loadRunSave(outputDirectory);
|
|
52
|
+
if (save) {
|
|
53
|
+
const known = new Set(pages.map((page) => page.file));
|
|
54
|
+
const selectedFiles = save.selectedFiles.filter((file) => known.has(file));
|
|
55
|
+
if (selectedFiles.length === 0) {
|
|
56
|
+
await clearRunSave(outputDirectory);
|
|
57
|
+
save = null;
|
|
58
|
+
} else {
|
|
59
|
+
save = { ...save, selectedFiles };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (interactive) {
|
|
64
|
+
const exitCode = await runMintTestUi({
|
|
65
|
+
targetPath,
|
|
66
|
+
docsRoot,
|
|
67
|
+
pages,
|
|
68
|
+
autoSelectedFiles,
|
|
69
|
+
totalPages,
|
|
70
|
+
save,
|
|
71
|
+
});
|
|
72
|
+
await terminate(exitCode);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (save) process.stdout.write(`resuming run ${save.runId}\n`);
|
|
77
|
+
const report = await runCodeTests({
|
|
78
|
+
path: targetPath,
|
|
79
|
+
agents: save?.agents ?? ['claude'],
|
|
80
|
+
model: save?.model ?? 'claude-opus-4-8',
|
|
81
|
+
selectedFiles: save?.selectedFiles ?? autoSelectedFiles,
|
|
82
|
+
outputDirectory,
|
|
83
|
+
runId: save?.runId ?? null,
|
|
84
|
+
concurrency: DEFAULT_CONCURRENCY,
|
|
85
|
+
commandTimeoutMs: DEFAULT_COMMAND_TIMEOUT_MS,
|
|
86
|
+
signal: new AbortController().signal,
|
|
87
|
+
onTaskUpdate: () => {},
|
|
88
|
+
});
|
|
89
|
+
const counts = countReport(report.tasks);
|
|
90
|
+
process.stdout.write(
|
|
91
|
+
`mint test ${report.status}: ${counts.passed} passed, ${counts.failed} failed, ${counts.agentErrors} agent errors\n`
|
|
92
|
+
);
|
|
93
|
+
process.stdout.write(`finished in ${formatDuration(report.durationMs)}\n`);
|
|
94
|
+
process.stdout.write(`report: ${report.reportPath}\n`);
|
|
95
|
+
await terminate(report.status === 'passed' ? 0 : 1);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
process.stderr.write(
|
|
98
|
+
`mint test failed: ${error instanceof Error ? error.message : 'unknown error'}\n`
|
|
99
|
+
);
|
|
100
|
+
await terminate(1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getAllPathsInDocsNav } from '@mintlify/common';
|
|
2
|
+
import { getConfigObj } from '@mintlify/prebuild';
|
|
3
|
+
import type { DocsConfig, NavigationConfig } from '@mintlify/validation';
|
|
4
|
+
|
|
5
|
+
export interface DocsNavScope {
|
|
6
|
+
navPaths: Set<string>;
|
|
7
|
+
autoSelectedPaths: Set<string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function loadDocsNavScope(docsRoot: string): Promise<DocsNavScope | null> {
|
|
11
|
+
const config = (await getConfigObj(docsRoot, 'docs').catch(() => null)) as DocsConfig | null;
|
|
12
|
+
const navigation = config?.navigation;
|
|
13
|
+
if (!navigation) return null;
|
|
14
|
+
const navPaths = new Set(getAllPathsInDocsNav(navigation));
|
|
15
|
+
if (navPaths.size === 0) return null;
|
|
16
|
+
const firstLanguage =
|
|
17
|
+
'languages' in navigation ? (navigation.languages[0] as NavigationConfig | undefined) : null;
|
|
18
|
+
const autoSelectedPaths = firstLanguage ? new Set(getAllPathsInDocsNav(firstLanguage)) : navPaths;
|
|
19
|
+
return { navPaths, autoSelectedPaths };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function navCandidatesForFile(file: string): string[] {
|
|
23
|
+
const base = file.replace(/\.mdx?$/i, '');
|
|
24
|
+
const candidates = [base];
|
|
25
|
+
if (base.endsWith('/index')) candidates.push(base.slice(0, -'/index'.length));
|
|
26
|
+
return candidates;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function fileInNavPaths(file: string, paths: Set<string>): boolean {
|
|
30
|
+
return navCandidatesForFile(file).some((candidate) => paths.has(candidate));
|
|
31
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { DocsPage } from '@mintlify/agent-harness';
|
|
2
|
+
|
|
3
|
+
export type ScopeSelectionState = 'all' | 'some' | 'none';
|
|
4
|
+
|
|
5
|
+
export interface ScopeTreeRow {
|
|
6
|
+
id: string;
|
|
7
|
+
kind: 'directory' | 'file';
|
|
8
|
+
label: string;
|
|
9
|
+
depth: number;
|
|
10
|
+
file?: string;
|
|
11
|
+
blockCount: number;
|
|
12
|
+
selectableFiles: string[];
|
|
13
|
+
selection: ScopeSelectionState;
|
|
14
|
+
expanded?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TreeNode {
|
|
18
|
+
id: string;
|
|
19
|
+
kind: 'directory' | 'file';
|
|
20
|
+
label: string;
|
|
21
|
+
file?: string;
|
|
22
|
+
blockCount: number;
|
|
23
|
+
children: TreeNode[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildTree(pages: DocsPage[]): TreeNode {
|
|
27
|
+
const root: TreeNode = { id: 'dir:', kind: 'directory', label: '.', blockCount: 0, children: [] };
|
|
28
|
+
const directories = new Map<string, TreeNode>([['', root]]);
|
|
29
|
+
|
|
30
|
+
for (const page of pages) {
|
|
31
|
+
const parts = page.file.split('/');
|
|
32
|
+
let parent = root;
|
|
33
|
+
let directoryPath = '';
|
|
34
|
+
for (const part of parts.slice(0, -1)) {
|
|
35
|
+
directoryPath = directoryPath ? `${directoryPath}/${part}` : part;
|
|
36
|
+
let directory = directories.get(directoryPath);
|
|
37
|
+
if (!directory) {
|
|
38
|
+
directory = {
|
|
39
|
+
id: `dir:${directoryPath}`,
|
|
40
|
+
kind: 'directory',
|
|
41
|
+
label: part,
|
|
42
|
+
blockCount: 0,
|
|
43
|
+
children: [],
|
|
44
|
+
};
|
|
45
|
+
directories.set(directoryPath, directory);
|
|
46
|
+
parent.children.push(directory);
|
|
47
|
+
}
|
|
48
|
+
parent = directory;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
parent.children.push({
|
|
52
|
+
id: `file:${page.file}`,
|
|
53
|
+
kind: 'file',
|
|
54
|
+
label: parts[parts.length - 1] ?? page.file,
|
|
55
|
+
file: page.file,
|
|
56
|
+
blockCount: page.blockCount,
|
|
57
|
+
children: [],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sumBlocks = (node: TreeNode): number => {
|
|
62
|
+
if (node.kind === 'file') return node.blockCount;
|
|
63
|
+
node.blockCount = node.children.reduce((total, child) => total + sumBlocks(child), 0);
|
|
64
|
+
return node.blockCount;
|
|
65
|
+
};
|
|
66
|
+
sumBlocks(root);
|
|
67
|
+
|
|
68
|
+
const sortChildren = (node: TreeNode): void => {
|
|
69
|
+
node.children.sort((left, right) => {
|
|
70
|
+
if (left.kind !== right.kind) return left.kind === 'directory' ? -1 : 1;
|
|
71
|
+
return left.label.localeCompare(right.label);
|
|
72
|
+
});
|
|
73
|
+
for (const child of node.children) sortChildren(child);
|
|
74
|
+
};
|
|
75
|
+
sortChildren(root);
|
|
76
|
+
return root;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function descendantFiles(node: TreeNode): string[] {
|
|
80
|
+
if (node.kind === 'file') return node.file ? [node.file] : [];
|
|
81
|
+
return node.children.flatMap(descendantFiles);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function selectionState(files: string[], selectedFiles: ReadonlySet<string>): ScopeSelectionState {
|
|
85
|
+
if (files.length === 0) return 'none';
|
|
86
|
+
const selectedCount = files.filter((file) => selectedFiles.has(file)).length;
|
|
87
|
+
if (selectedCount === 0) return 'none';
|
|
88
|
+
return selectedCount === files.length ? 'all' : 'some';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function visibleScopeRows(
|
|
92
|
+
pages: DocsPage[],
|
|
93
|
+
expandedDirectories: ReadonlySet<string>,
|
|
94
|
+
selectedFiles: ReadonlySet<string>
|
|
95
|
+
): ScopeTreeRow[] {
|
|
96
|
+
const root = buildTree(pages);
|
|
97
|
+
const rows: ScopeTreeRow[] = [];
|
|
98
|
+
|
|
99
|
+
const visit = (node: TreeNode, depth: number) => {
|
|
100
|
+
const files = descendantFiles(node);
|
|
101
|
+
const expanded = node.kind === 'directory' ? expandedDirectories.has(node.id) : undefined;
|
|
102
|
+
rows.push({
|
|
103
|
+
id: node.id,
|
|
104
|
+
kind: node.kind,
|
|
105
|
+
label: node.label,
|
|
106
|
+
depth,
|
|
107
|
+
...(node.file ? { file: node.file } : {}),
|
|
108
|
+
blockCount: node.blockCount,
|
|
109
|
+
selectableFiles: files,
|
|
110
|
+
selection: selectionState(files, selectedFiles),
|
|
111
|
+
...(expanded !== undefined ? { expanded } : {}),
|
|
112
|
+
});
|
|
113
|
+
if (node.kind === 'directory' && expanded) {
|
|
114
|
+
for (const child of node.children) visit(child, depth + 1);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
for (const child of root.children) visit(child, 0);
|
|
119
|
+
return rows;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function toggleScopeSelection(
|
|
123
|
+
selectedFiles: ReadonlySet<string>,
|
|
124
|
+
row: ScopeTreeRow
|
|
125
|
+
): Set<string> {
|
|
126
|
+
const next = new Set(selectedFiles);
|
|
127
|
+
if (row.selectableFiles.length === 0) return next;
|
|
128
|
+
const shouldSelect = row.selectableFiles.some((file) => !next.has(file));
|
|
129
|
+
for (const file of row.selectableFiles) {
|
|
130
|
+
if (shouldSelect) next.add(file);
|
|
131
|
+
else next.delete(file);
|
|
132
|
+
}
|
|
133
|
+
return next;
|
|
134
|
+
}
|