CLI: Support CI environment variable, remove negatives options from CLI usage

This commit is contained in:
Fuma Nama
2025-10-26 18:44:22 +08:00
parent 4500ce0e71
commit b171be1bda
11 changed files with 416 additions and 422 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'create-fumadocs-app': patch
---
Support `CI` environment variable, remove negatives options from CLI usage
+8 -8
View File
@@ -3,23 +3,22 @@
"version": "16.0.2",
"description": "Create a new documentation site with Fumadocs",
"keywords": [
"NextJs",
"next",
"react",
"Docs"
"Docs",
"Fumadocs"
],
"homepage": "https://fumadocs.dev",
"repository": "github:fuma-nama/fumadocs",
"license": "MIT",
"author": "Fuma Nama",
"type": "module",
"bin": "./dist/index.js",
"module": "./dist/create-app.js",
"types": "./dist/create-app.d.ts",
"bin": "./dist/bin.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/create-app.js",
"types": "./dist/create-app.d.ts"
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./plugins/*": {
"import": "./dist/plugins/*.js",
@@ -47,6 +46,7 @@
"ts-morph": "^27.0.2"
},
"devDependencies": {
"@commander-js/extra-typings": "^14.0.0",
"@types/cross-spawn": "^6.0.6",
"@types/node": "24.9.1",
"eslint-config-custom": "workspace:*",
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import {
cancel,
confirm,
group,
intro,
isCancel,
outro,
select,
spinner,
text,
} from '@clack/prompts';
import pc from 'picocolors';
import { getPackageManager, managers } from './auto-install';
import { create, type Template, type TemplatePlugin } from './index';
import { isCI, templates } from './constants';
import { Option, program } from '@commander-js/extra-typings';
const command = program
.argument('[name]', 'the project name')
.option('--src', '(Next.js only) enable `src/` directory')
.option('--install', 'install packages automatically')
.option('--no-git', 'disable auto Git repository initialization')
.addOption(
new Option(
'--linter <name>',
'(Next.js only) configure a linter/formatter',
).choices(['eslint', 'biome']),
)
.addOption(
new Option('--search <name>', 'configure a search solution').choices([
'orama',
'orama-cloud',
]),
)
.addOption(
new Option('--template <name>', 'choose a template').choices(
templates.map((item) => item.value),
),
)
.addOption(
new Option('--pm <name>', 'choose a package manager')
.choices(managers)
.default(getPackageManager()),
);
async function main(): Promise<void> {
command.parse(process.argv);
const defaultName = command.args[0];
const config = command.opts();
intro(pc.bgCyan(pc.bold('Create Fumadocs App')));
const options = await group(
{
name: async () => {
if (defaultName) return defaultName;
if (isCI) return 'untitled';
return text({
message: 'Project name',
placeholder: 'my-app',
defaultValue: 'my-app',
});
},
template: async () => {
if (config.template) return config.template;
if (isCI) return '+next+fuma-docs-mdx';
return select<Template>({
message: 'Choose a template',
initialValue: '+next+fuma-docs-mdx',
options: templates,
});
},
src: async ({ results }: { results: { template?: Template } }) => {
if (config.src !== undefined) return config.src;
if (isCI || !results.template?.startsWith('+next')) return false;
return confirm({
message: 'Use `/src` directory?',
initialValue: false,
});
},
lint: async ({ results }: { results: { template?: Template } }) => {
if (config.linter !== undefined) return config.linter;
if (isCI || !results.template?.startsWith('+next')) return 'disabled';
return select({
message: 'Configure linter?',
options: [
{
value: 'disabled',
label: 'Disabled',
},
{
value: 'eslint',
label: 'ESLint',
},
{
value: 'biome',
label: 'Biome',
},
],
});
},
search: async () => {
if (config.search !== undefined) return config.search;
if (isCI) return 'orama';
return select({
message: 'Choose a search solution?',
options: [
{
value: 'orama',
label: 'Default',
hint: 'local search powered by Orama, recommended',
},
{
value: 'orama-cloud',
label: 'Orama Cloud',
hint: '3rd party search solution, signup needed',
},
],
});
},
installDeps: async () => {
if (config.install !== undefined) return config.install;
if (isCI) return false;
return confirm({
message: `Do you want to install packages automatically? (detected as ${config.pm})`,
});
},
},
{
onCancel: () => {
cancel('Installation Stopped.');
process.exit(0);
},
},
);
const projectName = options.name.toLowerCase().replace(/\s/, '-');
if (!isCI) await checkDir(projectName);
const info = spinner();
info.start(`Generating Project`);
const plugins: TemplatePlugin[] = [];
if (options.src) {
const { nextUseSrc } = await import('./plugins/next-use-src');
plugins.push(nextUseSrc());
}
if (options.search === 'orama-cloud') {
const { oramaCloud } = await import('./plugins/orama-cloud');
plugins.push(oramaCloud());
}
if (options.lint === 'eslint') {
const { eslint } = await import('./plugins/eslint');
plugins.push(eslint());
}
if (options.lint === 'biome') {
const { biome } = await import('./plugins/biome');
plugins.push(biome());
}
await create({
packageManager: config.pm,
template: options.template,
outputDir: projectName,
installDeps: options.installDeps,
initializeGit: config.git,
plugins,
log: (message) => {
info.message(message);
},
});
info.stop('Project Generated');
outro(pc.bgGreen(pc.bold('Done')));
console.log(pc.bold('\nOpen the project'));
console.log(pc.cyan(`cd ${projectName}`));
console.log(pc.bold('\nRun Development Server'));
if (config.pm === 'npm' || config.pm === 'bun') {
console.log(pc.cyan(`${config.pm} run dev`));
} else {
console.log(pc.cyan(`${config.pm} dev`));
}
console.log(
pc.bold('\nYou can now open the project and start writing documents'),
);
process.exit(0);
}
async function checkDir(outputDir: string) {
const destDir = await fs.readdir(outputDir).catch(() => null);
if (!destDir || destDir.length === 0) return;
const del = await confirm({
message: `directory ${outputDir} already exists, do you want to delete its files?`,
});
if (isCancel(del)) {
cancel();
process.exit(1);
}
if (!del) return;
const info = spinner();
info.start(`Deleting files in ${outputDir}`);
await Promise.all(
destDir.map((item) => {
return fs.rm(path.join(outputDir, item), {
recursive: true,
force: true,
});
}),
);
info.stop(`Deleted files in ${outputDir}`);
}
main().catch((e: unknown) => {
console.error(e);
process.exit(1);
});
+9 -4
View File
@@ -4,6 +4,8 @@ import versionPkg from '../../create-app-versions/package.json';
export const sourceDir = fileURLToPath(new URL(`../`, import.meta.url).href);
export const isCI = Boolean(process.env.CI);
export interface TemplateInfo {
value:
| '+next+fuma-docs-mdx'
@@ -13,6 +15,9 @@ export interface TemplateInfo {
| 'tanstack-start';
label: string;
appDir: string;
/**
* path to root provider, relative to `appDir``
*/
rootProviderPath: string;
hint?: string;
/**
@@ -33,26 +38,26 @@ export const templates: TemplateInfo[] = [
value: 'waku',
label: 'Waku: Fumadocs MDX',
appDir: 'src',
rootProviderPath: 'src/components/provider.tsx',
rootProviderPath: 'components/provider.tsx',
},
{
value: 'react-router',
label: 'React Router: Fumadocs MDX (not RSC)',
appDir: 'app',
rootProviderPath: 'app/root.tsx',
rootProviderPath: 'root.tsx',
},
{
value: 'react-router-spa',
label: 'React Router SPA: Fumadocs MDX (not RSC)',
hint: 'SPA mode allows you to host the site statically, compatible with a CDN.',
appDir: 'app',
rootProviderPath: 'app/root.tsx',
rootProviderPath: 'root.tsx',
},
{
value: 'tanstack-start',
label: 'Tanstack Start: Fumadocs MDX (not RSC)',
appDir: 'src',
rootProviderPath: 'src/routes/__root.tsx',
rootProviderPath: 'routes/__root.tsx',
},
];
-169
View File
@@ -1,169 +0,0 @@
import path from 'node:path';
import fs from 'node:fs/promises';
import { copy, tryGitInit } from '@/utils';
import type { PackageManager } from './auto-install';
import { autoInstall } from './auto-install';
import {
depVersions,
sourceDir,
type TemplateInfo,
templates,
} from './constants';
export type Template = TemplateInfo['value'];
export interface Options {
outputDir: string;
template: Template;
/**
* the package manager to use
*
* @defaultValue 'npm'
*/
packageManager?: PackageManager;
installDeps?: boolean;
initializeGit?: boolean;
log?: (message: string) => void;
plugins?: TemplatePlugin[];
}
export interface TemplatePluginContext {
template: TemplateInfo;
log: (message: string) => void;
/**
* output directory
*/
dest: string;
/**
* output directory for app code (e.g. under `/src`)
*/
appDir: string;
}
export type PackageJsonType = {
name?: string;
version?: string;
private?: boolean;
scripts?: Record<string, string>;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
} & Record<string, unknown>;
type Awaitable<T> = T | Promise<T>;
export interface TemplatePlugin {
template?: (
this: Pick<TemplatePluginContext, 'dest'>,
info: TemplateInfo,
) => Awaitable<void | TemplateInfo>;
packageJson?: (
this: TemplatePluginContext,
packageJson: PackageJsonType,
) => Awaitable<void | PackageJsonType>;
afterWrite?: (this: TemplatePluginContext) => Awaitable<void>;
readme?: (
this: TemplatePluginContext,
content: string,
) => Awaitable<void | string>;
}
export async function create(createOptions: Options): Promise<void> {
const {
outputDir,
plugins = [],
packageManager = 'npm',
initializeGit = false,
installDeps = false,
log = console.log,
} = createOptions;
let template = templates.find(
(item) => item.value === createOptions.template,
)!;
for (const plugin of plugins) {
template =
(await plugin.template?.call({ dest: outputDir }, template)) ?? template;
}
const appDir = path.join(outputDir, template.appDir);
const projectName = path.basename(outputDir);
const pluginContext: TemplatePluginContext = {
template,
dest: outputDir,
log,
appDir,
};
await copy(path.join(sourceDir, 'template', template.value), outputDir, {
rename(file) {
file = file.replace('example.gitignore', '.gitignore');
return template.rename?.(file) ?? file;
},
});
const packageJsonPath = path.join(outputDir, 'package.json');
let packageJson = await initPackageJson(projectName, packageJsonPath);
for (const plugin of plugins) {
packageJson =
(await plugin.packageJson?.call(pluginContext, packageJson)) ??
packageJson;
}
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
const readmePath = path.join(outputDir, 'README.md');
let readme = `# ${projectName}\n\n${await fs.readFile(readmePath)}`;
for (const plugin of plugins) {
readme = (await plugin.readme?.call(pluginContext, readme)) ?? readme;
}
await fs.writeFile(readmePath, readme);
for (const plugin of plugins) {
await plugin.afterWrite?.call(pluginContext);
}
if (installDeps) {
try {
await autoInstall(packageManager, outputDir);
log('Installed dependencies');
} catch (err) {
log(`Failed to install dependencies: ${err}`);
}
}
if (initializeGit && (await tryGitInit(outputDir))) {
log('Initialized Git repository');
}
}
async function initPackageJson(
projectName: string,
packageJsonPath: string,
): Promise<PackageJsonType> {
function replaceWorkspaceDeps(deps: Record<string, string> = {}) {
for (const k in deps) {
if (deps[k].startsWith('workspace:') && k in depVersions) {
deps[k] = depVersions[k as keyof typeof depVersions];
}
}
return deps;
}
const packageJson: PackageJsonType = JSON.parse(
(await fs.readFile(packageJsonPath)).toString(),
);
return {
...packageJson,
name: projectName,
scripts: {
...packageJson.scripts,
postinstall: 'fumadocs-mdx',
},
dependencies: replaceWorkspaceDeps(packageJson.dependencies),
devDependencies: replaceWorkspaceDeps(packageJson.devDependencies),
};
}
+142 -237
View File
@@ -1,264 +1,169 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import fs from 'node:fs/promises';
import { copy, tryGitInit } from '@/utils';
import type { PackageManager } from './auto-install';
import { autoInstall } from './auto-install';
import {
cancel,
confirm,
group,
intro,
isCancel,
outro,
select,
spinner,
text,
} from '@clack/prompts';
import pc from 'picocolors';
import {
getPackageManager,
managers,
type PackageManager,
} from './auto-install';
import { create, type Template, TemplatePlugin } from './create-app';
import { templates } from './constants';
import { program } from 'commander';
depVersions,
sourceDir,
type TemplateInfo,
templates,
} from './constants';
program.argument('[name]', 'the project name');
program.option('--src', '(Next.js only) enable `src/` directory');
program.option('--no-src', '(Next.js only) disable `src/` directory');
export type Template = TemplateInfo['value'];
export interface Options {
outputDir: string;
template: Template;
program.option('--eslint', '(Next.js only) enable ESLint configuration');
program.option('--no-eslint', '(Next.js only) disable ESLint configuration');
/**
* the package manager to use
*
* @defaultValue 'npm'
*/
packageManager?: PackageManager;
program.option('--biome', '(Next.js only) enable Biome configuration');
program.option('--no-biome', '(Next.js only) disable Biome configuration');
program.option('--install', 'Enable installing packages automatically');
program.option('--no-install', 'Disable installing packages automatically');
program.option('--no-git', 'Disable auto Git repository initialization');
program.option(
'--template <name>',
`template to choose: ${templates.map((v) => v.value).join(', ')}`,
(value) => {
if (!templates.some((item) => item.value === value)) {
throw new Error(`Invalid template: ${value}.`);
}
return value;
},
);
program.option(
'--pm <name>',
`package manager to choose: ${managers.join(', ')}`,
(value) => {
if (!managers.includes(value as PackageManager)) {
throw new Error(`Invalid package manager: ${value}.`);
}
return value;
},
);
interface Options {
name?: string;
src?: boolean;
eslint?: boolean;
biome?: boolean;
install?: boolean;
template?: Template;
pm?: PackageManager;
git?: boolean;
installDeps?: boolean;
initializeGit?: boolean;
log?: (message: string) => void;
plugins?: TemplatePlugin[];
}
async function main(config: Options): Promise<void> {
intro(pc.bgCyan(pc.bold('Create Fumadocs App')));
const manager = config.pm ?? getPackageManager();
export interface TemplatePluginContext {
template: TemplateInfo;
log: (message: string) => void;
/**
* output directory
*/
dest: string;
type SrcOption = boolean;
type LintOption = 'biome' | 'eslint' | false;
const options = await group(
{
name: () => {
if (config.name) return Promise.resolve(config.name);
/**
* output directory for app code (e.g. under `/src`)
*/
appDir: string;
}
return text({
message: 'Project name',
placeholder: 'my-app',
defaultValue: 'my-app',
});
},
template: () => {
if (config.template) return Promise.resolve(config.template);
export type PackageJsonType = {
name?: string;
version?: string;
private?: boolean;
scripts?: Record<string, string>;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
} & Record<string, unknown>;
return select<Template>({
message: 'Choose a template',
initialValue: '+next+fuma-docs-mdx',
options: templates,
});
},
src: async (v): Promise<SrcOption | symbol> => {
if (!v.results.template?.startsWith('+next')) return false;
if (config.src !== undefined) return config.src;
type Awaitable<T> = T | Promise<T>;
return confirm({
message: 'Use `/src` directory?',
initialValue: false,
});
},
lint: async (v): Promise<LintOption | symbol> => {
if (!v.results.template?.startsWith('+next')) return false;
export interface TemplatePlugin {
template?: (
this: Pick<TemplatePluginContext, 'dest'>,
info: TemplateInfo,
) => Awaitable<void | TemplateInfo>;
packageJson?: (
this: TemplatePluginContext,
packageJson: PackageJsonType,
) => Awaitable<void | PackageJsonType>;
afterWrite?: (this: TemplatePluginContext) => Awaitable<void>;
readme?: (
this: TemplatePluginContext,
content: string,
) => Awaitable<void | string>;
}
if (config.eslint !== undefined) {
return config.eslint ? 'eslint' : false;
}
export async function create(createOptions: Options): Promise<void> {
const {
outputDir,
plugins = [],
packageManager = 'npm',
initializeGit = false,
installDeps = false,
log = console.log,
} = createOptions;
if (config.biome !== undefined) {
return config.biome ? 'biome' : false;
}
return select<LintOption>({
message: 'Configure linter?',
initialValue: false,
options: [
{
value: false,
label: 'Disabled',
},
{
value: 'eslint',
label: 'ESLint',
},
{
value: 'biome',
label: 'Biome',
},
],
});
},
search: () => {
return select({
message: 'Choose a search solution?',
options: [
{
value: 'orama' as const,
label: 'Default',
hint: 'local search powered by Orama, recommended',
},
{
value: 'orama-cloud' as const,
label: 'Orama Cloud',
hint: '3rd party search solution, signup needed',
},
],
});
},
installDeps: () => {
if (config.install !== undefined)
return Promise.resolve(config.install);
return confirm({
message: `Do you want to install packages automatically? (detected as ${manager})`,
});
},
},
{
onCancel: () => {
cancel('Installation Stopped.');
process.exit(0);
},
},
);
const projectName = options.name.toLowerCase().replace(/\s/, '-');
const destDir = await fs.readdir(projectName).catch(() => null);
if (destDir && destDir.length > 0) {
const del = await confirm({
message: `directory ${projectName} already exists, do you want to delete its files?`,
});
if (isCancel(del)) {
cancel();
return;
}
if (del) {
const info = spinner();
info.start(`Deleting files in ${projectName}`);
await Promise.all(
destDir.map((item) => {
return fs.rm(path.join(projectName, item), {
recursive: true,
force: true,
});
}),
);
info.stop(`Deleted files in ${projectName}`);
}
let template = templates.find(
(item) => item.value === createOptions.template,
)!;
for (const plugin of plugins) {
template =
(await plugin.template?.call({ dest: outputDir }, template)) ?? template;
}
const info = spinner();
info.start(`Generating Project`);
const plugins: TemplatePlugin[] = [];
const appDir = path.join(outputDir, template.appDir);
const projectName = path.basename(outputDir);
const pluginContext: TemplatePluginContext = {
template,
dest: outputDir,
log,
appDir,
};
if (options.src) {
const { nextUseSrc } = await import('./plugins/next-use-src');
plugins.push(nextUseSrc());
}
await copy(path.join(sourceDir, 'template', template.value), outputDir, {
rename(file) {
file = file.replace('example.gitignore', '.gitignore');
if (options.search === 'orama-cloud') {
const { oramaCloud } = await import('./plugins/orama-cloud');
plugins.push(oramaCloud());
}
if (options.lint === 'eslint') {
const { eslint } = await import('./plugins/eslint');
plugins.push(eslint());
}
if (options.lint === 'biome') {
const { biome } = await import('./plugins/biome');
plugins.push(biome());
}
await create({
packageManager: manager,
template: options.template,
outputDir: projectName,
installDeps: options.installDeps,
initializeGit: config.git ?? true,
plugins,
log: (message) => {
info.message(message);
return template.rename?.(file) ?? file;
},
});
info.stop('Project Generated');
const packageJsonPath = path.join(outputDir, 'package.json');
let packageJson = await initPackageJson(projectName, packageJsonPath);
for (const plugin of plugins) {
packageJson =
(await plugin.packageJson?.call(pluginContext, packageJson)) ??
packageJson;
}
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
outro(pc.bgGreen(pc.bold('Done')));
const readmePath = path.join(outputDir, 'README.md');
let readme = `# ${projectName}\n\n${await fs.readFile(readmePath)}`;
for (const plugin of plugins) {
readme = (await plugin.readme?.call(pluginContext, readme)) ?? readme;
}
await fs.writeFile(readmePath, readme);
console.log(pc.bold('\nOpen the project'));
console.log(pc.cyan(`cd ${projectName}`));
for (const plugin of plugins) {
await plugin.afterWrite?.call(pluginContext);
}
console.log(pc.bold('\nRun Development Server'));
console.log(pc.cyan('npm run dev | pnpm run dev | yarn dev'));
if (installDeps) {
try {
await autoInstall(packageManager, outputDir);
log('Installed dependencies');
} catch (err) {
log(`Failed to install dependencies: ${err}`);
}
}
console.log(
pc.bold('\nYou can now open the project and start writing documents'),
);
process.exit(0);
if (initializeGit && (await tryGitInit(outputDir))) {
log('Initialized Git repository');
}
}
program.parse();
async function initPackageJson(
projectName: string,
packageJsonPath: string,
): Promise<PackageJsonType> {
function replaceWorkspaceDeps(deps: Record<string, string> = {}) {
for (const k in deps) {
if (deps[k].startsWith('workspace:') && k in depVersions) {
deps[k] = depVersions[k as keyof typeof depVersions];
}
}
main({
name: program.args[0],
...program.opts(),
}).catch((e: unknown) => {
console.error(e);
process.exit(1);
});
return deps;
}
const packageJson: PackageJsonType = JSON.parse(
(await fs.readFile(packageJsonPath)).toString(),
);
return {
...packageJson,
name: projectName,
scripts: {
...packageJson.scripts,
postinstall: 'fumadocs-mdx',
},
dependencies: replaceWorkspaceDeps(packageJson.dependencies),
devDependencies: replaceWorkspaceDeps(packageJson.devDependencies),
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { TemplatePlugin } from '@/create-app';
import { TemplatePlugin } from '@/index';
import { copy, pick } from '@/utils';
import path from 'node:path';
import { depVersions, sourceDir } from '@/constants';
+1 -1
View File
@@ -1,4 +1,4 @@
import { TemplatePlugin } from '@/create-app';
import { TemplatePlugin } from '@/index';
import { copy } from '@/utils';
import path from 'node:path';
import { depVersions, sourceDir } from '@/constants';
@@ -1,4 +1,4 @@
import { TemplatePlugin } from '@/create-app';
import { TemplatePlugin } from '@/index';
import path from 'node:path';
import fs from 'node:fs/promises';
+1 -1
View File
@@ -22,7 +22,7 @@ writeFileSync(
console.log('Create-Fumadocs-App: versions.json updated');
export default defineConfig({
entry: ['./src/index.ts', './src/create-app.ts', './src/plugins/*'],
entry: ['./src/bin.ts', './src/index.ts', './src/plugins/*'],
format: 'esm',
target: 'node20',
dts: true,
+12
View File
@@ -1607,6 +1607,9 @@ importers:
specifier: ^27.0.2
version: 27.0.2
devDependencies:
'@commander-js/extra-typings':
specifier: ^14.0.0
version: 14.0.0([email protected])
'@types/cross-spawn':
specifier: ^6.0.6
version: 6.0.6
@@ -2906,6 +2909,11 @@ packages:
'@codemirror/[email protected]':
resolution: {integrity: sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==}
'@commander-js/[email protected]':
resolution: {integrity: sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==}
peerDependencies:
commander: ~14.0.0
'@content-collections/[email protected]':
resolution: {integrity: sha512-4ZbjxOjaDAxnj7mIij1q1vxZgOJQkA20ThoNZ0CsnmhJmUp+IDqIRLUyDyZ+4ZAk+zQy6bKeOzWzyLg32vMgRQ==}
peerDependencies:
@@ -12167,6 +12175,10 @@ snapshots:
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@commander-js/[email protected]([email protected])':
dependencies:
commander: 14.0.1
'@content-collections/[email protected]([email protected])':
dependencies:
'@standard-schema/spec': 1.0.0