@mintlify/cli 4.0.1314 → 4.0.1316
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/__test__/deslop/client.test.ts +167 -0
- package/__test__/deslop/resolveFiles.test.ts +183 -0
- package/__test__/deslop/whitespace.test.ts +94 -0
- package/bin/cli.js +48 -1
- package/bin/deslop/client.js +74 -0
- package/bin/deslop/index.js +242 -0
- package/bin/deslop/resolveFiles.js +186 -0
- package/bin/deslop/types.js +1 -0
- package/bin/deslop/whitespace.js +144 -0
- package/bin/signup.js +1 -1
- package/bin/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/src/cli.tsx +62 -1
- package/src/deslop/client.ts +77 -0
- package/src/deslop/index.tsx +286 -0
- package/src/deslop/resolveFiles.ts +176 -0
- package/src/deslop/types.ts +39 -0
- package/src/deslop/whitespace.ts +155 -0
- package/src/signup.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mintlify/cli",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.1316",
|
|
4
4
|
"description": "The Mintlify CLI",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=18.0.0"
|
|
@@ -45,12 +45,12 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@inquirer/prompts": "7.9.0",
|
|
48
|
-
"@mintlify/common": "1.0.
|
|
49
|
-
"@mintlify/link-rot": "3.0.
|
|
48
|
+
"@mintlify/common": "1.0.1024",
|
|
49
|
+
"@mintlify/link-rot": "3.0.1216",
|
|
50
50
|
"@mintlify/models": "0.0.339",
|
|
51
|
-
"@mintlify/prebuild": "1.0.
|
|
52
|
-
"@mintlify/previewing": "4.0.
|
|
53
|
-
"@mintlify/validation": "0.1.
|
|
51
|
+
"@mintlify/prebuild": "1.0.1172",
|
|
52
|
+
"@mintlify/previewing": "4.0.1241",
|
|
53
|
+
"@mintlify/validation": "0.1.789",
|
|
54
54
|
"adm-zip": "0.5.16",
|
|
55
55
|
"chalk": "5.2.0",
|
|
56
56
|
"color": "4.2.3",
|
|
@@ -92,5 +92,5 @@
|
|
|
92
92
|
"vitest": "2.1.9",
|
|
93
93
|
"vitest-mock-process": "1.0.4"
|
|
94
94
|
},
|
|
95
|
-
"gitHead": "
|
|
95
|
+
"gitHead": "b579437abcf745a46e1ff9744fe3cefa2c3de5e5"
|
|
96
96
|
}
|
package/src/cli.tsx
CHANGED
|
@@ -23,6 +23,7 @@ import { comingSoon } from './comingSoon.js';
|
|
|
23
23
|
import { setTelemetryEnabled } from './config.js';
|
|
24
24
|
import { getConfigValue, setConfigValue, clearConfigValue } from './config.js';
|
|
25
25
|
import { API_URL } from './constants.js';
|
|
26
|
+
import { deslopHandler } from './deslop/index.js';
|
|
26
27
|
import {
|
|
27
28
|
CMD_EXEC_PATH,
|
|
28
29
|
checkPort,
|
|
@@ -38,10 +39,11 @@ import { getAccessToken } from './keyring.js';
|
|
|
38
39
|
import { login } from './login.js';
|
|
39
40
|
import { logout } from './logout.js';
|
|
40
41
|
import { mdxLinter } from './mdxLinter.js';
|
|
42
|
+
import { subdomainMiddleware } from './middlewares/subdomainMiddleware.js';
|
|
41
43
|
import { createTelemetryMiddleware } from './middlewares/telemetryMiddleware.js';
|
|
42
44
|
import { checkOpenApiFile, getOpenApiFilenamesFromDocsConfig } from './openApiCheck.js';
|
|
43
45
|
import { scoreHandler } from './score/index.js';
|
|
44
|
-
import { signup } from './signup.js';
|
|
46
|
+
import { sendAIUsageMessage, signup } from './signup.js';
|
|
45
47
|
import { status, getCliSubdomains } from './status.js';
|
|
46
48
|
import { trackTelemetryPreferenceChange } from './telemetry/track.js';
|
|
47
49
|
import { update } from './update.js';
|
|
@@ -78,6 +80,12 @@ const showWelcome = async (packageName: string): Promise<void> => {
|
|
|
78
80
|
await terminate(0);
|
|
79
81
|
};
|
|
80
82
|
|
|
83
|
+
// If an AI tries to run mint signup --help, it needs to get the AI custom prompt instead
|
|
84
|
+
const isAISignupHelpRequest = (args: string[]): boolean => {
|
|
85
|
+
const positionals = args.filter((arg) => !arg.startsWith('-'));
|
|
86
|
+
return isAI() && positionals[0] === 'signup' && (args.includes('--help') || args.includes('-h'));
|
|
87
|
+
};
|
|
88
|
+
|
|
81
89
|
export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
|
|
82
90
|
if (shouldShowWelcome(hideBin(process.argv))) {
|
|
83
91
|
return showWelcome(packageName);
|
|
@@ -86,6 +94,11 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
|
|
|
86
94
|
const telemetryMiddleware = createTelemetryMiddleware();
|
|
87
95
|
render(<Logs />);
|
|
88
96
|
|
|
97
|
+
if (isAISignupHelpRequest(hideBin(process.argv))) {
|
|
98
|
+
sendAIUsageMessage();
|
|
99
|
+
return terminate(0);
|
|
100
|
+
}
|
|
101
|
+
|
|
89
102
|
return (
|
|
90
103
|
yargs(hideBin(process.argv))
|
|
91
104
|
.scriptName(packageName)
|
|
@@ -656,6 +669,54 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
|
|
|
656
669
|
.example('mint score docs.example.com', 'Run agent readiness checks on a URL'),
|
|
657
670
|
scoreHandler
|
|
658
671
|
)
|
|
672
|
+
.command(
|
|
673
|
+
'deslop [files..]',
|
|
674
|
+
'Detect AI-sounding prose and get humanization suggestions',
|
|
675
|
+
(yargs: Argv) =>
|
|
676
|
+
yargs
|
|
677
|
+
.middleware(subdomainMiddleware)
|
|
678
|
+
.positional('files', {
|
|
679
|
+
type: 'string',
|
|
680
|
+
array: true,
|
|
681
|
+
description: 'Files or globs to check (defaults to git-changed pages)',
|
|
682
|
+
})
|
|
683
|
+
.option('format', {
|
|
684
|
+
type: 'string',
|
|
685
|
+
choices: ['table', 'plain', 'json'] as const,
|
|
686
|
+
description: 'Output format',
|
|
687
|
+
})
|
|
688
|
+
.option('subdomain', {
|
|
689
|
+
type: 'string',
|
|
690
|
+
description: 'Documentation subdomain (default: mint config set subdomain)',
|
|
691
|
+
})
|
|
692
|
+
.option('threshold', {
|
|
693
|
+
type: 'number',
|
|
694
|
+
default: 0.5,
|
|
695
|
+
description: 'Fail a page when its AI-written fraction meets this value (0-1)',
|
|
696
|
+
})
|
|
697
|
+
.option('fix-whitespace', {
|
|
698
|
+
type: 'boolean',
|
|
699
|
+
default: false,
|
|
700
|
+
description:
|
|
701
|
+
'Normalize prose whitespace in the checked files (skips code and frontmatter)',
|
|
702
|
+
})
|
|
703
|
+
.check((argv) => {
|
|
704
|
+
if (
|
|
705
|
+
typeof argv.threshold === 'number' &&
|
|
706
|
+
(argv.threshold < 0 || argv.threshold > 1 || Number.isNaN(argv.threshold))
|
|
707
|
+
) {
|
|
708
|
+
throw new Error('--threshold must be a number between 0 and 1');
|
|
709
|
+
}
|
|
710
|
+
return true;
|
|
711
|
+
})
|
|
712
|
+
.example('mint deslop', 'Check git-changed pages for AI-sounding prose')
|
|
713
|
+
.example('mint deslop docs/guide.mdx', 'Check a specific page')
|
|
714
|
+
.example(
|
|
715
|
+
'mint deslop "docs/**/*.mdx" --format json',
|
|
716
|
+
'Check pages by glob with agent-friendly JSON output'
|
|
717
|
+
),
|
|
718
|
+
deslopHandler
|
|
719
|
+
)
|
|
659
720
|
// Coming soon commands — visible in help, tracked via telemetry to gauge interest.
|
|
660
721
|
.command(
|
|
661
722
|
'ai',
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { authenticatedFetch } from '../authenticatedFetch.js';
|
|
2
|
+
import { API_URL } from '../constants.js';
|
|
3
|
+
import type { DeslopFileResult, DeslopResponse } from './types.js';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
6
|
+
|
|
7
|
+
// Errors that apply to the whole batch (out of credits, service down) rather
|
|
8
|
+
// than a single page — hitting one means the rest will fail identically.
|
|
9
|
+
class DeslopAbortError extends Error {}
|
|
10
|
+
|
|
11
|
+
export async function checkPage(
|
|
12
|
+
subdomain: string,
|
|
13
|
+
pagePath: string,
|
|
14
|
+
content: string
|
|
15
|
+
): Promise<DeslopResponse> {
|
|
16
|
+
const endpoint = new URL(`${API_URL}/api/cli/deslop`);
|
|
17
|
+
endpoint.searchParams.set('subdomain', subdomain);
|
|
18
|
+
|
|
19
|
+
const res = await authenticatedFetch(endpoint.toString(), {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
22
|
+
body: JSON.stringify({ path: pagePath, content }),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (res.status === 402) {
|
|
26
|
+
throw new DeslopAbortError('Out of AI credits — upgrade or top up in the Mintlify dashboard.');
|
|
27
|
+
}
|
|
28
|
+
if (res.status === 503) {
|
|
29
|
+
throw new DeslopAbortError('Deslop is temporarily unavailable, try again shortly.');
|
|
30
|
+
}
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
const body = await res.text().catch(() => '');
|
|
33
|
+
throw new Error(`API error (${res.status}): ${body || res.statusText}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return res.json() as Promise<DeslopResponse>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function checkPages(
|
|
40
|
+
subdomain: string,
|
|
41
|
+
pages: { path: string; content: string }[],
|
|
42
|
+
concurrency: number = DEFAULT_CONCURRENCY
|
|
43
|
+
): Promise<DeslopFileResult[]> {
|
|
44
|
+
const results = new Array<DeslopFileResult>(pages.length);
|
|
45
|
+
let next = 0;
|
|
46
|
+
let aborted = false;
|
|
47
|
+
|
|
48
|
+
const worker = async () => {
|
|
49
|
+
while (next < pages.length && !aborted) {
|
|
50
|
+
const index = next++;
|
|
51
|
+
const page = pages[index]!;
|
|
52
|
+
try {
|
|
53
|
+
const response = await checkPage(subdomain, page.path, page.content);
|
|
54
|
+
results[index] = { file: page.path, response };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
results[index] = {
|
|
57
|
+
file: page.path,
|
|
58
|
+
error: err instanceof Error ? err.message : 'unknown error',
|
|
59
|
+
};
|
|
60
|
+
if (err instanceof DeslopAbortError) aborted = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, pages.length) }, worker));
|
|
66
|
+
|
|
67
|
+
// Pages left unprocessed because the batch was aborted after a global failure.
|
|
68
|
+
for (let index = 0; index < pages.length; index++) {
|
|
69
|
+
if (!results[index]) {
|
|
70
|
+
results[index] = {
|
|
71
|
+
file: pages[index]!.path,
|
|
72
|
+
error: 'Not checked — stopped after a credits or availability error.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { addLog, ErrorLog, SpinnerLog, removeLastLog } from '@mintlify/previewing';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { Text } from 'ink';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { CMD_EXEC_PATH, terminate } from '../helpers.js';
|
|
8
|
+
import { trackEvent } from '../telemetry/track.js';
|
|
9
|
+
import { checkPages } from './client.js';
|
|
10
|
+
import { resolveChangedFiles, resolveExplicitFiles } from './resolveFiles.js';
|
|
11
|
+
import type { DeslopCheckedResponse, DeslopFileResult } from './types.js';
|
|
12
|
+
import { normalizeWhitespace, summarizeWhitespace } from './whitespace.js';
|
|
13
|
+
|
|
14
|
+
type OutputFormat = 'table' | 'plain' | 'json';
|
|
15
|
+
|
|
16
|
+
export type DeslopArgs = {
|
|
17
|
+
files?: (string | number)[];
|
|
18
|
+
format?: string;
|
|
19
|
+
subdomain?: string;
|
|
20
|
+
threshold?: number;
|
|
21
|
+
fixWhitespace?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
async function fixWhitespacePass(
|
|
25
|
+
files: string[],
|
|
26
|
+
apply: boolean
|
|
27
|
+
): Promise<{ file: string; summary: string }[]> {
|
|
28
|
+
const changes: { file: string; summary: string }[] = [];
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
const fullPath = path.join(CMD_EXEC_PATH, file);
|
|
31
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
32
|
+
const report = normalizeWhitespace(content);
|
|
33
|
+
const summary = summarizeWhitespace(report.counts);
|
|
34
|
+
if (!report.changed || !summary) continue;
|
|
35
|
+
if (apply) await fs.writeFile(fullPath, report.fixed);
|
|
36
|
+
changes.push({ file, summary });
|
|
37
|
+
}
|
|
38
|
+
return changes;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Paint = (text: string) => string;
|
|
42
|
+
|
|
43
|
+
type Palette = { green: Paint; red: Paint; dim: Paint };
|
|
44
|
+
|
|
45
|
+
const identity: Paint = (text) => text;
|
|
46
|
+
|
|
47
|
+
const colorPalette: Palette = { green: chalk.green, red: chalk.red, dim: chalk.dim };
|
|
48
|
+
const plainPalette: Palette = { green: identity, red: identity, dim: identity };
|
|
49
|
+
|
|
50
|
+
function resolveFormat(argv: { format?: string }): OutputFormat {
|
|
51
|
+
if (argv.format === 'table' || argv.format === 'plain' || argv.format === 'json')
|
|
52
|
+
return argv.format;
|
|
53
|
+
return 'table';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function output(format: OutputFormat, text: string) {
|
|
57
|
+
if (format === 'table') {
|
|
58
|
+
addLog(<Text>{text}</Text>);
|
|
59
|
+
} else {
|
|
60
|
+
process.stdout.write(text + '\n');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function requireSubdomain(subdomain: string | undefined): string {
|
|
65
|
+
if (!subdomain) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
'No subdomain set. Pass --subdomain, or run `mint config set subdomain <name>`.'
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return subdomain;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function flaggedFraction(response: DeslopCheckedResponse): number {
|
|
74
|
+
return response.fractionAi + response.fractionAiAssisted;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A fully-human page (fraction 0) never fails, even at --threshold 0.
|
|
78
|
+
function isFailing(fraction: number, threshold: number): boolean {
|
|
79
|
+
return fraction > 0 && fraction >= threshold;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function truncateText(text: string, max = 100): string {
|
|
83
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
84
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderResult(result: DeslopFileResult, threshold: number, palette: Palette): string[] {
|
|
88
|
+
if ('error' in result) {
|
|
89
|
+
return [`${palette.red('✗')} ${result.file} — ${palette.red(result.error)}`];
|
|
90
|
+
}
|
|
91
|
+
const response = result.response;
|
|
92
|
+
if (response.skipped !== null) {
|
|
93
|
+
return [`${palette.dim('○')} ${result.file} — ${palette.dim(`skipped (${response.skipped})`)}`];
|
|
94
|
+
}
|
|
95
|
+
const fraction = flaggedFraction(response);
|
|
96
|
+
const failing = isFailing(fraction, threshold);
|
|
97
|
+
const icon = failing ? palette.red('✗') : palette.green('✓');
|
|
98
|
+
const percent = Math.round(fraction * 100);
|
|
99
|
+
const lines = [`${icon} ${result.file} — ${response.predictionShort} (${percent}% AI-written)`];
|
|
100
|
+
if (!failing) return lines;
|
|
101
|
+
for (const window of response.windows) {
|
|
102
|
+
lines.push(
|
|
103
|
+
` L${window.startLine}–${window.endLine} [${window.label} ${window.aiAssistanceScore.toFixed(2)}]`
|
|
104
|
+
);
|
|
105
|
+
lines.push(` ${palette.dim(truncateText(window.text))}`);
|
|
106
|
+
for (const rewrite of window.rewrites ?? []) {
|
|
107
|
+
lines.push(` ↻ ${rewrite.text}`);
|
|
108
|
+
lines.push(` ${palette.dim(rewrite.rationale)}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return lines;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type Summary = {
|
|
115
|
+
checked: number;
|
|
116
|
+
passed: number;
|
|
117
|
+
failed: number;
|
|
118
|
+
skipped: number;
|
|
119
|
+
creditsCharged: number;
|
|
120
|
+
errors: number;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
function summarize(results: DeslopFileResult[], threshold: number): Summary {
|
|
124
|
+
const summary: Summary = {
|
|
125
|
+
checked: 0,
|
|
126
|
+
passed: 0,
|
|
127
|
+
failed: 0,
|
|
128
|
+
skipped: 0,
|
|
129
|
+
creditsCharged: 0,
|
|
130
|
+
errors: 0,
|
|
131
|
+
};
|
|
132
|
+
for (const result of results) {
|
|
133
|
+
if ('error' in result) {
|
|
134
|
+
summary.errors += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
summary.creditsCharged += result.response.creditsCharged;
|
|
138
|
+
if (result.response.skipped !== null) {
|
|
139
|
+
summary.skipped += 1;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
summary.checked += 1;
|
|
143
|
+
if (isFailing(flaggedFraction(result.response), threshold)) {
|
|
144
|
+
summary.failed += 1;
|
|
145
|
+
} else {
|
|
146
|
+
summary.passed += 1;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return summary;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderSummaryLine(summary: Summary, palette: Palette): string {
|
|
153
|
+
const parts = [
|
|
154
|
+
`${summary.checked} checked`,
|
|
155
|
+
palette.green(`${summary.passed} passed`),
|
|
156
|
+
summary.failed > 0 ? palette.red(`${summary.failed} failed`) : `${summary.failed} failed`,
|
|
157
|
+
palette.dim(`${summary.skipped} skipped`),
|
|
158
|
+
`${summary.creditsCharged} ${summary.creditsCharged === 1 ? 'credit' : 'credits'} used`,
|
|
159
|
+
];
|
|
160
|
+
if (summary.errors > 0) {
|
|
161
|
+
parts.push(palette.red(`${summary.errors} ${summary.errors === 1 ? 'error' : 'errors'}`));
|
|
162
|
+
}
|
|
163
|
+
return parts.join(' · ');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const deslopHandler = async (argv: DeslopArgs) => {
|
|
167
|
+
const format = resolveFormat(argv);
|
|
168
|
+
let spinnerAdded = false;
|
|
169
|
+
const threshold = typeof argv.threshold === 'number' ? argv.threshold : 0.5;
|
|
170
|
+
try {
|
|
171
|
+
const subdomain = requireSubdomain(argv.subdomain);
|
|
172
|
+
const fileArgs = (argv.files ?? []).map(String).filter(Boolean);
|
|
173
|
+
const files =
|
|
174
|
+
fileArgs.length > 0
|
|
175
|
+
? await resolveExplicitFiles(CMD_EXEC_PATH, fileArgs)
|
|
176
|
+
: await resolveChangedFiles(CMD_EXEC_PATH);
|
|
177
|
+
|
|
178
|
+
if (files.length === 0) {
|
|
179
|
+
if (format === 'json') {
|
|
180
|
+
process.stderr.write('No changed docs pages to check.\n');
|
|
181
|
+
output(
|
|
182
|
+
format,
|
|
183
|
+
JSON.stringify(
|
|
184
|
+
{
|
|
185
|
+
threshold,
|
|
186
|
+
results: [],
|
|
187
|
+
summary: {
|
|
188
|
+
checked: 0,
|
|
189
|
+
passed: 0,
|
|
190
|
+
failed: 0,
|
|
191
|
+
skipped: 0,
|
|
192
|
+
errors: 0,
|
|
193
|
+
creditsCharged: 0,
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
null,
|
|
197
|
+
2
|
|
198
|
+
)
|
|
199
|
+
);
|
|
200
|
+
} else {
|
|
201
|
+
output(format, 'No changed docs pages to check.');
|
|
202
|
+
}
|
|
203
|
+
await terminate(0);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const whitespaceChanges = argv.fixWhitespace ? await fixWhitespacePass(files, true) : [];
|
|
208
|
+
|
|
209
|
+
const progressMessage = `Checking ${files.length} ${files.length === 1 ? 'page' : 'pages'} for AI-sounding prose...`;
|
|
210
|
+
if (format === 'table') {
|
|
211
|
+
addLog(<SpinnerLog message={progressMessage} />);
|
|
212
|
+
spinnerAdded = true;
|
|
213
|
+
} else {
|
|
214
|
+
process.stderr.write(progressMessage + '\n');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const pages = await Promise.all(
|
|
218
|
+
files.map(async (file) => ({
|
|
219
|
+
path: file,
|
|
220
|
+
content: await fs.readFile(path.join(CMD_EXEC_PATH, file), 'utf-8'),
|
|
221
|
+
}))
|
|
222
|
+
);
|
|
223
|
+
const results = await checkPages(subdomain, pages);
|
|
224
|
+
if (spinnerAdded) {
|
|
225
|
+
removeLastLog();
|
|
226
|
+
spinnerAdded = false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const summary = summarize(results, threshold);
|
|
230
|
+
|
|
231
|
+
void trackEvent('cli.deslop.executed', {
|
|
232
|
+
fileCount: files.length,
|
|
233
|
+
flaggedCount: summary.failed,
|
|
234
|
+
format,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (format === 'json') {
|
|
238
|
+
output(
|
|
239
|
+
format,
|
|
240
|
+
JSON.stringify(
|
|
241
|
+
{
|
|
242
|
+
threshold,
|
|
243
|
+
results: results.map((result) =>
|
|
244
|
+
'error' in result ? { path: result.file, error: result.error } : result.response
|
|
245
|
+
),
|
|
246
|
+
summary: {
|
|
247
|
+
checked: summary.checked,
|
|
248
|
+
passed: summary.passed,
|
|
249
|
+
failed: summary.failed,
|
|
250
|
+
skipped: summary.skipped,
|
|
251
|
+
errors: summary.errors,
|
|
252
|
+
creditsCharged: summary.creditsCharged,
|
|
253
|
+
},
|
|
254
|
+
whitespaceFixed: whitespaceChanges,
|
|
255
|
+
},
|
|
256
|
+
null,
|
|
257
|
+
2
|
|
258
|
+
)
|
|
259
|
+
);
|
|
260
|
+
} else {
|
|
261
|
+
const palette = format === 'table' ? colorPalette : plainPalette;
|
|
262
|
+
const lines: string[] = [];
|
|
263
|
+
for (const change of whitespaceChanges) {
|
|
264
|
+
lines.push(`${palette.green('⇥')} ${change.file} — whitespace fixed (${change.summary})`);
|
|
265
|
+
}
|
|
266
|
+
if (whitespaceChanges.length) lines.push('');
|
|
267
|
+
for (const result of results) {
|
|
268
|
+
lines.push(...renderResult(result, threshold, palette));
|
|
269
|
+
}
|
|
270
|
+
lines.push('');
|
|
271
|
+
lines.push(renderSummaryLine(summary, palette));
|
|
272
|
+
output(format, lines.join('\n'));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
await terminate(summary.failed > 0 || summary.errors > 0 ? 1 : 0);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
const message = err instanceof Error ? err.message : 'unknown error';
|
|
278
|
+
if (format === 'table') {
|
|
279
|
+
if (spinnerAdded) removeLastLog();
|
|
280
|
+
addLog(<ErrorLog message={message} />);
|
|
281
|
+
} else {
|
|
282
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
283
|
+
}
|
|
284
|
+
await terminate(1);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { categorizeFilePaths, getMintIgnore } from '@mintlify/prebuild';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const GLOB_CHARS = /[*?]/;
|
|
7
|
+
const DOCS_FILE = /\.mdx?$/i;
|
|
8
|
+
const REGEX_SPECIAL = /[.+^${}()|\\[\]]/;
|
|
9
|
+
const SKIPPED_DIRS = new Set(['.git', 'node_modules']);
|
|
10
|
+
|
|
11
|
+
function toPosix(filePath: string): string {
|
|
12
|
+
return filePath.split(path.sep).join('/');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeArg(arg: string): string {
|
|
16
|
+
return toPosix(arg).replace(/^\.\//, '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function globToRegExp(pattern: string): RegExp {
|
|
20
|
+
let source = '';
|
|
21
|
+
let i = 0;
|
|
22
|
+
while (i < pattern.length) {
|
|
23
|
+
const char = pattern[i]!;
|
|
24
|
+
if (char === '*') {
|
|
25
|
+
if (pattern[i + 1] === '*') {
|
|
26
|
+
if (pattern[i + 2] === '/') {
|
|
27
|
+
source += '(?:[^/]+/)*';
|
|
28
|
+
i += 3;
|
|
29
|
+
} else {
|
|
30
|
+
source += '.*';
|
|
31
|
+
i += 2;
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
source += '[^/]*';
|
|
35
|
+
i += 1;
|
|
36
|
+
}
|
|
37
|
+
} else if (char === '?') {
|
|
38
|
+
source += '[^/]';
|
|
39
|
+
i += 1;
|
|
40
|
+
} else {
|
|
41
|
+
source += REGEX_SPECIAL.test(char) ? `\\${char}` : char;
|
|
42
|
+
i += 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return new RegExp(`^${source}$`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function exists(filePath: string): Promise<boolean> {
|
|
49
|
+
try {
|
|
50
|
+
await fs.access(filePath);
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function isFile(filePath: string): Promise<boolean> {
|
|
58
|
+
try {
|
|
59
|
+
return (await fs.stat(filePath)).isFile();
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function walk(baseDir: string, dir = ''): Promise<string[]> {
|
|
66
|
+
const entries = await fs.readdir(path.join(baseDir, dir), { withFileTypes: true });
|
|
67
|
+
const files: string[] = [];
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (SKIPPED_DIRS.has(entry.name)) continue;
|
|
70
|
+
const rel = dir ? `${dir}/${entry.name}` : entry.name;
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
files.push(...(await walk(baseDir, rel)));
|
|
73
|
+
} else {
|
|
74
|
+
files.push(rel);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return files;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function resolveExplicitFiles(baseDir: string, args: string[]): Promise<string[]> {
|
|
81
|
+
let allFiles: string[] | null = null;
|
|
82
|
+
const matched: string[] = [];
|
|
83
|
+
const unmatched: string[] = [];
|
|
84
|
+
|
|
85
|
+
for (const arg of args) {
|
|
86
|
+
const pattern = normalizeArg(arg);
|
|
87
|
+
if (GLOB_CHARS.test(pattern)) {
|
|
88
|
+
allFiles = allFiles ?? (await walk(baseDir));
|
|
89
|
+
const regex = globToRegExp(pattern);
|
|
90
|
+
const hits = allFiles.filter((file) => DOCS_FILE.test(file) && regex.test(file)).sort();
|
|
91
|
+
if (hits.length === 0) {
|
|
92
|
+
unmatched.push(arg);
|
|
93
|
+
} else {
|
|
94
|
+
matched.push(...hits);
|
|
95
|
+
}
|
|
96
|
+
} else if (await isFile(path.join(baseDir, pattern))) {
|
|
97
|
+
matched.push(pattern);
|
|
98
|
+
} else {
|
|
99
|
+
unmatched.push(arg);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (unmatched.length > 0) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`No matching files for: ${unmatched.join(', ')} (globs match .md and .mdx files)`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return [...new Set(matched)];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function runGit(args: string[], cwd: string): Promise<string> {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
execFile('git', args, { cwd }, (error, stdout) => {
|
|
115
|
+
if (error) reject(error);
|
|
116
|
+
else resolve(stdout);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseGitLines(stdout: string): string[] {
|
|
122
|
+
return stdout
|
|
123
|
+
.split('\n')
|
|
124
|
+
.map((line) => line.trim())
|
|
125
|
+
.filter(Boolean);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function resolveChangedFiles(baseDir: string): Promise<string[]> {
|
|
129
|
+
try {
|
|
130
|
+
await runGit(['rev-parse', '--is-inside-work-tree'], baseDir);
|
|
131
|
+
} catch {
|
|
132
|
+
throw new Error(
|
|
133
|
+
'Not a git repository, so changed pages cannot be detected. Pass explicit paths: `mint deslop <files..>`.'
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let diff: string;
|
|
138
|
+
let untracked: string;
|
|
139
|
+
try {
|
|
140
|
+
[diff, untracked] = await Promise.all([
|
|
141
|
+
runGit(['-c', 'core.quotePath=false', 'diff', '--name-only', '--relative', 'HEAD'], baseDir),
|
|
142
|
+
runGit(['-c', 'core.quotePath=false', 'ls-files', '--others', '--exclude-standard'], baseDir),
|
|
143
|
+
]);
|
|
144
|
+
} catch {
|
|
145
|
+
throw new Error(
|
|
146
|
+
'Could not detect changed pages (does the repository have any commits?). Pass explicit paths: `mint deslop <files..>`.'
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const candidates = [...new Set([...parseGitLines(diff), ...parseGitLines(untracked)])]
|
|
151
|
+
.map(normalizeArg)
|
|
152
|
+
.filter((file) => DOCS_FILE.test(file));
|
|
153
|
+
|
|
154
|
+
const existing: string[] = [];
|
|
155
|
+
for (const file of candidates) {
|
|
156
|
+
if (await exists(path.join(baseDir, file))) existing.push(file);
|
|
157
|
+
}
|
|
158
|
+
if (existing.length === 0) return [];
|
|
159
|
+
|
|
160
|
+
if (!(await exists(path.join(baseDir, 'docs.json')))) {
|
|
161
|
+
return existing;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const mintIgnore = await getMintIgnore(baseDir);
|
|
166
|
+
const { contentFilenames } = await categorizeFilePaths(baseDir, mintIgnore);
|
|
167
|
+
const contentSet = new Set(contentFilenames.map((file) => toPosix(file).replace(/^\//, '')));
|
|
168
|
+
return existing.filter((file) => contentSet.has(file));
|
|
169
|
+
} catch {
|
|
170
|
+
// docs.json is present but its navigation could not be resolved. Fail closed
|
|
171
|
+
// rather than sending possibly-ignored files to the credit-consuming API.
|
|
172
|
+
throw new Error(
|
|
173
|
+
'Could not read docs.json to determine which pages to check. Pass explicit paths: `mint deslop <files..>`.'
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|