@mintlify/cli 4.0.1023 → 4.0.1025

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.
@@ -0,0 +1,318 @@
1
+ import * as previewing from '@mintlify/previewing';
2
+ import fse from 'fs-extra';
3
+ import path from 'path';
4
+
5
+ import { addWorkflow, slugify, buildFrontmatter, isValidCron } from '../src/workflow.js';
6
+
7
+ const FAKE_PROJECT = '/fake/project';
8
+ const WORKFLOWS_DIR = path.join(FAKE_PROJECT, '.mintlify', 'workflows');
9
+
10
+ vi.mock('@inquirer/prompts', () => ({
11
+ select: vi.fn(),
12
+ input: vi.fn(),
13
+ editor: vi.fn(),
14
+ }));
15
+
16
+ vi.mock('@mintlify/previewing', () => ({
17
+ addLog: vi.fn(),
18
+ addLogs: vi.fn(),
19
+ SuccessLog: vi.fn(),
20
+ }));
21
+
22
+ vi.mock('fs-extra', () => ({
23
+ default: {
24
+ pathExists: vi.fn(),
25
+ ensureDir: vi.fn().mockResolvedValue(undefined),
26
+ writeFile: vi.fn().mockResolvedValue(undefined),
27
+ },
28
+ }));
29
+
30
+ vi.mock('../src/constants.js', () => ({
31
+ CMD_EXEC_PATH: '/fake/project',
32
+ }));
33
+
34
+ const addLogSpy = vi.mocked(previewing.addLog);
35
+
36
+ describe('slugify', () => {
37
+ it('converts spaces to hyphens', () => {
38
+ expect(slugify('Update changelog')).toBe('update-changelog');
39
+ });
40
+
41
+ it('replaces special characters with hyphens', () => {
42
+ expect(slugify('My Workflow!@#$%')).toBe('my-workflow');
43
+ });
44
+
45
+ it('replaces slashes with hyphens like the dashboard', () => {
46
+ expect(slugify('a/b')).toBe('a-b');
47
+ });
48
+
49
+ it('collapses multiple hyphens', () => {
50
+ expect(slugify('a b---c')).toBe('a-b-c');
51
+ });
52
+
53
+ it('trims leading and trailing hyphens', () => {
54
+ expect(slugify(' --hello-- ')).toBe('hello');
55
+ });
56
+
57
+ it('lowercases the result', () => {
58
+ expect(slugify('UPPER CASE')).toBe('upper-case');
59
+ });
60
+
61
+ it('handles single word', () => {
62
+ expect(slugify('deploy')).toBe('deploy');
63
+ });
64
+ });
65
+
66
+ describe('isValidCron', () => {
67
+ it('accepts standard 5-field expressions', () => {
68
+ expect(isValidCron('0 9 * * 1')).toBe(true);
69
+ expect(isValidCron('*/15 * * * *')).toBe(true);
70
+ expect(isValidCron('0 0 1 1 *')).toBe(true);
71
+ });
72
+
73
+ it('accepts ranges and lists', () => {
74
+ expect(isValidCron('0 9-17 * * 1-5')).toBe(true);
75
+ expect(isValidCron('0,30 * * * *')).toBe(true);
76
+ });
77
+
78
+ it('rejects invalid expressions', () => {
79
+ expect(isValidCron('0 xd f gasf')).toBe(false);
80
+ expect(isValidCron('not a cron')).toBe(false);
81
+ expect(isValidCron('')).toBe(false);
82
+ });
83
+
84
+ it('rejects wrong number of fields', () => {
85
+ expect(isValidCron('* * *')).toBe(false);
86
+ expect(isValidCron('* * * * * *')).toBe(false);
87
+ });
88
+ });
89
+
90
+ describe('buildFrontmatter', () => {
91
+ it('builds cron trigger frontmatter', () => {
92
+ const result = buildFrontmatter({
93
+ name: 'Update changelog',
94
+ triggerType: 'cron',
95
+ cronExpression: '0 9 * * 1',
96
+ automerge: false,
97
+ });
98
+ expect(result).toBe('---\nname: "Update changelog"\non:\n cron: "0 9 * * 1"\n---');
99
+ });
100
+
101
+ it('builds push trigger frontmatter with repos', () => {
102
+ const result = buildFrontmatter({
103
+ name: 'Deploy docs',
104
+ triggerType: 'push',
105
+ triggerRepos: ['org/docs', 'org/api'],
106
+ automerge: false,
107
+ });
108
+ expect(result).toBe(
109
+ '---\nname: "Deploy docs"\non:\n push:\n - repo: "org/docs"\n - repo: "org/api"\n---'
110
+ );
111
+ });
112
+
113
+ it('builds push trigger with no repos', () => {
114
+ const result = buildFrontmatter({
115
+ name: 'Deploy',
116
+ triggerType: 'push',
117
+ automerge: false,
118
+ });
119
+ expect(result).toBe('---\nname: "Deploy"\non:\n push:\n---');
120
+ });
121
+
122
+ it('includes context repos', () => {
123
+ const result = buildFrontmatter({
124
+ name: 'Test',
125
+ triggerType: 'cron',
126
+ cronExpression: '0 9 * * 1',
127
+ contextRepos: ['org/repo1', 'org/repo2'],
128
+ automerge: false,
129
+ });
130
+ expect(result).toContain('context:\n - repo: "org/repo1"\n - repo: "org/repo2"');
131
+ });
132
+
133
+ it('includes automerge only when true', () => {
134
+ const result = buildFrontmatter({
135
+ name: 'Test',
136
+ triggerType: 'cron',
137
+ cronExpression: '0 9 * * 1',
138
+ automerge: true,
139
+ });
140
+ expect(result).toContain('automerge: true');
141
+ });
142
+
143
+ it('omits automerge when false', () => {
144
+ const result = buildFrontmatter({
145
+ name: 'Test',
146
+ triggerType: 'cron',
147
+ cronExpression: '0 9 * * 1',
148
+ automerge: false,
149
+ });
150
+ expect(result).not.toContain('automerge');
151
+ });
152
+
153
+ it('escapes quotes in name', () => {
154
+ const result = buildFrontmatter({
155
+ name: 'My "Test" Workflow',
156
+ triggerType: 'cron',
157
+ cronExpression: '0 9 * * 1',
158
+ automerge: false,
159
+ });
160
+ expect(result).toContain('name: "My \\"Test\\" Workflow"');
161
+ });
162
+ });
163
+
164
+ describe('addWorkflow', () => {
165
+ beforeEach(() => {
166
+ vi.clearAllMocks();
167
+ });
168
+
169
+ it('throws when docs.json does not exist', async () => {
170
+ vi.mocked(fse.pathExists).mockResolvedValue(false as never);
171
+
172
+ await expect(addWorkflow()).rejects.toThrow(
173
+ 'docs.json not found in the current directory. Please run this command from your docs repository root.'
174
+ );
175
+ });
176
+
177
+ it('outputs AI usage message when not interactive', async () => {
178
+ vi.mocked(fse.pathExists).mockResolvedValue(true as never);
179
+ const originalIsTTY = process.stdin.isTTY;
180
+ process.stdin.isTTY = false;
181
+
182
+ await addWorkflow();
183
+
184
+ expect(previewing.addLogs).toHaveBeenCalled();
185
+
186
+ process.stdin.isTTY = originalIsTTY;
187
+ });
188
+
189
+ it('throws when workflow name has no alphanumeric characters', async () => {
190
+ vi.mocked(fse.pathExists).mockResolvedValue(true as never);
191
+ const originalIsTTY = process.stdin.isTTY;
192
+ const originalClaudeCode = process.env.CLAUDECODE;
193
+ process.stdin.isTTY = true;
194
+ delete process.env.CLAUDECODE;
195
+
196
+ const { input } = await import('@inquirer/prompts');
197
+ vi.mocked(input).mockResolvedValueOnce('!!!');
198
+
199
+ await expect(addWorkflow()).rejects.toThrow(
200
+ 'Workflow name must contain at least one alphanumeric character.'
201
+ );
202
+
203
+ process.stdin.isTTY = originalIsTTY;
204
+ if (originalClaudeCode === undefined) {
205
+ delete process.env.CLAUDECODE;
206
+ } else {
207
+ process.env.CLAUDECODE = originalClaudeCode;
208
+ }
209
+ });
210
+
211
+ it('throws when workflow file already exists', async () => {
212
+ // pathExists returns true for both docs.json and the workflow file
213
+ vi.mocked(fse.pathExists).mockResolvedValue(true as never);
214
+ const originalIsTTY = process.stdin.isTTY;
215
+ const originalClaudeCode = process.env.CLAUDECODE;
216
+ process.stdin.isTTY = true;
217
+ delete process.env.CLAUDECODE;
218
+
219
+ const { input, select, editor } = await import('@inquirer/prompts');
220
+ vi.mocked(input)
221
+ .mockResolvedValueOnce('My Workflow') // workflow name
222
+ .mockResolvedValueOnce('0 9 * * 1') // cron expression
223
+ .mockResolvedValueOnce(''); // context repos
224
+ vi.mocked(select)
225
+ .mockResolvedValueOnce('cron') // trigger type
226
+ .mockResolvedValueOnce('no'); // automerge
227
+ vi.mocked(editor).mockResolvedValueOnce('Do the thing');
228
+
229
+ const expectedRelative = path.join('.mintlify', 'workflows', 'my-workflow.md');
230
+ await expect(addWorkflow()).rejects.toThrow(
231
+ `A workflow already exists at ${expectedRelative}. Please choose a different name or delete the existing file.`
232
+ );
233
+ expect(fse.writeFile).not.toHaveBeenCalled();
234
+
235
+ process.stdin.isTTY = originalIsTTY;
236
+ if (originalClaudeCode === undefined) {
237
+ delete process.env.CLAUDECODE;
238
+ } else {
239
+ process.env.CLAUDECODE = originalClaudeCode;
240
+ }
241
+ });
242
+
243
+ it('creates cron workflow file with correct content', async () => {
244
+ // true for docs.json, false for workflow file existence check
245
+ vi.mocked(fse.pathExists)
246
+ .mockResolvedValueOnce(true as never)
247
+ .mockResolvedValueOnce(false as never);
248
+ const originalIsTTY = process.stdin.isTTY;
249
+ const originalClaudeCode = process.env.CLAUDECODE;
250
+ process.stdin.isTTY = true;
251
+ delete process.env.CLAUDECODE;
252
+
253
+ const { input, select, editor } = await import('@inquirer/prompts');
254
+ vi.mocked(input)
255
+ .mockResolvedValueOnce('My Test Workflow') // workflow name
256
+ .mockResolvedValueOnce('0 9 * * 1') // cron expression
257
+ .mockResolvedValueOnce(''); // context repos
258
+ vi.mocked(select)
259
+ .mockResolvedValueOnce('cron') // trigger type
260
+ .mockResolvedValueOnce('no'); // automerge
261
+ vi.mocked(editor).mockResolvedValueOnce('Do the thing');
262
+
263
+ await addWorkflow();
264
+
265
+ expect(fse.ensureDir).toHaveBeenCalledWith(WORKFLOWS_DIR);
266
+ expect(fse.writeFile).toHaveBeenCalledWith(
267
+ path.join(WORKFLOWS_DIR, 'my-test-workflow.md'),
268
+ '---\nname: "My Test Workflow"\non:\n cron: "0 9 * * 1"\n---\n\nDo the thing\n'
269
+ );
270
+ const expectedRelative = path.join('.mintlify', 'workflows', 'my-test-workflow.md');
271
+ expect(addLogSpy).toHaveBeenCalledWith(
272
+ expect.objectContaining({
273
+ props: { message: `Workflow created at ${expectedRelative}` },
274
+ })
275
+ );
276
+
277
+ process.stdin.isTTY = originalIsTTY;
278
+ if (originalClaudeCode === undefined) {
279
+ delete process.env.CLAUDECODE;
280
+ } else {
281
+ process.env.CLAUDECODE = originalClaudeCode;
282
+ }
283
+ });
284
+
285
+ it('creates push trigger workflow with automerge and context', async () => {
286
+ vi.mocked(fse.pathExists)
287
+ .mockResolvedValueOnce(true as never)
288
+ .mockResolvedValueOnce(false as never);
289
+ const originalIsTTY = process.stdin.isTTY;
290
+ const originalClaudeCode = process.env.CLAUDECODE;
291
+ process.stdin.isTTY = true;
292
+ delete process.env.CLAUDECODE;
293
+
294
+ const { input, select, editor } = await import('@inquirer/prompts');
295
+ vi.mocked(input)
296
+ .mockResolvedValueOnce('Deploy Docs') // workflow name
297
+ .mockResolvedValueOnce('org/docs') // trigger repos
298
+ .mockResolvedValueOnce('org/server'); // context repos
299
+ vi.mocked(select)
300
+ .mockResolvedValueOnce('push') // trigger type
301
+ .mockResolvedValueOnce('yes'); // automerge
302
+ vi.mocked(editor).mockResolvedValueOnce('Deploy the docs');
303
+
304
+ await addWorkflow();
305
+
306
+ expect(fse.writeFile).toHaveBeenCalledWith(
307
+ path.join(WORKFLOWS_DIR, 'deploy-docs.md'),
308
+ '---\nname: "Deploy Docs"\non:\n push:\n - repo: "org/docs"\ncontext:\n - repo: "org/server"\nautomerge: true\n---\n\nDeploy the docs\n'
309
+ );
310
+
311
+ process.stdin.isTTY = originalIsTTY;
312
+ if (originalClaudeCode === undefined) {
313
+ delete process.env.CLAUDECODE;
314
+ } else {
315
+ process.env.CLAUDECODE = originalClaudeCode;
316
+ }
317
+ });
318
+ });
package/bin/cli.js CHANGED
@@ -23,6 +23,7 @@ import { mdxLinter } from './mdxLinter.js';
23
23
  import { migrateMdx } from './migrateMdx.js';
