dolly-mls-cli 1.0.0

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.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # mls-cli
2
+
3
+ 一个命令行脚手架工具,用于从远程 Git 仓库拉取模板并生成新项目结构。
4
+
5
+ ## 功能特性
6
+
7
+ - 从 GitHub/GitLab 等远程仓库下载项目模板
8
+ - 支持模板变量替换
9
+ - 交互式项目配置引导
10
+ - 快速初始化项目目录结构
11
+
12
+ ## 环境要求
13
+
14
+ - Node.js >= 14.0.0
15
+ - npm 或 yarn
16
+
17
+ ## 安装
18
+
19
+ ### 全局安装(推荐)
20
+
21
+ ```bash
22
+ npm install -g mls-cli
23
+
24
+ npm install -g mls-cli --registry=https://packages.aliyun.com/688f46a99eda9d4e3ee46b62/npm/npm-registry/
25
+ ```
26
+
27
+ ### 本地使用
28
+
29
+ ```bash
30
+ npx mls-cli <project-name>
31
+ ```
32
+
33
+ ## 源码运行说明
34
+
35
+ ### 1. 克隆仓库
36
+
37
+ ```bash
38
+ git clone https://codeup.aliyun.com/688f46a99eda9d4e3ee46b62/mls-cli.git
39
+ cd mls-cli
40
+ ```
41
+
42
+ ### 2. 安装依赖
43
+
44
+ ```bash
45
+ npm install
46
+ ```
47
+
48
+ ### 3. 本地运行
49
+
50
+ ```bash
51
+ # 使用 node 直接运行
52
+ node bin/cli.js <project-name>
53
+
54
+ # 或者创建软链接进行测试
55
+ npm link
56
+ mls <project-name>
57
+ ```
58
+
59
+ ### 4. 运行测试
60
+
61
+ ```bash
62
+ npm test
63
+ ```
64
+
65
+ ## 使用示例
66
+
67
+ ```bash
68
+ # 创建新项目
69
+ mls my-project https://codeup.aliyun.com/688f46a99eda9d4e3ee46b62/mls-template.git
70
+
71
+ - 上面地址为可用脚手架模板地址
72
+
73
+ # 按照提示选择模板和配置
74
+ ```
75
+
76
+ ## 项目结构
77
+
78
+ ```
79
+ mls-cli/
80
+ ├── bin/
81
+ │ └── cli.js # 命令行入口
82
+ ├── lib/
83
+ │ ├── create.js # 项目创建逻辑
84
+ │ ├── download.js # 模板下载逻辑
85
+ │ └── replace.js # 变量替换逻辑
86
+ ├── test/
87
+ │ └── test.js # 测试文件
88
+ ├── package.json # 项目配置
89
+ └── README.md # 说明文档
90
+ ```
91
+
92
+ ## 依赖说明
93
+
94
+ - **commander**: 命令行参数解析
95
+ - **download-git-repo**: Git 仓库下载
96
+ - **chalk**: 命令行颜色输出
97
+ - **ora**: 加载动画
98
+ - **fs-extra**: 文件系统扩展
99
+ - **glob**: 文件匹配
100
+
101
+ ## 开发计划
102
+
103
+ - [ ] 支持更多模板源
104
+ - [ ] 添加配置文件支持
105
+ - [ ] 优化交互体验
106
+
107
+ ## License
108
+
109
+ ISC
package/bin/cli.js ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { program } = require('commander');
4
+ const chalk = require('chalk');
5
+ const create = require('../lib/create');
6
+ const pkg = require('../package.json');
7
+
8
+ program
9
+ .name('mls')
10
+ .version(pkg.version)
11
+ .description('A CLI scaffold tool for creating projects from remote templates');
12
+
13
+ program
14
+ .command('create <projectName> <url>')
15
+ .description('Create a new project from a remote template')
16
+ .option('-p, --placeholder <placeholder>', 'Custom placeholder pattern to replace', '{{PROJECT_NAME}}')
17
+ .action(async (projectName, url, options) => {
18
+ console.log(chalk.blue(`\nCreating project: ${projectName}`));
19
+ console.log(chalk.gray(`Template URL: ${url}\n`));
20
+
21
+ try {
22
+ await create(projectName, url, options);
23
+ console.log(chalk.green(`\nProject ${projectName} created successfully!`));
24
+ console.log(chalk.cyan(`\nNext steps:`));
25
+ console.log(chalk.white(` cd ${projectName}`));
26
+ console.log(chalk.white(` npm install`));
27
+ console.log(chalk.white(` npm start\n`));
28
+ } catch (error) {
29
+ console.error(chalk.red(`\nError: ${error.message}`));
30
+ process.exit(1);
31
+ }
32
+ });
33
+
34
+ program.parse(process.argv);
35
+
36
+ if (!process.argv.slice(2).length) {
37
+ program.outputHelp();
38
+ }
package/lib/create.js ADDED
@@ -0,0 +1,49 @@
1
+ const path = require('path');
2
+ const fs = require('fs-extra');
3
+ const ora = require('ora');
4
+ const chalk = require('chalk');
5
+ const download = require('./download');
6
+ const replace = require('./replace');
7
+
8
+ /**
9
+ * Create a new project from a remote template
10
+ * @param {string} projectName - The name of the project to create
11
+ * @param {string} url - The URL of the template (git repo or zip file)
12
+ * @param {object} options - Options including placeholder pattern
13
+ */
14
+ async function create(projectName, url, options = {}) {
15
+ const targetDir = path.resolve(process.cwd(), projectName);
16
+ const placeholder = options.placeholder || '{{PROJECT_NAME}}';
17
+
18
+ // Check if target directory already exists
19
+ if (fs.existsSync(targetDir)) {
20
+ throw new Error(`Directory "${projectName}" already exists. Please choose a different name or remove the existing directory.`);
21
+ }
22
+
23
+ // Create target directory
24
+ fs.ensureDirSync(targetDir);
25
+
26
+ const downloadSpinner = ora('Downloading template...').start();
27
+
28
+ try {
29
+ // Download template
30
+ await download(url, targetDir);
31
+ downloadSpinner.succeed('Template downloaded successfully');
32
+
33
+ // Replace placeholders
34
+ const replaceSpinner = ora('Replacing placeholders...').start();
35
+ const replacedCount = await replace(targetDir, placeholder, projectName);
36
+ replaceSpinner.succeed(`Replaced ${replacedCount} occurrences in template files`);
37
+
38
+ return targetDir;
39
+ } catch (error) {
40
+ downloadSpinner.fail('Failed to download template');
41
+ // Clean up on failure
42
+ if (fs.existsSync(targetDir)) {
43
+ fs.removeSync(targetDir);
44
+ }
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ module.exports = create;
@@ -0,0 +1,200 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const https = require('https');
4
+ const http = require('http');
5
+ const { execSync } = require('child_process');
6
+ const { URL } = require('url');
7
+
8
+ /**
9
+ * Download template from URL
10
+ * Supports: git repositories, zip files, tar.gz files
11
+ * @param {string} url - The URL to download from
12
+ * @param {string} targetDir - The target directory
13
+ */
14
+ async function download(url, targetDir) {
15
+ // Determine download type based on URL
16
+ if (isGitUrl(url)) {
17
+ return downloadFromGit(url, targetDir);
18
+ } else if (url.endsWith('.zip') || url.endsWith('.tar.gz') || url.endsWith('.tgz')) {
19
+ return downloadArchive(url, targetDir);
20
+ } else {
21
+ // Try git clone first, fallback to archive download
22
+ try {
23
+ return await downloadFromGit(url, targetDir);
24
+ } catch {
25
+ throw new Error('Unable to download template. Please provide a valid git URL or archive URL (.zip, .tar.gz)');
26
+ }
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Check if URL is a git repository URL
32
+ */
33
+ function isGitUrl(url) {
34
+ return url.endsWith('.git') ||
35
+ url.includes('github.com') ||
36
+ url.includes('gitlab.com') ||
37
+ url.includes('gitee.com') ||
38
+ url.includes('bitbucket.org');
39
+ }
40
+
41
+ /**
42
+ * Download from git repository
43
+ */
44
+ async function downloadFromGit(url, targetDir) {
45
+ // Normalize git URL
46
+ let gitUrl = url;
47
+
48
+ // Handle GitHub shorthand (user/repo)
49
+ if (/^[\w-]+\/[\w-]+$/.test(url)) {
50
+ gitUrl = `https://github.com/${url}.git`;
51
+ }
52
+
53
+ // Ensure .git extension for proper cloning
54
+ if (!gitUrl.endsWith('.git') && !gitUrl.includes('?')) {
55
+ gitUrl = gitUrl + '.git';
56
+ }
57
+
58
+ try {
59
+ // Clone to temp directory first, then move contents
60
+ const tempDir = targetDir + '_temp';
61
+ execSync(`git clone --depth 1 "${gitUrl}" "${tempDir}"`, {
62
+ stdio: 'pipe'
63
+ });
64
+
65
+ // Remove .git folder
66
+ fs.removeSync(path.join(tempDir, '.git'));
67
+
68
+ // Move contents to target directory
69
+ const files = fs.readdirSync(tempDir);
70
+ for (const file of files) {
71
+ fs.moveSync(path.join(tempDir, file), path.join(targetDir, file));
72
+ }
73
+
74
+ // Clean up temp directory
75
+ fs.removeSync(tempDir);
76
+ } catch (error) {
77
+ throw new Error(`Git clone failed: ${error.message}`);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Download archive file (zip, tar.gz)
83
+ */
84
+ async function downloadArchive(url, targetDir) {
85
+ const tempFile = path.join(targetDir, 'temp_archive');
86
+
87
+ try {
88
+ // Download file
89
+ await downloadFile(url, tempFile);
90
+
91
+ // Extract based on file type
92
+ if (url.endsWith('.zip')) {
93
+ await extractZip(tempFile, targetDir);
94
+ } else {
95
+ await extractTarGz(tempFile, targetDir);
96
+ }
97
+
98
+ // Clean up temp file
99
+ fs.removeSync(tempFile);
100
+ } catch (error) {
101
+ throw new Error(`Archive download failed: ${error.message}`);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Download a file from URL
107
+ */
108
+ function downloadFile(url, dest) {
109
+ return new Promise((resolve, reject) => {
110
+ const parsedUrl = new URL(url);
111
+ const protocol = parsedUrl.protocol === 'https:' ? https : http;
112
+
113
+ const file = fs.createWriteStream(dest);
114
+
115
+ const request = protocol.get(url, (response) => {
116
+ // Handle redirects
117
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
118
+ file.close();
119
+ fs.unlinkSync(dest);
120
+ return downloadFile(response.headers.location, dest).then(resolve).catch(reject);
121
+ }
122
+
123
+ if (response.statusCode !== 200) {
124
+ file.close();
125
+ fs.unlinkSync(dest);
126
+ return reject(new Error(`HTTP ${response.statusCode}`));
127
+ }
128
+
129
+ response.pipe(file);
130
+
131
+ file.on('finish', () => {
132
+ file.close();
133
+ resolve();
134
+ });
135
+ });
136
+
137
+ request.on('error', (err) => {
138
+ fs.unlink(dest, () => {});
139
+ reject(err);
140
+ });
141
+
142
+ file.on('error', (err) => {
143
+ fs.unlink(dest, () => {});
144
+ reject(err);
145
+ });
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Extract zip file
151
+ */
152
+ async function extractZip(zipPath, targetDir) {
153
+ // Use built-in unzip on Unix or PowerShell on Windows
154
+ const isWindows = process.platform === 'win32';
155
+
156
+ if (isWindows) {
157
+ execSync(`powershell -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '${targetDir}' -Force"`, {
158
+ stdio: 'pipe'
159
+ });
160
+ } else {
161
+ execSync(`unzip -o "${zipPath}" -d "${targetDir}"`, {
162
+ stdio: 'pipe'
163
+ });
164
+ }
165
+
166
+ // If extracted to a single subdirectory, move contents up
167
+ await flattenSingleSubdir(targetDir);
168
+ }
169
+
170
+ /**
171
+ * Extract tar.gz file
172
+ */
173
+ async function extractTarGz(tarPath, targetDir) {
174
+ execSync(`tar -xzf "${tarPath}" -C "${targetDir}"`, {
175
+ stdio: 'pipe'
176
+ });
177
+
178
+ // If extracted to a single subdirectory, move contents up
179
+ await flattenSingleSubdir(targetDir);
180
+ }
181
+
182
+ /**
183
+ * If directory contains only one subdirectory, move its contents up
184
+ */
185
+ async function flattenSingleSubdir(dir) {
186
+ const items = fs.readdirSync(dir).filter(item => !item.startsWith('temp_'));
187
+
188
+ if (items.length === 1) {
189
+ const subdir = path.join(dir, items[0]);
190
+ if (fs.statSync(subdir).isDirectory()) {
191
+ const files = fs.readdirSync(subdir);
192
+ for (const file of files) {
193
+ fs.moveSync(path.join(subdir, file), path.join(dir, file), { overwrite: true });
194
+ }
195
+ fs.removeSync(subdir);
196
+ }
197
+ }
198
+ }
199
+
200
+ module.exports = download;
package/lib/replace.js ADDED
@@ -0,0 +1,204 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const { glob } = require('glob');
4
+
5
+ // File extensions to process for placeholder replacement
6
+ const TEXT_EXTENSIONS = [
7
+ '.json', '.yaml', '.yml', '.html', '.htm', '.xml',
8
+ '.md', 'Dockerfile', 'Makefile', 'Rakefile'
9
+ ];
10
+
11
+ // Files to always check (regardless of extension)
12
+ const ALWAYS_CHECK_FILES = [
13
+ 'package.json',
14
+ 'package-lock.json',
15
+ 'README.md',
16
+ 'README',
17
+ 'LICENSE',
18
+ '.gitignore',
19
+ '.npmrc',
20
+ '.nvmrc',
21
+ 'Dockerfile',
22
+ 'docker-compose.yml',
23
+ 'docker-compose.yaml'
24
+ ];
25
+
26
+ // Directories to skip
27
+ const SKIP_DIRS = [
28
+ 'node_modules',
29
+ '.git',
30
+ '.svn',
31
+ '.hg',
32
+ 'dist',
33
+ 'build',
34
+ '.next',
35
+ '.nuxt',
36
+ '__pycache__',
37
+ '.pytest_cache',
38
+ 'vendor',
39
+ '.idea',
40
+ '.vscode'
41
+ ];
42
+
43
+ /**
44
+ * Replace placeholder in all text files within a directory
45
+ * @param {string} dir - The directory to process
46
+ * @param {string} placeholder - The placeholder pattern to replace
47
+ * @param {string} replacement - The replacement value (project name)
48
+ * @returns {number} - Number of replacements made
49
+ */
50
+ async function replace(dir, placeholder, replacement) {
51
+ let totalReplacements = 0;
52
+
53
+ // Get all files recursively
54
+ const files = await glob('**/*', {
55
+ cwd: dir,
56
+ nodir: true,
57
+ dot: true,
58
+ ignore: SKIP_DIRS.map(d => `**/${d}/**`)
59
+ });
60
+
61
+ for (const file of files) {
62
+ const filePath = path.join(dir, file);
63
+ const fileName = path.basename(file);
64
+ const ext = path.extname(file).toLowerCase();
65
+
66
+ // Check if file should be processed
67
+ const shouldProcess = TEXT_EXTENSIONS.includes(ext) ||
68
+ TEXT_EXTENSIONS.includes(fileName) ||
69
+ ALWAYS_CHECK_FILES.includes(fileName) ||
70
+ !ext; // Files without extension
71
+
72
+ if (shouldProcess) {
73
+ try {
74
+ const count = await replaceInFile(filePath, placeholder, replacement);
75
+ totalReplacements += count;
76
+ } catch {
77
+ // Skip files that can't be read (binary files, etc.)
78
+ }
79
+ }
80
+ }
81
+
82
+ // Also handle file/directory renaming if placeholder is in the name
83
+ await renameWithPlaceholder(dir, placeholder, replacement);
84
+
85
+ return totalReplacements;
86
+ }
87
+
88
+ /**
89
+ * Replace placeholder in a single file
90
+ * @param {string} filePath - Path to the file
91
+ * @param {string} placeholder - The placeholder pattern
92
+ * @param {string} replacement - The replacement value
93
+ * @returns {number} - Number of replacements made
94
+ */
95
+ async function replaceInFile(filePath, placeholder, replacement) {
96
+ const content = await fs.readFile(filePath, 'utf8');
97
+
98
+ // Check if file is binary
99
+ if (isBinary(content)) {
100
+ return 0;
101
+ }
102
+
103
+ // Create regex for the placeholder (escape special characters)
104
+ const escapedPlaceholder = escapeRegExp(placeholder);
105
+ const regex = new RegExp(escapedPlaceholder, 'g');
106
+
107
+ // Count matches
108
+ const matches = content.match(regex);
109
+ const count = matches ? matches.length : 0;
110
+
111
+ if (count > 0) {
112
+ const newContent = content.replace(regex, replacement);
113
+ await fs.writeFile(filePath, newContent, 'utf8');
114
+ }
115
+
116
+ return count;
117
+ }
118
+
119
+ /**
120
+ * Rename files and directories containing the placeholder using recursive approach
121
+ * This ensures nested directories are handled correctly
122
+ */
123
+ async function renameWithPlaceholder(dir, placeholder, replacement) {
124
+ await renameRecursive(dir, placeholder, replacement);
125
+ }
126
+
127
+ /**
128
+ * Recursively rename files and directories
129
+ * Process order: files first, then recurse into subdirs, finally rename current dir items
130
+ */
131
+ async function renameRecursive(dir, placeholder, replacement) {
132
+ if (!fs.existsSync(dir)) return;
133
+
134
+ const items = fs.readdirSync(dir);
135
+ const files = [];
136
+ const dirs = [];
137
+
138
+ // Separate files and directories
139
+ for (const item of items) {
140
+ if (SKIP_DIRS.includes(item)) continue;
141
+
142
+ const itemPath = path.join(dir, item);
143
+ try {
144
+ if (fs.statSync(itemPath).isDirectory()) {
145
+ dirs.push(item);
146
+ } else {
147
+ files.push(item);
148
+ }
149
+ } catch {
150
+ // Skip items that can't be accessed
151
+ }
152
+ }
153
+
154
+ // 1. Rename files in current directory
155
+ for (const file of files) {
156
+ if (file.includes(placeholder)) {
157
+ const filePath = path.join(dir, file);
158
+ const newName = file.replace(new RegExp(escapeRegExp(placeholder), 'g'), replacement);
159
+ const newPath = path.join(dir, newName);
160
+ await fs.move(filePath, newPath, { overwrite: true });
161
+ }
162
+ }
163
+
164
+ // 2. Recurse into subdirectories (process children before renaming parent)
165
+ for (const subdir of dirs) {
166
+ const subdirPath = path.join(dir, subdir);
167
+ await renameRecursive(subdirPath, placeholder, replacement);
168
+ }
169
+
170
+ // 3. Rename subdirectories in current directory (after their contents are processed)
171
+ // Re-read directory as some items may have been renamed in recursion
172
+ const updatedItems = fs.readdirSync(dir);
173
+ for (const item of updatedItems) {
174
+ if (SKIP_DIRS.includes(item)) continue;
175
+
176
+ const itemPath = path.join(dir, item);
177
+ try {
178
+ if (fs.statSync(itemPath).isDirectory() && item.includes(placeholder)) {
179
+ const newName = item.replace(new RegExp(escapeRegExp(placeholder), 'g'), replacement);
180
+ const newPath = path.join(dir, newName);
181
+ await fs.move(itemPath, newPath, { overwrite: true });
182
+ }
183
+ } catch {
184
+ // Skip items that can't be accessed
185
+ }
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Check if content appears to be binary
191
+ */
192
+ function isBinary(content) {
193
+ // Check for null bytes which indicate binary content
194
+ return content.includes('\0');
195
+ }
196
+
197
+ /**
198
+ * Escape special regex characters in a string
199
+ */
200
+ function escapeRegExp(string) {
201
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
202
+ }
203
+
204
+ module.exports = replace;
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "dolly-mls-cli",
3
+ "version": "1.0.0",
4
+ "description": "A CLI scaffold tool for creating projects from remote templates",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "mls": "./bin/cli.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node test/test.js"
11
+ },
12
+ "keywords": [
13
+ "cli",
14
+ "scaffold",
15
+ "template",
16
+ "generator"
17
+ ],
18
+ "author": "",
19
+ "license": "ISC",
20
+ "dependencies": {
21
+ "chalk": "^4.1.2",
22
+ "commander": "^12.0.0",
23
+ "download-git-repo": "^3.0.2",
24
+ "fs-extra": "^11.2.0",
25
+ "glob": "^13.0.6",
26
+ "ora": "^5.4.1"
27
+ },
28
+ "engines": {
29
+ "node": ">=14.0.0"
30
+ },
31
+ "files": [
32
+ "bin",
33
+ "lib"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }