@mintlify/cli 4.0.1106 → 4.0.1108

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.
@@ -1,3 +1,6 @@
1
+ import { input, select } from '@inquirer/prompts';
2
+
3
+ import { isAI } from '../src/helpers.js';
1
4
  import { init } from '../src/init.js';
2
5
 
3
6
  vi.mock('@inquirer/prompts', () => ({
@@ -5,6 +8,10 @@ vi.mock('@inquirer/prompts', () => ({
5
8
  input: vi.fn(),
6
9
  }));
7
10
 
11
+ vi.mock('../src/helpers.js', () => ({
12
+ isAI: vi.fn().mockReturnValue(false),
13
+ }));
14
+
8
15
  vi.mock('@mintlify/previewing', () => ({
9
16
  addLogs: vi.fn(),
10
17
  addLog: vi.fn(),
@@ -32,6 +39,7 @@ vi.mock('fs-extra', () => ({
32
39
  remove: vi.fn().mockResolvedValue(undefined),
33
40
  readJson: vi.fn().mockResolvedValue({ theme: 'quill', name: 'Test' }),
34
41
  writeJson: vi.fn().mockResolvedValue(undefined),
42
+ pathExists: vi.fn().mockResolvedValue(true),
35
43
  },
36
44
  }));
37
45
 
@@ -42,12 +50,19 @@ vi.mock('adm-zip', () => ({
42
50
  }));
43
51
 
44
52
  global.fetch = vi.fn().mockResolvedValue({
53
+ ok: true,
45
54
  arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
55
+ json: () => Promise.resolve([]),
46
56
  });
47
57
 
48
58
  describe('init', () => {
49
59
  beforeEach(() => {
50
60
  vi.clearAllMocks();
61
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
62
+ ok: true,
63
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
64
+ json: () => Promise.resolve([]),
65
+ });
51
66
  });
52
67
 
53
68
  describe('path traversal prevention', () => {
@@ -70,18 +85,35 @@ describe('init', () => {
70
85
  });
71
86
 
72
87
  it('allows current directory (.)', async () => {
73
- // Should not throw for current directory
74
88
  await expect(init('.', false, 'quill', 'Test')).resolves.not.toThrow();
75
89
  });
76
90
 
77
91
  it('allows subdirectory paths', async () => {
78
- // Should not throw for subdirectory
79
92
  await expect(init('docs', false, 'quill', 'Test')).resolves.not.toThrow();
80
93
  });
81
94
 
82
95
  it('allows nested subdirectory paths', async () => {
83
- // Should not throw for nested subdirectory
84
96
  await expect(init('docs/api', false, 'quill', 'Test')).resolves.not.toThrow();
85
97
  });
86
98
  });
99
+
100
+ describe('AI agent guard for template without name', () => {
101
+ it('does not call interactive prompts when AI uses --template without --name', async () => {
102
+ vi.mocked(isAI).mockReturnValue(true);
103
+
104
+ await init('.', false, undefined, undefined, 'some-template');
105
+
106
+ expect(input).not.toHaveBeenCalled();
107
+ expect(select).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it('does not call interactive prompts when AI omits both --template and --theme', async () => {
111
+ vi.mocked(isAI).mockReturnValue(true);
112
+
113
+ await init('.', false, undefined, 'MyProject');
114
+
115
+ expect(input).not.toHaveBeenCalled();
116
+ expect(select).not.toHaveBeenCalled();
117
+ });
118
+ });
87
119
  });
package/bin/cli.js CHANGED
@@ -381,14 +381,18 @@ export const cli = ({ packageName = 'mint' }) => {
381
381
  .option('name', {
382
382
  type: 'string',
383
383
  description: 'Name of the documentation project',
384
+ })
385
+ .option('template', {
386
+ type: 'string',
387
+ description: 'Use a template as a starting point',
384
388
  })
385
389
  .option('force', {
386
390
  type: 'boolean',
387
391
  default: false,
388
392
  description: 'Create the documentation in a subdirectory',
389
- }), (_a) => __awaiter(void 0, [_a], void 0, function* ({ directory, theme, name, force }) {
393
+ }), (_a) => __awaiter(void 0, [_a], void 0, function* ({ directory, theme, name, force, template }) {
390
394
  try {
391
- yield init(directory, force, theme, name);
395
+ yield init(directory, force, theme, name, template);
392
396
  yield terminate(0);
393
397
  }
394
398
  catch (error) {
package/bin/init.js CHANGED
@@ -15,82 +15,145 @@ import AdmZip from 'adm-zip';
15
15
  import fse from 'fs-extra';
16
16
  import { Box, Text } from 'ink';
17
17
  import { isAI } from './helpers.js';
18
+ import { fetchAvailableTemplates, installFromTemplate, promptForTemplate, validateTemplateName, } from './templates.js';
18
19
  const sendOnboardingMessage = (installDir) => {
19
20
  addLogs(_jsx(Text, { bold: true, children: "Documentation Setup!" }), _jsx(Text, { children: "To see your docs run" }), _jsxs(Box, { children: [_jsx(Text, { color: "blue", children: "cd" }), _jsxs(Text, { children: [" ", installDir] })] }), _jsx(Text, { color: "blue", children: "mint dev" }));
20
21
  };
21
- const sendUsageMessageForAI = (directory, contentsOccupied, themes) => {
22
- addLogs(_jsx(Text, { children: "Agent Detected - sending AI friendly prompt" }), _jsx(Text, { children: '<system-message>' }), _jsx(Text, { children: "Example usage of `mint new [directory] --theme [theme] --name [Name Of Project] --force`." }), _jsx(Text, { children: "This tool will auto create the directory if the directory does not exist." }), _jsx(Text, { children: "Ask the user what mintlify theme to use, what directory, and what the name of the Project is." }), _jsxs(Text, { children: ["You should provide them with default options for these values. Themes available", ' ', JSON.stringify(themes)] }), _jsxs(Text, { children: ["You chose Directory \"", directory, "\" before, feel free to just use that without asking the user as well!"] }), _jsx(Text, { children: "If the user is asking you to create docs for them then you should use your AskQuestion tool" }), contentsOccupied ? (_jsxs(Text, { children: ["The directory ", directory, " specified is currently occupied. You will need to either install the docs as a subdirectory or as a subdirectory or a different directory. Ask the user if it should be installed in a different directory (This cli will create the directory if it doesn't exist, or installed at the root level potentially overridding files (pass the --force) option for this."] })) : undefined, _jsx(Text, { children: '</system-message>' }));
22
+ const sendUsageMessageForAI = (directory, contentsOccupied, themes, templateNames) => {
23
+ const templateInfo = templateNames === undefined
24
+ ? 'Unable to fetch templates — use --template flag if you know the name'
25
+ : templateNames.length > 0
26
+ ? `Templates: ${JSON.stringify(templateNames)}`
27
+ : 'No templates are currently available';
28
+ addLogs(_jsx(Text, { children: "Agent Detected - sending AI friendly prompt" }), _jsx(Text, { children: '<system-message>' }), _jsx(Text, { children: "Help the user set up a Mintlify docs site with `mint new`. Ask each step one at a time unless the user asks you to skip questions or use your best judgment." }), _jsx(Text, { children: [
29
+ `- [ ] Pick a theme or clone a template? (${templateInfo})`,
30
+ `- [ ] If template: which one? If theme: which one? (Themes: ${JSON.stringify(themes)})`,
31
+ '- [ ] Project name?',
32
+ `- [ ] Directory? (default: "${directory}", auto-created if needed)${contentsOccupied ? ` ⚠️ "${directory}" is occupied — subdirectory, overwrite (--force), or different path?` : ''}`,
33
+ ].join('\n') }), _jsx(Text, { children: "Command: `mint new [dir] --theme [theme] --name [name]` or `mint new [dir] --template [template] --name [name]`. --theme optionally overrides a template default. --force overwrites non-empty dirs. Use AskQuestion to present choices." }), _jsx(Text, { children: '</system-message>' }));
23
34
  };
24
- export function init(installDir, force, theme, name) {
35
+ function sendAIUsageMessage(directory, contentsOccupied, themes) {
36
+ return __awaiter(this, void 0, void 0, function* () {
37
+ const templateNames = yield fetchAvailableTemplates().catch(() => undefined);
38
+ sendUsageMessageForAI(directory, contentsOccupied, themes, templateNames);
39
+ });
40
+ }
41
+ function resolveInstallDir(installDir, force, contentsOccupied) {
42
+ return __awaiter(this, void 0, void 0, function* () {
43
+ if (!contentsOccupied)
44
+ return installDir;
45
+ if (isAI()) {
46
+ if (force)
47
+ return installDir;
48
+ return undefined;
49
+ }
50
+ const choice = yield select({
51
+ message: `Directory ${installDir} is not empty. What would you like to do?`,
52
+ choices: [
53
+ { name: 'Create in a subdirectory', value: 'subdir' },
54
+ { name: 'Overwrite current directory (may lose contents)', value: 'overwrite' },
55
+ { name: 'Cancel', value: 'cancel' },
56
+ ],
57
+ });
58
+ if (choice === 'cancel')
59
+ return undefined;
60
+ if (choice === 'subdir') {
61
+ const subdir = yield input({
62
+ message: 'Subdirectory name:',
63
+ default: 'docs',
64
+ });
65
+ if (!subdir || subdir.trim() === '') {
66
+ throw new Error('Subdirectory name cannot be empty');
67
+ }
68
+ const resolved = installDir === '.' ? subdir : `${installDir}/${subdir}`;
69
+ validatePathWithinCwd(resolved, process.cwd());
70
+ return resolved;
71
+ }
72
+ return installDir;
73
+ });
74
+ }
75
+ function promptForProjectName(installDir, currentName) {
76
+ return __awaiter(this, void 0, void 0, function* () {
77
+ if (currentName)
78
+ return currentName;
79
+ const defaultProject = installDir === '.' ? 'Mintlify' : installDir;
80
+ return input({ message: 'Project Name', default: defaultProject });
81
+ });
82
+ }
83
+ function promptForApproach() {
84
+ return __awaiter(this, void 0, void 0, function* () {
85
+ const approach = yield select({
86
+ message: 'How would you like to set up your docs?',
87
+ choices: [
88
+ { name: 'Pick a theme', value: 'theme' },
89
+ { name: 'Clone a template', value: 'template' },
90
+ ],
91
+ });
92
+ if (approach === 'template')
93
+ return promptForTemplate();
94
+ return undefined;
95
+ });
96
+ }
97
+ export function init(installDir, force, theme, name, template) {
25
98
  return __awaiter(this, void 0, void 0, function* () {
26
- // Validate path is within current working directory to prevent path traversal
27
99
  validatePathWithinCwd(installDir);
28
- let selectedTheme = theme;
29
- let projectName = name;
30
100
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
101
  const themes = docsConfigSchema.options.map((option) => {
32
102
  return option.shape.theme._def.value;
33
103
  });
34
104
  const dirContents = yield fse.readdir(installDir).catch(() => []);
35
105
  const contentsOccupied = dirContents.length > 0;
36
- if ((!theme || !name) && isAI()) {
37
- sendUsageMessageForAI(installDir, contentsOccupied, themes);
106
+ if (isAI() && (!name || (!template && !theme))) {
107
+ yield sendAIUsageMessage(installDir, contentsOccupied, themes);
38
108
  return;
39
109
  }
40
- if (contentsOccupied && isAI() && !force) {
41
- sendUsageMessageForAI(installDir, contentsOccupied, themes);
110
+ if (isAI() && contentsOccupied && !force) {
111
+ yield sendAIUsageMessage(installDir, contentsOccupied, themes);
42
112
  return;
43
113
  }
44
- if (contentsOccupied && !isAI()) {
45
- const choice = yield select({
46
- message: `Directory ${installDir} is not empty. What would you like to do?`,
47
- choices: [
48
- { name: 'Create in a subdirectory', value: 'subdir' },
49
- { name: 'Overwrite current directory (may lose contents)', value: 'overwrite' },
50
- { name: 'Cancel', value: 'cancel' },
51
- ],
52
- });
53
- if (choice === 'cancel') {
114
+ const selectedTemplate = template
115
+ ? yield validateTemplateName(template).then(() => template)
116
+ : !isAI() && !theme
117
+ ? yield promptForApproach()
118
+ : undefined;
119
+ if (selectedTemplate) {
120
+ const resolved = yield resolveInstallDir(installDir, force, contentsOccupied);
121
+ if (resolved === undefined) {
122
+ if (isAI())
123
+ yield sendAIUsageMessage(installDir, contentsOccupied, themes);
54
124
  return;
55
125
  }
56
- if (choice === 'subdir') {
57
- const subdir = yield input({
58
- message: 'Subdirectory name:',
59
- default: 'docs',
60
- });
61
- if (!subdir || subdir.trim() === '') {
62
- throw new Error('Subdirectory name cannot be empty');
63
- }
64
- installDir = installDir === '.' ? subdir : `${installDir}/${subdir}`;
65
- // Re-validate after subdirectory is appended
66
- validatePathWithinCwd(installDir, process.cwd());
67
- }
126
+ const projectName = yield promptForProjectName(resolved, name);
127
+ yield fse.ensureDir(resolved);
128
+ yield installFromTemplate(resolved, selectedTemplate, projectName, theme);
129
+ sendOnboardingMessage(resolved);
130
+ return;
131
+ }
132
+ // Standard theme-based path
133
+ const resolved = yield resolveInstallDir(installDir, force, contentsOccupied);
134
+ if (resolved === undefined) {
135
+ if (isAI())
136
+ yield sendAIUsageMessage(installDir, contentsOccupied, themes);
137
+ return;
68
138
  }
139
+ let projectName = name;
140
+ let selectedTheme = theme;
69
141
  if (!isAI() && (!selectedTheme || !projectName)) {
70
- const defaultProject = projectName !== undefined ? projectName : installDir === '.' ? 'Mintlify' : installDir;
71
- if (!projectName) {
72
- projectName = yield input({
73
- message: 'Project Name',
74
- default: defaultProject,
75
- });
76
- }
142
+ projectName = yield promptForProjectName(resolved, projectName);
77
143
  if (!selectedTheme) {
78
144
  selectedTheme = yield select({
79
145
  message: 'Theme',
80
- choices: themes.map((t) => ({
81
- name: t,
82
- value: t,
83
- })),
146
+ choices: themes.map((t) => ({ name: t, value: t })),
84
147
  });
85
148
  }
86
149
  }
87
150
  if (projectName === undefined || selectedTheme === undefined) {
88
- sendUsageMessageForAI(installDir, contentsOccupied, themes);
151
+ yield sendAIUsageMessage(resolved, contentsOccupied, themes);
89
152
  return;
90
153
  }
91
- yield fse.ensureDir(installDir);
92
- yield install(installDir, projectName, selectedTheme);
93
- sendOnboardingMessage(installDir);
154
+ yield fse.ensureDir(resolved);
155
+ yield install(resolved, projectName, selectedTheme);
156
+ sendOnboardingMessage(resolved);
94
157
  });
95
158
  }
96
159
  const install = (installDir, projectName, theme) => __awaiter(void 0, void 0, void 0, function* () {
@@ -0,0 +1,127 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { jsx as _jsx } from "react/jsx-runtime";
11
+ import { select } from '@inquirer/prompts';
12
+ import { addLog, SpinnerLog, removeLastLog } from '@mintlify/previewing';
13
+ import AdmZip from 'adm-zip';
14
+ import fse from 'fs-extra';
15
+ import path from 'path';
16
+ const TEMPLATES_REPO_OWNER = 'mintlify';
17
+ const TEMPLATES_REPO_NAME = 'templates';
18
+ const TEMPLATES_REPO_BRANCH = 'main';
19
+ export function fetchAvailableTemplates() {
20
+ return __awaiter(this, void 0, void 0, function* () {
21
+ const url = `https://api.github.com/repos/${TEMPLATES_REPO_OWNER}/${TEMPLATES_REPO_NAME}/contents/?ref=${TEMPLATES_REPO_BRANCH}`;
22
+ const response = yield fetch(url, {
23
+ headers: { Accept: 'application/vnd.github.v3+json' },
24
+ });
25
+ if (!response.ok) {
26
+ throw new Error(`Failed to fetch templates: ${response.status} ${response.statusText}`);
27
+ }
28
+ const entries = (yield response.json());
29
+ return entries.filter((entry) => entry.type === 'dir').map((entry) => entry.name);
30
+ });
31
+ }
32
+ export function validateTemplateName(templateName) {
33
+ return __awaiter(this, void 0, void 0, function* () {
34
+ if (!templateName ||
35
+ templateName === '.' ||
36
+ templateName === '..' ||
37
+ templateName.includes('/')) {
38
+ throw new Error(`Invalid template name: "${templateName}".`);
39
+ }
40
+ const url = `https://api.github.com/repos/${TEMPLATES_REPO_OWNER}/${TEMPLATES_REPO_NAME}/contents/${encodeURIComponent(templateName)}?ref=${TEMPLATES_REPO_BRANCH}`;
41
+ const response = yield fetch(url, {
42
+ headers: { Accept: 'application/vnd.github.v3+json' },
43
+ });
44
+ if (!response.ok) {
45
+ const available = yield fetchAvailableTemplates().catch(() => []);
46
+ const suggestion = available.length > 0 ? ` Available templates: ${available.join(', ')}` : '';
47
+ throw new Error(`Template "${templateName}" not found in ${TEMPLATES_REPO_OWNER}/${TEMPLATES_REPO_NAME}.${suggestion}`);
48
+ }
49
+ const entries = (yield response.json());
50
+ const hasDocsJson = entries.some((entry) => entry.name === 'docs.json');
51
+ if (!hasDocsJson) {
52
+ throw new Error(`Template "${templateName}" is not a valid Mintlify template (missing docs.json).`);
53
+ }
54
+ return templateName;
55
+ });
56
+ }
57
+ export function promptForTemplate() {
58
+ return __awaiter(this, void 0, void 0, function* () {
59
+ addLog(_jsx(SpinnerLog, { message: "fetching available templates..." }));
60
+ let templateNames;
61
+ try {
62
+ templateNames = yield fetchAvailableTemplates();
63
+ }
64
+ catch (_a) {
65
+ removeLastLog();
66
+ throw new Error('Failed to fetch templates. Please check your network connection and try again.');
67
+ }
68
+ removeLastLog();
69
+ if (templateNames.length === 0) {
70
+ throw new Error('No templates are currently available.');
71
+ }
72
+ return select({
73
+ message: 'Choose a template',
74
+ choices: templateNames.map((t) => ({ name: t, value: t })),
75
+ });
76
+ });
77
+ }
78
+ export function installFromTemplate(installDir, templateName, projectName, theme) {
79
+ return __awaiter(this, void 0, void 0, function* () {
80
+ const zipPath = path.join(installDir, '__template__.zip');
81
+ const extractDir = path.join(installDir, '__template_extract__');
82
+ try {
83
+ addLog(_jsx(SpinnerLog, { message: `downloading template "${templateName}"...` }));
84
+ try {
85
+ const zipUrl = `https://github.com/${TEMPLATES_REPO_OWNER}/${TEMPLATES_REPO_NAME}/archive/refs/heads/${TEMPLATES_REPO_BRANCH}.zip`;
86
+ const response = yield fetch(zipUrl);
87
+ if (!response.ok) {
88
+ throw new Error(`Failed to download templates archive: ${response.status}`);
89
+ }
90
+ const buffer = yield response.arrayBuffer();
91
+ yield fse.writeFile(zipPath, Buffer.from(buffer));
92
+ }
93
+ finally {
94
+ removeLastLog();
95
+ }
96
+ addLog(_jsx(SpinnerLog, { message: "extracting template..." }));
97
+ try {
98
+ const zip = new AdmZip(zipPath);
99
+ zip.extractAllTo(extractDir, true);
100
+ }
101
+ finally {
102
+ removeLastLog();
103
+ }
104
+ const repoRoot = path.join(extractDir, `${TEMPLATES_REPO_NAME}-${TEMPLATES_REPO_BRANCH}`);
105
+ const templateDir = path.join(repoRoot, templateName);
106
+ if (!(yield fse.pathExists(templateDir))) {
107
+ throw new Error(`Template directory "${templateName}" not found in the downloaded archive.`);
108
+ }
109
+ yield fse.copy(templateDir, installDir, { overwrite: true });
110
+ }
111
+ finally {
112
+ yield fse.remove(zipPath).catch(() => { });
113
+ yield fse.remove(extractDir).catch(() => { });
114
+ }
115
+ const docsJsonPath = path.join(installDir, 'docs.json');
116
+ if (yield fse.pathExists(docsJsonPath)) {
117
+ const docsConfig = yield fse.readJson(docsJsonPath);
118
+ if (projectName) {
119
+ docsConfig.name = projectName;
120
+ }
121
+ if (theme) {
122
+ docsConfig.theme = theme;
123
+ }
124
+ yield fse.writeJson(docsJsonPath, docsConfig, { spaces: 2 });
125
+ }
126
+ });
127
+ }