24
24
  import { scrapeSite, scrapePage, scrapeOpenApi } from './scrape.js';
25
25
  import { update } from './update.js';
26
+ import { addWorkflow } from './workflow.js';
26
27
  export const cli = ({ packageName = 'mint' }) => {
27
28
  render(_jsx(Logs, {}));
28
29
  return (yargs(hideBin(process.argv))
@@ -329,6 +330,16 @@ export const cli = ({ packageName = 'mint' }) => {
329
330
  addLog(_jsx(ErrorLog, { message: error instanceof Error ? error.message : 'error occurred' }));
330
331
  yield terminate(1);
331
332
  }
333
+ }))
334
+ .command('workflow', 'Add a workflow to your documentation repository', () => undefined, () => __awaiter(void 0, void 0, void 0, function* () {
335
+ try {
336
+ yield addWorkflow();
337
+ yield terminate(0);
338
+ }
339
+ catch (error) {
340
+ addLog(_jsx(ErrorLog, { message: error instanceof Error ? error.message : 'error occurred' }));
341
+ yield terminate(1);
342
+ }
332
343
  }))
333
344
  .command('scrape', 'Scrape documentation from external sites', (yargs) => yargs
334
345
  .command(['$0 <url>', 'site <url>'], 'Scrape an entire documentation site', (yargs) => yargs