@mintlify/cli 4.0.1733 → 4.0.1735

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.
Files changed (38) hide show
  1. package/__test__/agent-claim.test.ts +17 -22
  2. package/__test__/agent-credentials.test.ts +91 -4
  3. package/__test__/deploy.test.ts +4 -6
  4. package/__test__/validateCommand.test.ts +141 -0
  5. package/__test__/validateEngine.test.ts +46 -0
  6. package/__test__/validateRules.test.ts +500 -0
  7. package/bin/agent-claim.js +3 -10
  8. package/bin/agent-credentials.js +75 -6
  9. package/bin/cli.js +22 -1
  10. package/bin/deploy.js +1 -8
  11. package/bin/resolveFiles.js +3 -1
  12. package/bin/tsconfig.build.tsbuildinfo +1 -1
  13. package/bin/validate/command.js +58 -0
  14. package/bin/validate/engine.js +76 -0
  15. package/bin/validate/files.js +148 -0
  16. package/bin/validate/mdx.js +201 -0
  17. package/bin/validate/output.js +28 -0
  18. package/bin/validate/rules/content/voidTags.js +35 -0
  19. package/bin/validate/rules/index.js +3 -0
  20. package/bin/validate/rules/parseError.js +7 -0
  21. package/bin/validate/site.js +55 -0
  22. package/bin/validate/types.js +1 -0
  23. package/package.json +8 -8
  24. package/src/agent-claim.ts +1 -10
  25. package/src/agent-credentials.ts +53 -10
  26. package/src/cli.tsx +24 -1
  27. package/src/deploy.ts +2 -9
  28. package/src/resolveFiles.ts +3 -1
  29. package/src/validate/command.tsx +71 -0
  30. package/src/validate/engine.ts +93 -0
  31. package/src/validate/files.ts +212 -0
  32. package/src/validate/mdx.ts +252 -0
  33. package/src/validate/output.tsx +56 -0
  34. package/src/validate/rules/content/voidTags.ts +35 -0
  35. package/src/validate/rules/index.ts +5 -0
  36. package/src/validate/rules/parseError.ts +9 -0
  37. package/src/validate/site.ts +52 -0
  38. package/src/validate/types.ts +88 -0
@@ -0,0 +1,500 @@
1
+ import { getAST } from '@mintlify/common';
2
+ import fs from 'fs-extra';
3
+ import fsp from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ import { checkSite, selectRules, UsageError } from '../src/validate/engine.js';
8
+ import { toParseError } from '../src/validate/files.js';
9
+ import { applyEdits, maskNonMarkup, relocate, unmapEdit } from '../src/validate/mdx.js';
10
+ import type { Diagnostic } from '../src/validate/types.js';
11
+
12
+ vi.mock('@mintlify/prebuild', async (importOriginal) => ({
13
+ ...(await importOriginal<typeof import('@mintlify/prebuild')>()),
14
+ categorizeFilePaths: vi.fn(() => {
15
+ throw new Error('lint must not categorize every file');
16
+ }),
17
+ }));
18
+
19
+ const hasErrors = (diagnostics: Diagnostic[]) =>
20
+ diagnostics.some(({ severity }) => severity === 'error');
21
+
22
+ /** Runs the file rules (all defaults, or exactly `rules`) the way mint validate --no-build does. */
23
+ const check = async (dir: string, { rules, files }: { rules?: string[]; files?: string[] }) => {
24
+ const selected = selectRules({ disable: ['all'], enable: rules ?? ['content'] });
25
+ const result = await checkSite(dir, selected, {
26
+ selected: files && new Set(files),
27
+ localSchema: false,
28
+ });
29
+ const issues = result.diagnostics.map((diagnostic) => ({
30
+ ...diagnostic,
31
+ fixable: diagnostic.fix !== undefined,
32
+ }));
33
+ return {
34
+ issues,
35
+ filesChecked: result.site.files.filter(({ file }) => !files || files.includes(file)).length,
36
+ };
37
+ };
38
+
39
+ const write = (dir: string, files: Record<string, string>) => {
40
+ fs.writeJSONSync(path.join(dir, 'docs.json'), {
41
+ name: 'Test',
42
+ navigation: { pages: ['index'] },
43
+ });
44
+ for (const [file, content] of Object.entries(files)) {
45
+ fs.outputFileSync(path.join(dir, file), content);
46
+ }
47
+ };
48
+
49
+ describe('validate rules', () => {
50
+ let dir: string;
51
+
52
+ beforeEach(() => {
53
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lint-'));
54
+ });
55
+
56
+ afterEach(() => {
57
+ fs.removeSync(dir);
58
+ });
59
+
60
+ const read = (file: string) => fs.readFileSync(path.join(dir, file), 'utf-8');
61
+
62
+ /** The edit each diagnostic carries, as the text it would replace and its replacement. */
63
+ const previews = (issues: Diagnostic[]) =>
64
+ issues.map(
65
+ ({ file, fix }) => fix && { before: read(file).slice(fix.start, fix.end), after: fix.text }
66
+ );
67
+
68
+ it('flags lowercase void tags with edits, skipping code and components', async () => {
69
+ write(dir, {
70
+ 'index.mdx':
71
+ '---\ntitle: Home\n---\n\nline<br>\n\n<hr>\n\n<img src="/a.png" alt="a">\n\n<Link>x</Link>\n\n`<br>`\n\n```html\n<br>\n```\n',
72
+ });
73
+
74
+ const result = await check(dir, {});
75
+
76
+ expect(result.issues.map(({ rule }) => rule)).toEqual(['void-tags', 'void-tags', 'void-tags']);
77
+ expect(previews(result.issues)).toEqual([
78
+ { before: '<br>', after: '<br />' },
79
+ { before: '<hr>', after: '<hr />' },
80
+ { before: '<img src="/a.png" alt="a">', after: '<img src="/a.png" alt="a" />' },
81
+ ]);
82
+ });
83
+
84
+ it('skips void tags inside block-quoted code fences', async () => {
85
+ write(dir, {
86
+ 'index.mdx': '---\ntitle: Home\n---\n\n> ~~~html\n> <br>\n> ~~~\n\nline<br>\n',
87
+ 'other.mdx': '---\ntitle: Other\n---\n\n> ```\n> <br>\n\nafter<br>\n',
88
+ });
89
+
90
+ const result = await check(dir, {});
91
+
92
+ expect(result.issues.map(({ file, line }) => [file, line])).toEqual([
93
+ ['index.mdx', 9],
94
+ ['other.mdx', 8],
95
+ ]);
96
+ expect(previews(result.issues)).toEqual([
97
+ { before: '<br>', after: '<br />' },
98
+ { before: '<br>', after: '<br />' },
99
+ ]);
100
+ });
101
+
102
+ it('keeps an info-string fence line from closing an open fence', async () => {
103
+ write(dir, {
104
+ 'index.mdx':
105
+ '---\ntitle: Home\n---\n\n```bash\n$ one\n\nStep 2\n\n```bash\n$ two <br>\n```\n\nafter<br>\n',
106
+ });
107
+
108
+ const result = await check(dir, {});
109
+
110
+ expect(result.issues.map(({ line }) => line)).toEqual([14]);
111
+ expect(previews(result.issues)).toEqual([{ before: '<br>', after: '<br />' }]);
112
+ });
113
+
114
+ it('does not close a block-quoted fence with an info-string fence line', async () => {
115
+ write(dir, {
116
+ 'index.mdx':
117
+ '---\ntitle: Home\n---\n\n> ```\n> <br>\n> ```bash\n> <hr>\n> ```\n\nafter<br>\n',
118
+ });
119
+
120
+ const result = await check(dir, {});
121
+
122
+ expect(result.issues.map(({ line }) => line)).toEqual([11]);
123
+ expect(previews(result.issues)).toEqual([{ before: '<br>', after: '<br />' }]);
124
+ });
125
+
126
+ it('skips custom elements, frontmatter, ESM and attribute strings for void tags', async () => {
127
+ write(dir, {
128
+ 'index.mdx':
129
+ '---\ntitle: "a<br>b"\n---\n\nimport { A } from "/snippets/a.jsx";\nexport const html = "<br>";\n\n<img-comparison-slider>x</img-comparison-slider>\n\n<Card title="one<br>two">x</Card>\n\n<img src="/a.png" alt="<hr>">\n\n`` a`<br>`b ``\n',
130
+ });
131
+
132
+ const { issues } = await check(dir, { rules: ['void-tags'] });
133
+
134
+ expect(issues.map(({ line }) => line)).toEqual([12]);
135
+ expect(previews(issues)).toEqual([
136
+ { before: '<img src="/a.png" alt="<hr>">', after: '<img src="/a.png" alt="<hr>" />' },
137
+ ]);
138
+ });
139
+
140
+ it('leaves escaped void tags alone', async () => {
141
+ write(dir, { 'index.mdx': '---\ntitle: Home\n---\n\nText \\<br> and \\<img src="/a.png">\n' });
142
+
143
+ expect((await check(dir, { rules: ['void-tags'] })).issues).toEqual([]);
144
+ });
145
+
146
+ it('substitutes docs.json variables in snippets before checking them', async () => {
147
+ write(dir, {
148
+ 'index.mdx': '---\ntitle: Home\n---\n\nok\n',
149
+ 'snippets/note.mdx': 'Hello {{foo.bar}} line<br>\n',
150
+ });
151
+ fs.writeJSONSync(path.join(dir, 'docs.json'), {
152
+ name: 'Test',
153
+ navigation: { pages: ['index'] },
154
+ variables: { 'foo.bar': 'a much longer value' },
155
+ });
156
+
157
+ const { issues } = await check(dir, {});
158
+
159
+ expect(issues.map(({ rule, line, column }) => [rule, line, column])).toEqual([
160
+ ['void-tags', 1, 23],
161
+ ]);
162
+ expect(previews(issues)).toEqual([{ before: '<br>', after: '<br />' }]);
163
+ });
164
+
165
+ it('leaves void tags that already have a closing tag', async () => {
166
+ const page = '---\ntitle: Home\n---\n\n<img src="/a.png" alt="a"></img>\n\nline<br></br>\n';
167
+ write(dir, { 'index.mdx': page });
168
+
169
+ expect((await check(dir, { rules: ['void-tags'] })).issues).toEqual([]);
170
+ });
171
+
172
+ it('accepts <MDX> fragments the build preprocesses', async () => {
173
+ write(dir, {
174
+ 'index.mdx':
175
+ '---\ntitle: Home\n---\n\n{true ? <MDX>\n```bash\n<root>/{arch}/app.vpkg\n```\n</MDX> : null}\n',
176
+ });
177
+
178
+ expect((await check(dir, {})).issues).toEqual([]);
179
+ });
180
+
181
+ it('walks only Markdown files and respects .mintignore', async () => {
182
+ write(dir, {
183
+ '.mintignore': 'drafts/\n',
184
+ 'openapi.yaml': 'openapi: [unclosed\n',
185
+ 'drafts/wip.mdx': '---\ntitle: Wip\n---\n\nline<br>\n',
186
+ 'SKILL.md': 'line<br>\n',
187
+ 'index.mdx': '---\ntitle: Home\n---\n\nline<br>\n',
188
+ });
189
+
190
+ const result = await check(dir, { rules: ['void-tags'] });
191
+
192
+ expect(result.filesChecked).toBe(1);
193
+ expect(result.issues.map(({ file }) => file)).toEqual(['index.mdx']);
194
+ });
195
+
196
+ it('does not mask void tags outside strings, but skips expression strings, multi-line exports and BOM frontmatter', async () => {
197
+ write(dir, {
198
+ 'index.mdx':
199
+ '---\ntitle: "a<br>b"\n---\n\nexport const items = [\n "<br>",\n\n "<hr>",\n];\n\nText {"<br>"} and {cond && \'<img>\'}\n\nline<br>\n',
200
+ });
201
+
202
+ const { issues } = await check(dir, { rules: ['void-tags'] });
203
+
204
+ expect(issues.map(({ line }) => line)).toEqual([13]);
205
+ });
206
+
207
+ it('ends an export block at a blank line once it parses, despite brackets in literals', async () => {
208
+ write(dir, {
209
+ 'index.mdx':
210
+ '---\ntitle: Home\n---\n\nexport const css = `\n .a { color: red;\n`;\n\nfirst<br>\n\nexport const re = /[(]/;\n\nsecond<br>\n\nexport const items = [\n "<br>",\n\n "<hr>",\n];\n\nthird<br>\n',
211
+ });
212
+
213
+ const { issues } = await check(dir, { rules: ['void-tags'] });
214
+
215
+ expect(issues.map(({ line }) => line)).toEqual([9, 13, 21]);
216
+ });
217
+
218
+ it('masks regex literals in expression value position but not division', async () => {
219
+ write(dir, {
220
+ 'index.mdx':
221
+ "---\ntitle: Home\n---\n\n{items.map((s) => s.replace(/<br>/g, ''))}\n\n{total / width / 2} and<br>\n\n{value.replace(/\\/<hr>/, '')}\n\nline<br>\n",
222
+ });
223
+
224
+ const { issues } = await check(dir, { rules: ['void-tags'] });
225
+
226
+ expect(issues.map(({ rule, line }) => [rule, line])).toEqual([
227
+ ['void-tags', 7],
228
+ ['void-tags', 11],
229
+ ]);
230
+ expect(previews(issues)).toEqual([
231
+ { before: '<br>', after: '<br />' },
232
+ { before: '<br>', after: '<br />' },
233
+ ]);
234
+ });
235
+
236
+ it('masks line comments inside expressions', async () => {
237
+ write(dir, {
238
+ 'index.mdx':
239
+ '---\ntitle: Home\n---\n\n{\n// <br>\n}\n\n{(\n // <hr>\n value\n)}\n\n{total // <br>\n}\n\nline<br>\n',
240
+ });
241
+
242
+ const { issues } = await check(dir, { rules: ['void-tags'] });
243
+
244
+ expect(issues.map(({ line }) => line)).toEqual([17]);
245
+ });
246
+
247
+ it('treats quotes in JSX text inside an expression as text, not strings', async () => {
248
+ write(dir, {
249
+ 'index.mdx':
250
+ '---\ntitle: Home\n---\n\n{cond && <p>Don\'t {"<br>"} miss this</p>}\n\nline<br>\n\n{list.map((item) => <li key={item}>It\'s {item}</li>)}\n\nmore<hr>\n',
251
+ });
252
+
253
+ const { issues } = await check(dir, { rules: ['void-tags'] });
254
+
255
+ expect(issues.map(({ line }) => line)).toEqual([7, 11]);
256
+ });
257
+
258
+ it('keeps file order when linting many files concurrently', async () => {
259
+ const names = Array.from(
260
+ { length: 40 },
261
+ (_, index) => `page-${String(index).padStart(2, '0')}.mdx`
262
+ );
263
+ write(dir, Object.fromEntries(names.map((name) => [name, '---\ntitle: T\n---\n\nline<br>\n'])));
264
+
265
+ const result = await check(dir, { rules: ['void-tags'] });
266
+
267
+ const flagged = result.issues.map(({ file }) => file);
268
+ expect(flagged).toEqual([...flagged].sort());
269
+ expect(flagged).toHaveLength(40);
270
+ });
271
+
272
+ it('rejects unknown rule ids', async () => {
273
+ write(dir, { 'index.mdx': '---\ntitle: Home\n---\n' });
274
+
275
+ await expect(check(dir, { rules: ['nope'] })).rejects.toThrow('unknown rule or group "nope"');
276
+ });
277
+
278
+ it('reports every file that fails to parse in one run, with its position', async () => {
279
+ write(dir, {
280
+ 'index.mdx': '---\ntitle: Home\n---\n\nok\n',
281
+ 'a.mdx': '---\ntitle: A\n---\n\n<Note>\nunclosed\n',
282
+ 'b.mdx': '---\ntitle: B\n---\n\nText {broken\n',
283
+ });
284
+
285
+ const result = await check(dir, {});
286
+ const parseErrors = result.issues.filter(({ rule }) => rule === 'parse-error');
287
+
288
+ expect(parseErrors.map(({ file }) => file).sort()).toEqual(['a.mdx', 'b.mdx']);
289
+ for (const issue of parseErrors) {
290
+ expect(issue).toMatchObject({ severity: 'error', fixable: false });
291
+ expect(issue.line).toBeGreaterThan(0);
292
+ expect(issue.message).toContain('(other checks were skipped for this file)');
293
+ }
294
+ expect(hasErrors(result.issues)).toBe(true);
295
+ });
296
+
297
+ it('still runs parse-error when --rules picks only other rules', async () => {
298
+ write(dir, { 'index.mdx': '---\ntitle: Home\n---\n\n<Note>\nunclosed\n' });
299
+
300
+ const result = await check(dir, { rules: ['void-tags'] });
301
+
302
+ expect(result.issues.map(({ rule }) => rule)).toEqual(['parse-error']);
303
+ await expect(check(dir, { rules: ['parse-error'] })).rejects.toThrow(
304
+ "parse-error always runs and can't be disabled"
305
+ );
306
+ });
307
+
308
+ it('lints snippets', async () => {
309
+ write(dir, {
310
+ 'index.mdx': '---\ntitle: Home\n---\n\nok\n',
311
+ 'snippets/note.mdx': '---\ntitle: Same\n---\n\nline<br>\n',
312
+ });
313
+
314
+ const result = await check(dir, {});
315
+
316
+ expect(result.issues.map(({ file, rule }) => [file, rule])).toEqual([
317
+ ['snippets/note.mdx', 'void-tags'],
318
+ ]);
319
+ });
320
+
321
+ it('rejects --files that name no page or snippet of the site', async () => {
322
+ write(dir, { 'index.mdx': '---\ntitle: Home\n---\n\nok\n', 'README.md': '# Readme\n' });
323
+
324
+ await expect(check(dir, { files: ['README.md'] })).rejects.toThrow(UsageError);
325
+ });
326
+
327
+ it('only lints the files it is given', async () => {
328
+ write(dir, {
329
+ 'index.mdx': '---\ntitle: Home\n---\n\nline<br>\n',
330
+ 'other.mdx': '---\ntitle: Other\n---\n\nline<br>\n',
331
+ });
332
+
333
+ const result = await check(dir, { files: ['other.mdx'] });
334
+
335
+ expect(result.filesChecked).toBe(1);
336
+ expect(result.issues.map(({ file }) => file)).toEqual(['other.mdx']);
337
+ });
338
+
339
+ it('never reads the content of unselected files in the check pass', async () => {
340
+ write(dir, {
341
+ 'index.mdx': '---\ntitle: Home\n---\n\nline<br>\n',
342
+ 'other.mdx': '---\ntitle: Other\n---\n\nline<br>\n',
343
+ });
344
+ const spy = vi.spyOn(fsp, 'readFile');
345
+
346
+ const result = await check(dir, { rules: ['void-tags'], files: ['other.mdx'] });
347
+
348
+ const reads = spy.mock.calls.map(([file]) => String(file));
349
+ spy.mockRestore();
350
+ expect(reads.some((file) => file.endsWith('other.mdx'))).toBe(true);
351
+ expect(reads.some((file) => file.endsWith('index.mdx'))).toBe(false);
352
+ expect(result.issues.map(({ file }) => file)).toEqual(['other.mdx']);
353
+ });
354
+
355
+ it('skips void tags in block-quoted fences and inline code', async () => {
356
+ write(dir, {
357
+ 'index.mdx': '---\ntitle: Home\n---\n\n> Quote\n>\n> ```html\n> <br>\n> ```\n\nSee `<hr>`.\n',
358
+ });
359
+
360
+ expect((await check(dir, { rules: ['void-tags'] })).issues).toEqual([]);
361
+ });
362
+
363
+ it('flags void tags outside code on a page that fails to parse', async () => {
364
+ write(dir, {
365
+ 'index.mdx':
366
+ '---\ntitle: Home\n---\n\n<Card title="a">\n\nLine<br>\n\n```html\n<br>\n```\n\nSee `<hr>`.\n\n</Card>\n',
367
+ });
368
+
369
+ const { issues } = await check(dir, { rules: ['void-tags'] });
370
+
371
+ expect(issues.map(({ line }) => line)).toEqual([7]);
372
+ expect(previews(issues)).toEqual([{ before: '<br>', after: '<br />' }]);
373
+ });
374
+
375
+ it('does not pair backticks across table cells', async () => {
376
+ write(dir, {
377
+ 'index.mdx': '---\ntitle: Home\n---\n\n| A | B |\n| - | - |\n| `open | <br> close` |\n',
378
+ });
379
+
380
+ const { issues } = await check(dir, { rules: ['void-tags'] });
381
+
382
+ expect(issues.map(({ line, column }) => [line, column])).toEqual([[7, 11]]);
383
+ });
384
+
385
+ it('skips inline code inside table cells', async () => {
386
+ write(dir, {
387
+ 'index.mdx': '---\ntitle: Home\n---\n\n| A | B |\n| - | - |\n| `a | b` | `<br>` |\n',
388
+ });
389
+
390
+ expect((await check(dir, { rules: ['void-tags'] })).issues).toEqual([]);
391
+ });
392
+
393
+ it('does not treat an indented line inside JSX as code', async () => {
394
+ write(dir, { 'index.mdx': '---\ntitle: Home\n---\n\n<Card>\n line<br>\n</Card>\n' });
395
+
396
+ const { issues } = await check(dir, { rules: ['void-tags'] });
397
+
398
+ expect(issues.map(({ line, column }) => [line, column])).toEqual([[6, 9]]);
399
+ });
400
+
401
+ it('does not open inline code at an escaped backtick', async () => {
402
+ write(dir, { 'index.mdx': '---\ntitle: Home\n---\n\nText \\`a<br>\\` and `b\\`<hr>\n' });
403
+
404
+ const { issues } = await check(dir, { rules: ['void-tags'] });
405
+
406
+ expect(previews(issues)).toEqual([
407
+ { before: '<br>', after: '<br />' },
408
+ { before: '<hr>', after: '<hr />' },
409
+ ]);
410
+ });
411
+ });
412
+
413
+ describe('maskNonMarkup', () => {
414
+ it('masks a regex literal after typeof', () => {
415
+ expect(maskNonMarkup('{typeof /<br>/}')).toBe('{typeof / /}');
416
+ });
417
+
418
+ it('masks template strings but keeps the code inside ${}', () => {
419
+ expect(maskNonMarkup('{`a\n\n${x + "<hr>"}\n\nb`}')).toBe('{` \n\n${x + " "}\n\n `}');
420
+ });
421
+
422
+ it('keeps masking after an expression the tokenizer rejects', () => {
423
+ expect(maskNonMarkup('## A {#1-intro}\n\n{"<br>"}')).toBe('## A {#1-intro}\n\n{" "}');
424
+ });
425
+
426
+ it('blanks an escaped tag and does not open an expression at an escaped brace', () => {
427
+ expect(maskNonMarkup('\\<br> \\{"<br>"} {"<br>"}')).toBe('\\ br> \\{"<br>"} {" "}');
428
+ });
429
+ });
430
+
431
+ describe('toParseError', () => {
432
+ it('reads the position from the reason of an unclosed JSX tag', () => {
433
+ let error: unknown;
434
+ try {
435
+ getAST('---\ntitle: A\n---\n\n<Note>\nunclosed\n', 'a.mdx');
436
+ } catch (caught) {
437
+ error = caught;
438
+ }
439
+
440
+ expect(toParseError(error)).toEqual({
441
+ line: 5,
442
+ column: 1,
443
+ message: 'Expected a closing tag for `<Note>`',
444
+ });
445
+ });
446
+ });
447
+
448
+ describe('applyEdits', () => {
449
+ it('keeps a replacement that an insertion at its start precedes', () => {
450
+ expect(
451
+ applyEdits('a<br>b', [
452
+ { start: 1, end: 5, text: '<br />' },
453
+ { start: 1, end: 1, text: 'x' },
454
+ ])
455
+ ).toBe('ax<br />b');
456
+ });
457
+ });
458
+
459
+ describe('mapping back through edits', () => {
460
+ const inserted = [
461
+ { content: 'ab', edits: [{ start: 1, end: 1, text: 'XY' }] },
462
+ { content: 'aXYb', edits: [] },
463
+ ];
464
+ const deleted = [
465
+ { content: 'aXYb', edits: [{ start: 1, end: 3, text: '' }] },
466
+ { content: 'ab', edits: [] },
467
+ ];
468
+
469
+ it('snaps positions at or inside inserted text to the insertion point', () => {
470
+ expect(relocate(inserted, 1, { line: 1, column: 2 })).toEqual({ line: 1, column: 2 });
471
+ expect(relocate(inserted, 1, { line: 1, column: 3 })).toEqual({ line: 1, column: 2 });
472
+ expect(relocate(inserted, 1, { line: 1, column: 5 })).toEqual({ line: 1, column: 3 });
473
+ });
474
+
475
+ it('places a position at a deletion after the deleted text', () => {
476
+ expect(relocate(deleted, 1, { line: 1, column: 2 })).toEqual({ line: 1, column: 4 });
477
+ });
478
+
479
+ it('maps an edit at an insertion point to before the inserted text and rejects one inside it', () => {
480
+ expect(unmapEdit(inserted, 1, { start: 1, end: 1, text: 'Z' })).toEqual({
481
+ start: 1,
482
+ end: 1,
483
+ text: 'Z',
484
+ });
485
+ expect(unmapEdit(inserted, 1, { start: 2, end: 2, text: 'Z' })).toBeUndefined();
486
+ expect(unmapEdit(inserted, 1, { start: 3, end: 3, text: 'Z' })).toEqual({
487
+ start: 1,
488
+ end: 1,
489
+ text: 'Z',
490
+ });
491
+ });
492
+
493
+ it('maps an edit at a deletion point to before the deleted text', () => {
494
+ expect(unmapEdit(deleted, 1, { start: 1, end: 1, text: 'Z' })).toEqual({
495
+ start: 1,
496
+ end: 1,
497
+ text: 'Z',
498
+ });
499
+ });
500
+ });
@@ -45,8 +45,8 @@ const completedClaimSchema = z.object({
45
45
  identity: agentIdentityAssertionSchema,
46
46
  });
47
47
  const signupSchema = z.object({ orgId: z.string().min(1), claimed: z.literal(true) });
48
- export function claimDeployment(options_1, warn_1) {
49
- return __awaiter(this, arguments, void 0, function* (options, warn, progress = () => { }) {
48
+ export function claimDeployment(options_1) {
49
+ return __awaiter(this, arguments, void 0, function* (options, progress = () => { }) {
50
50
  var _a, _b;
51
51
  const { apiUrl, directory } = options;
52
52
  const code = (_a = options.code) === null || _a === void 0 ? void 0 : _a.trim();
@@ -66,14 +66,7 @@ export function claimDeployment(options_1, warn_1) {
66
66
  if (new URL(discovery.issuer).origin !== new URL(credentials.issuer).origin) {
67
67
  throw new Error('Saved agent credentials belong to another issuer.');
68
68
  }
69
- const save = (value) => __awaiter(this, void 0, void 0, function* () {
70
- try {
71
- yield saveAgentCredentials(apiUrl, directory, value);
72
- }
73
- catch (_a) {
74
- warn('Could not save claimed credentials to the keyring. Continuing for this run; a retry may not recover the completed claim.');
75
- }
76
- });
69
+ const save = (value) => saveAgentCredentials(apiUrl, directory, value);
77
70
  if (!credentials.claimVerified) {
78
71
  progress('Completing the WorkOS claim...');
79
72
  const completed = yield agentResponse(yield agentRequest(`${discovery.agent_auth.claim_endpoint.replace(/\/$/, '')}/complete`, {
@@ -7,11 +7,25 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
+ var __rest = (this && this.__rest) || function (s, e) {
11
+ var t = {};
12
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
13
+ t[p] = s[p];
14
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
15
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
16
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
17
+ t[p[i]] = s[p[i]];
18
+ }
19
+ return t;
20
+ };
10
21
  import { createHash } from 'node:crypto';
22
+ import fs from 'node:fs/promises';
11
23
  import path from 'node:path';
12
24
  import { z } from 'zod';
25
+ import { CONFIG_DIR } from './constants.js';
13
26
  import { getKeytar } from './keyring.js';
14
27
  const service = 'mintlify-agent';
28
+ const fallbackFile = () => path.join(CONFIG_DIR, 'agent-credentials.json');
15
29
  export const agentCredentialsSchema = z.object({
16
30
  registrationId: z.string().min(1),
17
31
  issuer: z.url(),
@@ -29,13 +43,69 @@ function projectAccount(apiUrl, directory) {
29
43
  function registrationAccount(apiUrl, registrationId) {
30
44
  return `${new URL(apiUrl).origin}:${registrationId}`;
31
45
  }
46
+ function readFallback() {
47
+ return __awaiter(this, void 0, void 0, function* () {
48
+ try {
49
+ return z
50
+ .record(z.string(), z.string())
51
+ .parse(JSON.parse(yield fs.readFile(fallbackFile(), 'utf-8')));
52
+ }
53
+ catch (_a) {
54
+ return {};
55
+ }
56
+ });
57
+ }
58
+ function writeFallback(entries) {
59
+ return __awaiter(this, void 0, void 0, function* () {
60
+ yield fs.mkdir(CONFIG_DIR, { recursive: true });
61
+ const temp = `${fallbackFile()}.${process.pid}.tmp`;
62
+ yield fs.writeFile(temp, JSON.stringify(entries, null, 2), { mode: 0o600 });
63
+ yield fs.chmod(temp, 0o600);
64
+ yield fs.rename(temp, fallbackFile());
65
+ });
66
+ }
67
+ function getSecret(account) {
68
+ return __awaiter(this, void 0, void 0, function* () {
69
+ let keyringError;
70
+ try {
71
+ const value = yield (yield getKeytar()).getPassword(service, account);
72
+ if (value)
73
+ return value;
74
+ }
75
+ catch (error) {
76
+ keyringError = error;
77
+ }
78
+ const value = (yield readFallback())[account];
79
+ if (value)
80
+ return value;
81
+ if (keyringError)
82
+ throw keyringError;
83
+ return null;
84
+ });
85
+ }
86
+ function setSecret(account, value) {
87
+ return __awaiter(this, void 0, void 0, function* () {
88
+ try {
89
+ yield (yield getKeytar()).setPassword(service, account, value);
90
+ }
91
+ catch (_a) {
92
+ yield writeFallback(Object.assign(Object.assign({}, (yield readFallback())), { [account]: value }));
93
+ yield getKeytar()
94
+ .then((keytar) => keytar.deletePassword(service, account))
95
+ .catch(() => { });
96
+ return;
97
+ }
98
+ const _b = yield readFallback(), _c = account, stale = _b[_c], rest = __rest(_b, [typeof _c === "symbol" ? _c : _c + ""]);
99
+ if (stale)
100
+ yield writeFallback(rest).catch(() => { });
101
+ });
102
+ }
32
103
  export function loadAgentCredentials(apiUrl, directory) {
33
104
  return __awaiter(this, void 0, void 0, function* () {
34
- const keytar = yield getKeytar();
35
- const registrationId = yield keytar.getPassword(service, projectAccount(apiUrl, directory));
105
+ const registrationId = yield getSecret(projectAccount(apiUrl, directory));
36
106
  if (!registrationId)
37
107
  return null;
38
- const value = yield keytar.getPassword(service, registrationAccount(apiUrl, registrationId));
108
+ const value = yield getSecret(registrationAccount(apiUrl, registrationId));
39
109
  if (!value)
40
110
  throw new Error('Saved agent credentials are missing. Use --new to create another site.');
41
111
  const credentials = agentCredentialsSchema.parse(JSON.parse(value));
@@ -48,8 +118,7 @@ export function loadAgentCredentials(apiUrl, directory) {
48
118
  export function saveAgentCredentials(apiUrl, directory, credentials) {
49
119
  return __awaiter(this, void 0, void 0, function* () {
50
120
  const value = agentCredentialsSchema.parse(credentials);
51
- const keytar = yield getKeytar();
52
- yield keytar.setPassword(service, registrationAccount(apiUrl, value.registrationId), JSON.stringify(value));
53
- yield keytar.setPassword(service, projectAccount(apiUrl, directory), value.registrationId);
121
+ yield setSecret(registrationAccount(apiUrl, value.registrationId), JSON.stringify(value));
122
+ yield setSecret(projectAccount(apiUrl, directory), value.registrationId);
54
123
  });
55
124
  }
package/bin/cli.js CHANGED
@@ -43,6 +43,7 @@ import { sendPublishPrompt, signup } from './signup.js';
43
43
  import { status, getCliSubdomains } from './status.js';
44
44
  import { trackTelemetryPreferenceChange } from './telemetry/track.js';
45
45
  import { update } from './update.js';
46
+ import { runLintStep, splitList } from './validate/command.js';
46
47
  import { renderWelcome } from './welcome.js';
47
48
  import { workflowsBuilder } from './workflows/index.js';
48
49
  // The branded welcome screen replaces yargs' default help, but only for an
@@ -114,7 +115,7 @@ export const cli = ({ packageName = 'mint' }) => {
114
115
  .example('$0 claim --code ABCDEFGH', 'Finish the claim with the code from the user')
115
116
  .epilog('Run from the same directory as mint deploy, or pass --directory. Ask the user to open the claim link and return the code. The CLI reads saved credentials internally; do not read or print keychain secrets. Retry the same command if Mintlify signup fails.'), (argv) => __awaiter(void 0, void 0, void 0, function* () {
116
117
  try {
117
- const message = yield claimDeployment({ apiUrl: API_URL, directory: argv.directory, code: argv.code, email: argv.email }, (message) => process.stderr.write(`Warning: ${message}\n`), (message) => process.stderr.write(`${message}\n`));
118
+ const message = yield claimDeployment({ apiUrl: API_URL, directory: argv.directory, code: argv.code, email: argv.email }, (message) => process.stderr.write(`${message}\n`));
118
119
  process.stdout.write(`${message}\n`);
119
120
  yield terminate(0);
120
121
  }
@@ -303,9 +304,29 @@ export const cli = ({ packageName = 'mint' }) => {
303
304
  type: 'boolean',
304
305
  default: false,
305
306
  description: 'Disable OpenAPI file generation',
307
+ })
308
+ .option('files', {
309
+ type: 'string',
310
+ array: true,
311
+ description: 'Limit page checks to these files or globs; site-wide checks and the build still cover the whole site',
312
+ })
313
+ .option('enable', {
314
+ type: 'string',
315
+ array: true,
316
+ coerce: splitList,
317
+ description: 'Turn on rules or groups (comma-separated), for example after --disable all',
318
+ })
319
+ .option('disable', {
320
+ type: 'string',
321
+ array: true,
322
+ coerce: splitList,
323
+ description: 'Turn off rules or groups (comma-separated), or all; parse errors are still reported',
306
324
  })
307
325
  .usage('usage: mintlify validate [options]')
308
326
  .example('mintlify validate', 'validate the build'), (argv) => __awaiter(void 0, void 0, void 0, function* () {
327
+ const lintPassed = yield runLintStep(argv);
328
+ if (!lintPassed)
329
+ return;
309
330
  if (!argv['disable-openapi']) {
310
331
  let openApiFilenames = [];
311
332
  try {