@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
@@ -54,7 +54,7 @@ afterEach(() => vi.unstubAllGlobals());
54
54
 
55
55
  describe('claim command', () => {
56
56
  it('completes the existing claim, saves the new identity, and signs up with a fresh token', async () => {
57
- expect(await claimDeployment(options, vi.fn())).toContain('successfully');
57
+ expect(await claimDeployment(options)).toContain('successfully');
58
58
  expect(fetchMock).toHaveBeenCalledWith(
59
59
  `${issuer}/agent/identity/claim/complete`,
60
60
  expect.objectContaining({
@@ -83,11 +83,11 @@ describe('claim command', () => {
83
83
  fetchMock.mockImplementation((url, init) =>
84
84
  url.endsWith('/signup') ? response({ code: 'unavailable' }, 503) : implementation(url, init)
85
85
  );
86
- await expect(claimDeployment(options, vi.fn())).rejects.toThrow('503');
86
+ await expect(claimDeployment(options)).rejects.toThrow('503');
87
87
  const saved = vi.mocked(saveAgentCredentials).mock.calls[0]![2];
88
88
  vi.mocked(loadAgentCredentials).mockResolvedValue(saved);
89
89
  fetchMock.mockClear().mockImplementation(implementation);
90
- expect(await claimDeployment(options, vi.fn())).toContain('successfully');
90
+ expect(await claimDeployment(options)).toContain('successfully');
91
91
  expect(fetchMock.mock.calls.some(([url]) => url.endsWith('/complete'))).toBe(false);
92
92
  });
93
93
  it('does not save or sign up after an invalid code', async () => {
@@ -97,25 +97,22 @@ describe('claim command', () => {
97
97
  ? response({ code: 'invalid_user_code' }, 400)
98
98
  : implementation(url, init)
99
99
  );
100
- await expect(claimDeployment(options, vi.fn())).rejects.toThrow('invalid_user_code');
100
+ await expect(claimDeployment(options)).rejects.toThrow('invalid_user_code');
101
101
  expect(saveAgentCredentials).not.toHaveBeenCalled();
102
102
  expect(trackEvent).not.toHaveBeenCalled();
103
103
  expect(fetchMock.mock.calls.some(([url]) => url.endsWith('/signup'))).toBe(false);
104
104
  });
105
- it('warns on storage failure and continues signup without exposing secrets', async () => {
106
- vi.mocked(saveAgentCredentials).mockRejectedValue(new Error('keyring unavailable'));
107
- const warn = vi.fn();
108
- const output = await claimDeployment(options, warn);
109
- expect(output).toContain('successfully');
110
- expect(warn).toHaveBeenCalled();
111
- expect(JSON.stringify([output, warn.mock.calls])).not.toContain('secret-claim');
112
- expect(JSON.stringify([output, warn.mock.calls])).not.toContain('claimed-assertion');
105
+ it('fails before signup when credentials cannot be saved', async () => {
106
+ vi.mocked(saveAgentCredentials).mockRejectedValue(new Error('disk full'));
107
+ await expect(claimDeployment(options)).rejects.toThrow('disk full');
108
+ expect(fetchMock.mock.calls.some(([url]) => url.endsWith('/signup'))).toBe(false);
113
109
  });
114
110
  it('creates only a claim link for --email', async () => {
115
- const output = await claimDeployment(
116
- { apiUrl, directory: '/docs', email: 'person@example.com' },
117
- vi.fn()
118
- );
111
+ const output = await claimDeployment({
112
+ apiUrl,
113
+ directory: '/docs',
114
+ email: 'person@example.com',
115
+ });
119
116
  expect(new URL(output.match(/https?:\/\/\S+/)![0]).searchParams.get('registration')).toBe(
120
117
  'reg'
121
118
  );
@@ -128,15 +125,13 @@ describe('claim command', () => {
128
125
  });
129
126
  it('fails before network calls when credentials are missing', async () => {
130
127
  vi.mocked(loadAgentCredentials).mockResolvedValue(null);
131
- await expect(claimDeployment(options, vi.fn())).rejects.toThrow('No saved agent registration');
128
+ await expect(claimDeployment(options)).rejects.toThrow('No saved agent registration');
132
129
  expect(fetchMock).not.toHaveBeenCalled();
133
130
  });
134
131
  it.each([{ code: undefined }, { code: ' ', email: undefined }, { email: 'person@example.com' }])(
135
132
  'rejects missing or conflicting arguments: %o',
136
133
  async (override) => {
137
- await expect(claimDeployment({ ...options, ...override }, vi.fn())).rejects.toThrow(
138
- 'Provide either'
139
- );
134
+ await expect(claimDeployment({ ...options, ...override })).rejects.toThrow('Provide either');
140
135
  expect(fetchMock).not.toHaveBeenCalled();
141
136
  }
142
137
  );
@@ -145,7 +140,7 @@ describe('claim command', () => {
145
140
  ...original,
146
141
  issuer: 'https://other.example.com',
147
142
  });
148
- await expect(claimDeployment(options, vi.fn())).rejects.toThrow('another issuer');
143
+ await expect(claimDeployment(options)).rejects.toThrow('another issuer');
149
144
  expect(fetchMock.mock.calls).toHaveLength(2);
150
145
  });
151
146
  it('does not report success if signup returns an anonymous identity', async () => {
@@ -155,6 +150,6 @@ describe('claim command', () => {
155
150
  ? response({ orgId: 'org', claimed: false })
156
151
  : implementation(url, init)
157
152
  );
158
- await expect(claimDeployment(options, vi.fn())).rejects.toThrow('unexpected response');
153
+ await expect(claimDeployment(options)).rejects.toThrow('unexpected response');
159
154
  });
160
155
  });
@@ -1,8 +1,21 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
1
4
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
5
 
3
6
  const entries = new Map<string, string>();
4
- const keytar = vi.hoisted(() => ({ getPassword: vi.fn(), setPassword: vi.fn() }));
7
+ const keytar = vi.hoisted(() => ({
8
+ getPassword: vi.fn(),
9
+ setPassword: vi.fn(),
10
+ deletePassword: vi.fn(),
11
+ }));
5
12
  vi.mock('../src/keyring.js', () => ({ getKeytar: async () => keytar }));
13
+ const configDir = vi.hoisted(() => ({ path: '' }));
14
+ vi.mock('../src/constants.js', () => ({
15
+ get CONFIG_DIR() {
16
+ return configDir.path;
17
+ },
18
+ }));
6
19
 
7
20
  import { loadAgentCredentials, saveAgentCredentials } from '../src/agent-credentials.js';
8
21
 
@@ -17,12 +30,16 @@ describe('agent credentials', () => {
17
30
  beforeEach(() => {
18
31
  entries.clear();
19
32
  vi.resetAllMocks();
33
+ configDir.path = fs.mkdtempSync(path.join(os.tmpdir(), 'mint-agent-'));
20
34
  keytar.getPassword.mockImplementation(
21
35
  async (service, account) => entries.get(`${service}/${account}`) ?? null
22
36
  );
23
37
  keytar.setPassword.mockImplementation(async (service, account, value) => {
24
38
  entries.set(`${service}/${account}`, value);
25
39
  });
40
+ keytar.deletePassword.mockImplementation(async (service, account) =>
41
+ entries.delete(`${service}/${account}`)
42
+ );
26
43
  });
27
44
 
28
45
  it('isolates projects and environments from each other and human credentials', async () => {
@@ -45,12 +62,82 @@ describe('agent credentials', () => {
45
62
  expect(entries.has('mintlify-agent/https://api.example.com:reg_1')).toBe(true);
46
63
  });
47
64
 
48
- it('does not switch the project pointer when storing the credential fails', async () => {
65
+ it('falls back to a local file when the keyring cannot be written or read', async () => {
66
+ keytar.setPassword.mockRejectedValue(new Error('locked'));
67
+ await saveAgentCredentials('https://api.example.com', '/docs', credentials);
68
+ expect(entries.size).toBe(0);
69
+ const file = path.join(configDir.path, 'agent-credentials.json');
70
+ if (process.platform !== 'win32') expect(fs.statSync(file).mode & 0o777).toBe(0o600);
71
+ keytar.getPassword.mockRejectedValue(new Error('locked'));
72
+ expect(await loadAgentCredentials('https://api.example.com', '/docs')).toEqual(credentials);
73
+ });
74
+
75
+ it('removes stale fallback entries once the keyring is writable again', async () => {
49
76
  keytar.setPassword.mockRejectedValueOnce(new Error('locked'));
77
+ await saveAgentCredentials('https://api.example.com', '/docs', credentials);
78
+ await saveAgentCredentials('https://api.example.com', '/docs', {
79
+ ...credentials,
80
+ assertion: 'next',
81
+ });
82
+ expect((await loadAgentCredentials('https://api.example.com', '/docs'))?.assertion).toBe(
83
+ 'next'
84
+ );
85
+ const file = path.join(configDir.path, 'agent-credentials.json');
86
+ expect(JSON.parse(fs.readFileSync(file, 'utf-8'))).toEqual({});
87
+ });
88
+
89
+ it('reads the keyring before the fallback file', async () => {
90
+ await saveAgentCredentials('https://api.example.com', '/docs', credentials);
91
+ fs.writeFileSync(
92
+ path.join(configDir.path, 'agent-credentials.json'),
93
+ JSON.stringify({
94
+ 'https://api.example.com:reg_1': JSON.stringify({ ...credentials, assertion: 'stale' }),
95
+ })
96
+ );
97
+ expect(await loadAgentCredentials('https://api.example.com', '/docs')).toEqual(credentials);
98
+ });
99
+
100
+ it('ignores a corrupt fallback file when the keyring has the credentials', async () => {
101
+ await saveAgentCredentials('https://api.example.com', '/docs', credentials);
102
+ fs.writeFileSync(path.join(configDir.path, 'agent-credentials.json'), '{');
103
+ expect(await loadAgentCredentials('https://api.example.com', '/docs')).toEqual(credentials);
104
+ });
105
+
106
+ it('drops the stale keyring entry when a write falls back to the file', async () => {
107
+ await saveAgentCredentials('https://api.example.com', '/docs', credentials);
108
+ keytar.setPassword.mockRejectedValueOnce(new Error('locked'));
109
+ await saveAgentCredentials('https://api.example.com', '/docs', {
110
+ ...credentials,
111
+ assertion: 'next',
112
+ });
113
+ expect((await loadAgentCredentials('https://api.example.com', '/docs'))?.assertion).toBe(
114
+ 'next'
115
+ );
116
+ });
117
+
118
+ it('succeeds when the keyring write works but fallback cleanup fails', async () => {
119
+ keytar.setPassword.mockRejectedValueOnce(new Error('locked'));
120
+ await saveAgentCredentials('https://api.example.com', '/docs', credentials);
121
+ fs.chmodSync(configDir.path, 0o500);
122
+ await expect(
123
+ saveAgentCredentials('https://api.example.com', '/docs', {
124
+ ...credentials,
125
+ assertion: 'next',
126
+ })
127
+ ).resolves.toBeUndefined();
128
+ fs.chmodSync(configDir.path, 0o700);
129
+ expect((await loadAgentCredentials('https://api.example.com', '/docs'))?.assertion).toBe(
130
+ 'next'
131
+ );
132
+ });
133
+
134
+ it('fails when neither the keyring nor the local file can be written', async () => {
135
+ keytar.setPassword.mockRejectedValue(new Error('locked'));
136
+ configDir.path = path.join(configDir.path, 'not-a-directory');
137
+ fs.writeFileSync(configDir.path, '');
50
138
  await expect(
51
139
  saveAgentCredentials('https://api.example.com', '/docs', credentials)
52
- ).rejects.toThrow('locked');
53
- expect(keytar.setPassword).toHaveBeenCalledTimes(1);
140
+ ).rejects.toThrow();
54
141
  });
55
142
 
56
143
  it('preserves read failures instead of treating them as an absent registration', async () => {
@@ -82,12 +82,10 @@ describe('mint deploy', () => {
82
82
  const result = await prepareAgentDeployment({ ...options, apiUrl }, warn);
83
83
  expect(formatDeployment(result, apiUrl)).toContain(`Site: ${siteUrl}`);
84
84
  });
85
- it('continues provisioning after keyring save failure', async () => {
86
- vi.mocked(storage.saveAgentCredentials).mockRejectedValue(new Error('locked'));
87
- const result = await prepareAgentDeployment(options, warn);
88
- expect(result.status).toBe('ready');
89
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('Continuing for this run'));
90
- expect(formatDeployment(result, options.apiUrl)).toContain('https://allocated.mintlify.site');
85
+ it('fails before provisioning when credentials cannot be saved', async () => {
86
+ vi.mocked(storage.saveAgentCredentials).mockRejectedValue(new Error('disk full'));
87
+ await expect(prepareAgentDeployment(options, warn)).rejects.toThrow('disk full');
88
+ expect(deployment.provisionAgentSite).not.toHaveBeenCalled();
91
89
  });
92
90
  it('persists the exchanged access token for later commands', async () => {
93
91
  await prepareAgentDeployment(options, warn);
@@ -0,0 +1,141 @@
1
+ import { addLog, validateBuild } from '@mintlify/previewing';
2
+ import fs from 'fs-extra';
3
+ import path from 'node:path';
4
+ import { mockProcessExit } from 'vitest-mock-process';
5
+
6
+ import { runCommand } from './utils.js';
7
+
8
+ const { dir } = vi.hoisted(() => ({
9
+ dir: `${process.env.TMPDIR ?? '/tmp'}/validate-command-${process.pid}`,
10
+ }));
11
+
12
+ vi.mock('../src/helpers.js', async (importOriginal) => {
13
+ const mod = await importOriginal<typeof import('../src/helpers.js')>();
14
+ return { ...mod, CMD_EXEC_PATH: dir, autoUpgradeIfNeeded: vi.fn() };
15
+ });
16
+
17
+ vi.mock('@mintlify/previewing', async (importOriginal) => {
18
+ const mod = await importOriginal<typeof import('@mintlify/previewing')>();
19
+ return { ...mod, addLog: vi.fn(), validateBuild: vi.fn() };
20
+ });
21
+
22
+ const processExitMock = mockProcessExit();
23
+
24
+ const HOME = '---\ntitle: Home\n---\n\nBody\n';
25
+
26
+ const writeSite = (files: Record<string, string>, config: Record<string, unknown> = {}) => {
27
+ fs.emptyDirSync(dir);
28
+ fs.writeJSONSync(path.join(dir, 'docs.json'), {
29
+ theme: 'mint',
30
+ name: 'Test',
31
+ colors: { primary: '#000000' },
32
+ navigation: { pages: ['index'] },
33
+ ...config,
34
+ });
35
+ for (const [file, content] of Object.entries(files)) {
36
+ fs.outputFileSync(path.join(dir, file), content);
37
+ }
38
+ };
39
+
40
+ const logged = () => JSON.stringify(vi.mocked(addLog).mock.calls);
41
+
42
+ describe('mint validate', () => {
43
+ beforeEach(() => {
44
+ vi.clearAllMocks();
45
+ vi.mocked(validateBuild).mockResolvedValue(undefined as never);
46
+ });
47
+
48
+ afterAll(() => {
49
+ fs.removeSync(dir);
50
+ });
51
+
52
+ it('logs lint findings by file and exits 1 before the build', async () => {
53
+ writeSite({
54
+ 'index.mdx': '---\ntitle: Home\n---\n\nline<br>\n',
55
+ 'other.mdx': '---\ntitle: Other\n---\n\nText {broken\n',
56
+ });
57
+
58
+ await runCommand('validate');
59
+
60
+ const text = logged();
61
+ expect(text).toContain('index.mdx');
62
+ expect(text).toContain('must be self-closed');
63
+ expect(text).toContain('other.mdx');
64
+ expect(text).toContain('other checks were skipped for this file');
65
+ expect(text).toContain('2 errors');
66
+ expect(validateBuild).not.toHaveBeenCalled();
67
+ expect(processExitMock).toHaveBeenCalledWith(1);
68
+ });
69
+
70
+ it('continues into the build when the lint pass is clean', async () => {
71
+ writeSite({ 'index.mdx': HOME });
72
+
73
+ await runCommand('validate');
74
+
75
+ expect(validateBuild).toHaveBeenCalledWith(
76
+ expect.objectContaining({ packageName: 'mint' }),
77
+ expect.any(Function)
78
+ );
79
+ expect(processExitMock).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it('keeps --disable-openapi working', async () => {
83
+ writeSite({ 'index.mdx': HOME }, { api: { openapi: 'missing.json' } });
84
+
85
+ await runCommand('validate', '--disable-openapi');
86
+
87
+ expect(validateBuild).toHaveBeenCalled();
88
+ });
89
+
90
+ it('turns rules and groups on and off', async () => {
91
+ writeSite({ 'index.mdx': '---\ntitle: Home\n---\n\nline<br>\n' });
92
+
93
+ await runCommand('validate', '--disable', 'content');
94
+ expect(logged()).toContain('parse-error');
95
+ expect(logged()).not.toContain('void-tags');
96
+
97
+ vi.clearAllMocks();
98
+ await runCommand('validate', '--disable', 'all', '--enable', 'void-tags');
99
+ expect(logged()).toContain('void-tags');
100
+ expect(processExitMock).toHaveBeenCalledWith(1);
101
+ });
102
+
103
+ it('rejects unknown rules and disabling parse-error', async () => {
104
+ writeSite({ 'index.mdx': HOME });
105
+
106
+ await runCommand('validate', '--disable', 'nope');
107
+ expect(logged()).toContain('unknown rule or group');
108
+ expect(processExitMock).toHaveBeenLastCalledWith(1);
109
+
110
+ await runCommand('validate', '--disable', 'parse-error');
111
+ expect(logged()).toContain("parse-error always runs and can't be disabled");
112
+ expect(processExitMock).toHaveBeenLastCalledWith(1);
113
+ expect(validateBuild).not.toHaveBeenCalled();
114
+ });
115
+
116
+ it('limits page findings to --files', async () => {
117
+ writeSite(
118
+ {
119
+ 'index.mdx': '---\ntitle: Home\n---\n\nline<br>\n',
120
+ 'other.mdx': '---\ntitle: Other\n---\n\nline<br>\n\nmore<hr>\n',
121
+ },
122
+ { navigation: { pages: ['index', 'other'] } }
123
+ );
124
+
125
+ await runCommand('validate', '--files', 'other.mdx');
126
+
127
+ expect(logged()).toContain('other.mdx');
128
+ expect(logged()).not.toContain('index.mdx');
129
+ expect(processExitMock).toHaveBeenCalledWith(1);
130
+ });
131
+
132
+ it('rejects --files that match nothing', async () => {
133
+ writeSite({ 'index.mdx': HOME });
134
+
135
+ await runCommand('validate', '--files', 'missing.mdx');
136
+
137
+ expect(logged()).toContain('No matching files');
138
+ expect(processExitMock).toHaveBeenCalledWith(1);
139
+ expect(validateBuild).not.toHaveBeenCalled();
140
+ });
141
+ });
@@ -0,0 +1,46 @@
1
+ import { selectRules, UsageError } from '../src/validate/engine.js';
2
+ import { RULES } from '../src/validate/rules/index.js';
3
+ import { GROUPS } from '../src/validate/types.js';
4
+
5
+ const ids = (rules: { id: string }[]) => rules.map(({ id }) => id);
6
+
7
+ describe('selectRules', () => {
8
+ it('starts from every rule; --disable all empties the selection', () => {
9
+ expect(ids(selectRules({}))).toEqual(ids(RULES));
10
+ expect(ids(selectRules({ disable: ['all'] }))).toEqual([]);
11
+ });
12
+
13
+ it('turns on the rules of an enabled group', () => {
14
+ const content = ids(selectRules({ disable: ['all'], enable: ['content'] }));
15
+
16
+ expect(content).toEqual(['void-tags']);
17
+ });
18
+
19
+ it('applies --enable after --disable', () => {
20
+ expect(ids(selectRules({ disable: ['content'], enable: ['void-tags'] }))).toContain(
21
+ 'void-tags'
22
+ );
23
+ expect(ids(selectRules({ disable: ['content'] }))).not.toContain('void-tags');
24
+ });
25
+
26
+ it('rejects unknown names and naming parse-error', () => {
27
+ expect(() => selectRules({ enable: ['nope'] })).toThrow(UsageError);
28
+ expect(() => selectRules({ disable: ['parse-error'] })).toThrow(
29
+ "parse-error always runs and can't be disabled"
30
+ );
31
+ expect(() => selectRules({ enable: ['parse-error'] })).toThrow(
32
+ "parse-error always runs and can't be disabled"
33
+ );
34
+ });
35
+
36
+ it('gives every rule a known group, a severity and a description', () => {
37
+ for (const rule of RULES) {
38
+ expect(GROUPS).toContain(rule.group);
39
+ expect(['error', 'warning']).toContain(rule.severity);
40
+ expect(rule.description).not.toBe('');
41
+ expect(rule.id).not.toBe('parse-error');
42
+ expect(GROUPS as string[]).not.toContain(rule.id);
43
+ }
44
+ expect(new Set(ids(RULES)).size).toBe(RULES.length);
45
+ });
46
+ });