594 lines
19 KiB
TypeScript
594 lines
19 KiB
TypeScript
import fs, { createReadStream } from 'fs';
|
||
import path from 'path';
|
||
import * as tar from 'tar';
|
||
import glob from 'fast-glob';
|
||
import { program, Command } from '@/program.ts';
|
||
import { getConfig, query } from '@/module/index.ts';
|
||
import { fileIsExist } from '@/uitls/file.ts';
|
||
import ignore from 'ignore';
|
||
import { chalk } from '@/module/chalk.ts';
|
||
import * as backServices from '@/query/services/index.ts';
|
||
import inquirer from 'inquirer';
|
||
// 查找文件(忽略大小写)
|
||
async function findFileInsensitive(targetFile: string): Promise<string | null> {
|
||
const files = fs.readdirSync('.');
|
||
const matchedFile = files.find((file) => file.toLowerCase() === targetFile.toLowerCase());
|
||
return matchedFile || null;
|
||
}
|
||
|
||
// 递归收集文件信息
|
||
async function collectFileInfo(filePath: string, baseDir = '.'): Promise<any[]> {
|
||
const stats = fs.statSync(filePath);
|
||
const relativePath = path.relative(baseDir, filePath);
|
||
|
||
if (stats.isFile()) {
|
||
return [{ path: relativePath, size: stats.size }];
|
||
}
|
||
|
||
if (stats.isDirectory()) {
|
||
const files = fs.readdirSync(filePath);
|
||
const results = await Promise.all(files.map((file) => collectFileInfo(path.join(filePath, file), baseDir)));
|
||
return results.flat();
|
||
}
|
||
|
||
return [];
|
||
}
|
||
// 解析 .npmignore 文件
|
||
async function loadNpmIgnore(cwd: string): Promise<ignore.Ignore> {
|
||
const npmIgnorePath = path.join(cwd, '.npmignore');
|
||
const ig = ignore();
|
||
|
||
try {
|
||
const content = fs.readFileSync(npmIgnorePath, 'utf-8');
|
||
ig.add(content);
|
||
} catch (err) {
|
||
console.warn('.npmignore not found, using default ignore rules');
|
||
// 如果没有 .npmignore 文件,使用默认规则
|
||
ig.add(['node_modules', '.git']);
|
||
}
|
||
|
||
return ig;
|
||
}
|
||
// 获取文件列表,兼容 .npmignore
|
||
async function getFiles(cwd: string, patterns: string[]): Promise<string[]> {
|
||
const ig = await loadNpmIgnore(cwd);
|
||
|
||
// 使用 fast-glob 匹配文件
|
||
const allFiles = await glob(patterns, {
|
||
cwd,
|
||
dot: true, // 包括隐藏文件
|
||
onlyFiles: false, // 包括目录
|
||
followSymbolicLinks: true,
|
||
});
|
||
|
||
// 过滤忽略的文件
|
||
const filteredFiles = allFiles.filter((file) => !ig.ignores(file));
|
||
return filteredFiles;
|
||
}
|
||
/**
|
||
* 复制文件到 pack-dist
|
||
* @param files 文件列表
|
||
* @param cwd 当前工作目录
|
||
* @param packDist 打包目录
|
||
*/
|
||
export const copyFilesToPackDist = async (files: string[], cwd: string, packDist = 'pack-dist') => {
|
||
const packDistPath = path.join(cwd, packDist);
|
||
if (!fileIsExist(packDistPath)) {
|
||
fs.mkdirSync(packDistPath, { recursive: true });
|
||
} else {
|
||
fs.rmSync(packDistPath, { recursive: true, force: true });
|
||
}
|
||
files.forEach((file) => {
|
||
const stat = fs.statSync(path.join(cwd, file));
|
||
if (stat.isDirectory()) {
|
||
fs.cpSync(path.join(cwd, file), path.join(packDistPath, file), { recursive: true });
|
||
} else {
|
||
fs.copyFileSync(path.join(cwd, file), path.join(packDistPath, file), fs.constants.COPYFILE_EXCL);
|
||
}
|
||
});
|
||
const packageInfo = await getPackageInfo();
|
||
// 根据所有文件,生成一个index.html
|
||
const indexHtmlPath = path.join(packDistPath, 'index.html');
|
||
const collectionFiles = (await Promise.all(files.map((file) => collectFileInfo(file)))).flat();
|
||
const prettifySize = (size: number) => {
|
||
if (size < 1024) {
|
||
return `${size}B`;
|
||
}
|
||
if (size < 1024 * 1024) {
|
||
return `${(size / 1024).toFixed(2)}kB`;
|
||
}
|
||
return `${(size / 1024 / 1024).toFixed(2)}MB`;
|
||
};
|
||
const filesString = collectionFiles.map((file) => `<li><a href="${file.path}">${file.path}</a><span>${prettifySize(file.size)}</span></li>`).join('\n');
|
||
const indexHtmlContent = `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
|
||
<head>
|
||
<title>${packageInfo.name}</title>
|
||
</head>
|
||
|
||
<body>
|
||
<h1>${packageInfo.name}</h1>
|
||
<ul>
|
||
${filesString}
|
||
</ul>
|
||
<pre>${JSON.stringify(packageInfo, null, 2)}</pre>
|
||
</body>
|
||
|
||
</html>`;
|
||
fs.writeFileSync(indexHtmlPath, indexHtmlContent);
|
||
};
|
||
export const pack = async (opts: { isTar: boolean; packDist?: string }) => {
|
||
const cwd = process.cwd();
|
||
const collection: Record<string, any> = {};
|
||
const packageJsonPath = path.join(cwd, 'package.json');
|
||
if (!fileIsExist(packageJsonPath)) {
|
||
console.error('package.json not found');
|
||
return;
|
||
}
|
||
|
||
let packageJson;
|
||
try {
|
||
const packageContent = fs.readFileSync(packageJsonPath, 'utf-8');
|
||
packageJson = JSON.parse(packageContent);
|
||
} catch (error) {
|
||
console.error('Invalid package.json:', error);
|
||
return;
|
||
}
|
||
|
||
let outputFileName = `${packageJson.name}-${packageJson.version}.tgz`
|
||
.replace('@', '') // 替换特殊字符 @
|
||
.replace(/[\/\\:*?"<>|]/g, '-'); // 替换特殊字符
|
||
|
||
// 当 opts.isTar 为 true 时,输出文件为 tgz 文件
|
||
const outputFilePath = path.join(cwd, outputFileName);
|
||
|
||
// 从 package.json 的 files 字段收集文件
|
||
const filesToInclude = packageJson.files
|
||
? await glob(packageJson.files, {
|
||
cwd: cwd,
|
||
dot: true, // 包括隐藏文件
|
||
onlyFiles: false, // 包括目录
|
||
followSymbolicLinks: true, // 处理符号链接
|
||
})
|
||
: [];
|
||
// 确保 README.md 和 dist 存在(忽略大小写检测 README.md)
|
||
const readmeFile = await findFileInsensitive('README.md');
|
||
if (readmeFile && !filesToInclude.includes(readmeFile)) {
|
||
filesToInclude.push(readmeFile);
|
||
}
|
||
const packageFile = await findFileInsensitive('package.json');
|
||
if (packageFile && !filesToInclude.includes(packageFile)) {
|
||
filesToInclude.push(packageFile);
|
||
}
|
||
const allFiles = (await Promise.all(filesToInclude.map((file) => collectFileInfo(file)))).flat();
|
||
|
||
// 输出文件详细信息
|
||
console.log('Tarball Contents:');
|
||
allFiles.forEach((file) => {
|
||
console.log(`${file.size}B ${file.path}`);
|
||
});
|
||
const totalSize = allFiles.reduce((sum, file) => sum + file.size, 0);
|
||
const packageSize = (totalSize / 1024).toFixed(2) + ' kB';
|
||
|
||
collection.files = allFiles;
|
||
collection.packageJson = packageJson;
|
||
collection.totalSize = totalSize;
|
||
collection.tags = packageJson.app?.tags || packageJson.keywords || [];
|
||
|
||
console.log('\nTarball Details');
|
||
console.log(`name: ${packageJson.name}`);
|
||
console.log(`version: ${packageJson.version}`);
|
||
console.log(`filename: ${outputFileName}`);
|
||
console.log(`package size: ${packageSize}`);
|
||
console.log(`total files: ${allFiles.length}`);
|
||
opts?.isTar && console.log(`Created package: ${outputFileName}`);
|
||
try {
|
||
if (opts.isTar) {
|
||
await tar.c(
|
||
{
|
||
gzip: true,
|
||
file: outputFilePath,
|
||
cwd: cwd,
|
||
},
|
||
filesToInclude,
|
||
);
|
||
} else {
|
||
copyFilesToPackDist(filesToInclude, cwd, opts.packDist);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error creating tarball:', error);
|
||
}
|
||
const readme = await findFileInsensitive('README.md');
|
||
if (readme) {
|
||
const readmeContent = fs.readFileSync(readme, 'utf-8');
|
||
collection.readme = readmeContent;
|
||
}
|
||
return { collection, outputFilePath };
|
||
};
|
||
export const getPackageInfo = async () => {
|
||
const cwd = process.cwd();
|
||
const packageJsonPath = path.join(cwd, 'package.json');
|
||
try {
|
||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||
return packageJson;
|
||
} catch (error) {
|
||
console.error('Invalid package.json:', error);
|
||
return {};
|
||
}
|
||
};
|
||
|
||
type PackByIgnoreOpts = {
|
||
isTar: boolean;
|
||
packDist?: string;
|
||
};
|
||
export const packByIgnore = async (opts: PackByIgnoreOpts) => {
|
||
let collection: Record<string, any> = {};
|
||
const cwd = process.cwd();
|
||
const patterns = ['**/*']; // 匹配所有文件
|
||
const packageJsonPath = path.join(cwd, 'package.json');
|
||
let packageJson;
|
||
try {
|
||
const packageContent = fs.readFileSync(packageJsonPath, 'utf-8');
|
||
packageJson = JSON.parse(packageContent);
|
||
} catch (error) {
|
||
console.error('Invalid package.json:', error);
|
||
return;
|
||
}
|
||
|
||
let outputFileName = `${packageJson.name}-${packageJson.version}.tgz`
|
||
.replace('@', '') // 替换特殊字符 @
|
||
.replace(/[\/\\:*?"<>|]/g, '-'); // 替换特殊字符
|
||
|
||
const outputFilePath = path.join(cwd, outputFileName);
|
||
|
||
// 获取符合条件的文件列表
|
||
const files = await getFiles(cwd, patterns);
|
||
|
||
console.log('Files to include in the package:');
|
||
// files 获取 size 和 path
|
||
const filesInfo = await Promise.all(files.map((file) => collectFileInfo(file)));
|
||
const allFiles = filesInfo.flat();
|
||
allFiles.forEach((file) => {
|
||
console.log(`${file.size}B ${file.path}`);
|
||
});
|
||
|
||
const totalSize = allFiles.reduce((sum, file) => sum + file.size, 0);
|
||
const packageSize = (totalSize / 1024).toFixed(2) + ' kB';
|
||
|
||
collection.files = allFiles;
|
||
collection.packageJson = packageJson;
|
||
collection.totalSize = totalSize;
|
||
collection.tags = packageJson.app?.tags || packageJson.keywords || [];
|
||
|
||
console.log('\nTarball Details');
|
||
console.log(`package size: ${packageSize}`);
|
||
console.log(`total files: ${allFiles.length}`);
|
||
const filesToInclude = files.map((file) => path.relative(cwd, file));
|
||
try {
|
||
if (opts.isTar) {
|
||
await tar.c(
|
||
{
|
||
gzip: true,
|
||
file: outputFilePath,
|
||
cwd: cwd,
|
||
},
|
||
filesToInclude,
|
||
);
|
||
} else {
|
||
copyFilesToPackDist(filesToInclude, cwd, opts.packDist);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error creating tarball:', error);
|
||
}
|
||
const readme = await findFileInsensitive('README.md');
|
||
if (readme) {
|
||
const readmeContent = fs.readFileSync(readme, 'utf-8');
|
||
collection.readme = readmeContent;
|
||
}
|
||
return { collection, outputFilePath };
|
||
};
|
||
/**
|
||
* 打包应用
|
||
* @param ignore 是否忽略 .npmignore 文件
|
||
* @returns 打包结果
|
||
*/
|
||
export const packLib = async ({ ignore = false, tar = false, packDist = 'pack-dist' }: { ignore?: boolean; tar?: boolean; packDist?: string }) => {
|
||
if (ignore) {
|
||
return await packByIgnore({ isTar: tar, packDist });
|
||
}
|
||
return await pack({ isTar: tar, packDist });
|
||
};
|
||
export const unpackLib = async (filePath: string, cwd: string) => {
|
||
try {
|
||
await tar.x({
|
||
file: filePath,
|
||
cwd: cwd,
|
||
});
|
||
} catch (error) {
|
||
console.error('Error extracting tarball:', error);
|
||
}
|
||
};
|
||
const publishCommand = new Command('publish')
|
||
.description('发布应用')
|
||
.option('-k, --key <key>', '应用 key')
|
||
.option('-v, --version <version>', '应用版本')
|
||
.action(async (options) => {
|
||
const { key, version } = options;
|
||
const config = await getConfig();
|
||
console.log('发布逻辑实现', { key, version, config });
|
||
});
|
||
|
||
const deployLoadFn = async (id: string, fileKey: string, force = false, install = false) => {
|
||
if (!id) {
|
||
console.error(chalk.red('id is required'));
|
||
return;
|
||
}
|
||
// pkg: {
|
||
// name: 'mark',
|
||
// version: '0.0.2',
|
||
// description: '',
|
||
// main: 'dist/app.mjs',
|
||
// app: [Object],
|
||
// files: [Array],
|
||
// scripts: [Object],
|
||
// keywords: [Array],
|
||
// author: 'abearxiong <xiongxiao@xiongxiao.me>',
|
||
// license: 'MIT',
|
||
// type: 'module',
|
||
// devDependencies: [Object],
|
||
// dependencies: [Object]
|
||
// },
|
||
const res = await query.post({
|
||
path: 'micro-app',
|
||
key: 'deploy',
|
||
data: {
|
||
id: id,
|
||
key: fileKey,
|
||
force: force,
|
||
install: !!install,
|
||
},
|
||
});
|
||
if (res.code === 200) {
|
||
console.log('deploy-load success. current version:', res.data?.pkg?.version);
|
||
console.log('run: ', 'envision services -s', res.data?.pkg?.app?.name || res.data?.pkg?.name);
|
||
} else {
|
||
console.error('deploy-load failed', res.message);
|
||
}
|
||
return res;
|
||
};
|
||
|
||
const packCommand = new Command('pack')
|
||
.description('打包应用, 默认使用 package.json 中的 files 字段')
|
||
.option('-i, --ignore', '使用 .npmignore 文件模式去忽略文件进行打包, 不需要package.json中的files字段')
|
||
.option('-p, --publish', '打包并发布')
|
||
.option('-u, --update', '发布后显示更新命令, show command for deploy to server')
|
||
.option('-t, --tar', '打包为 tgz 文件')
|
||
.option('-d, --packDist <dist>', '打包目录')
|
||
.option('-y, --yes', '确定,直接打包', true)
|
||
.action(async (opts) => {
|
||
const packDist = opts.packDist || 'pack-dist';
|
||
const packageInfo = await getPackageInfo();
|
||
if (!packageInfo) {
|
||
console.error('Invalid package.json:');
|
||
return;
|
||
}
|
||
let basename = packageInfo.basename || '';
|
||
let appKey: string | undefined;
|
||
let version = packageInfo.version || '';
|
||
if (!version) {
|
||
const answers = await inquirer.prompt([
|
||
{
|
||
type: 'input',
|
||
name: 'version',
|
||
message: 'Enter your version:',
|
||
},
|
||
]);
|
||
version = answers.version || version;
|
||
}
|
||
|
||
if (basename) {
|
||
if (basename.startsWith('/')) {
|
||
basename = basename.slice(1);
|
||
}
|
||
const basenameArr = basename.split('/');
|
||
if (basenameArr.length !== 2) {
|
||
console.error(chalk.red('basename is error, 请输入正确的路径, packages.json中basename例如 root/appKey'));
|
||
return;
|
||
}
|
||
appKey = basenameArr[1] || '';
|
||
}
|
||
if (!appKey) {
|
||
const answers = await inquirer.prompt([
|
||
{
|
||
type: 'input',
|
||
name: 'appKey',
|
||
message: 'Enter your appKey:',
|
||
},
|
||
]);
|
||
appKey = answers.appKey || appKey;
|
||
}
|
||
let value: { collection: Record<string, any>; outputFilePath: string } = await packLib({
|
||
ignore: opts.ignore,
|
||
tar: opts.tar,
|
||
packDist,
|
||
});
|
||
if (opts.publish) {
|
||
// 运行 deploy 命令
|
||
const runDeployCommand = 'envision pack-deploy ' + value.outputFilePath + ' -k ' + appKey;
|
||
const [_app, _command] = process.argv;
|
||
console.log(chalk.blue('example: '), runDeployCommand);
|
||
let deployDist = opts.isTar ? value.outputFilePath : packDist;
|
||
const deployCommand = [_app, _command, 'deploy', deployDist, '-k', appKey, '-v', version, '-u'];
|
||
if (opts.org) {
|
||
deployCommand.push('-o', opts.org);
|
||
}
|
||
if (opts.update) {
|
||
deployCommand.push('-s');
|
||
}
|
||
if (opts.yes) {
|
||
deployCommand.push('-y', 'yes');
|
||
}
|
||
program.parse(deployCommand);
|
||
}
|
||
});
|
||
const packDeployCommand = new Command('pack-deploy')
|
||
.argument('<id>', 'id')
|
||
.option('-k, --key <key>', 'fileKey, 服务器的部署文件夹的列表')
|
||
.option('-f --force', 'force')
|
||
.option('-i, --install ', 'install dependencies')
|
||
.action(async (id, opts) => {
|
||
let { force, key, install } = opts || {};
|
||
if (!key) {
|
||
const answers = await inquirer.prompt([
|
||
{
|
||
type: 'input',
|
||
name: 'key',
|
||
message: 'Enter your deploy to services fileKey:',
|
||
when: () => !key, // 当 username 为空时,提示用户输入
|
||
},
|
||
]);
|
||
key = answers.key || key;
|
||
}
|
||
const res = await deployLoadFn(id, key, force, install);
|
||
});
|
||
|
||
program.addCommand(packDeployCommand);
|
||
program.addCommand(publishCommand);
|
||
program.addCommand(packCommand);
|
||
|
||
enum AppType {
|
||
/**
|
||
* run in (import way)
|
||
*/
|
||
SystemApp = 'system-app',
|
||
/**
|
||
* fork 执行
|
||
*/
|
||
MicroApp = 'micro-app',
|
||
GatewayApp = 'gateway-app',
|
||
/**
|
||
* pm2 启动
|
||
*/
|
||
Pm2SystemApp = 'pm2-system-app',
|
||
}
|
||
type ServiceItem = {
|
||
key: string;
|
||
status: 'inactive' | 'running' | 'stop' | 'error';
|
||
type: AppType;
|
||
description: string;
|
||
version: string;
|
||
};
|
||
|
||
const servicesCommand = new Command('services')
|
||
.description('服务器registry当中的服务管理')
|
||
.option('-l, --list', 'list services')
|
||
.option('-r, --restart <service>', 'restart services')
|
||
.option('-s, --start <service>', 'start services')
|
||
.option('-t, --stop <service>', 'stop services')
|
||
.option('-i, --info <services>', 'info services')
|
||
.option('-d, --delete <services>', 'delete services')
|
||
.action(async (opts) => {
|
||
//
|
||
if (opts.list) {
|
||
const res = await backServices.queryServiceList();
|
||
if (res.code === 200) {
|
||
// console.log('res', JSON.stringify(res.data, null, 2));
|
||
const data = res.data as ServiceItem[];
|
||
console.log('services list');
|
||
const getMaxLengths = (data) => {
|
||
const lengths = { key: 0, status: 0, type: 0, description: 0, version: 0 };
|
||
data.forEach((item) => {
|
||
lengths.key = Math.max(lengths.key, item.key.length);
|
||
lengths.status = Math.max(lengths.status, item.status.length);
|
||
lengths.type = Math.max(lengths.type, item.type.length);
|
||
lengths.description = Math.max(lengths.description, item.description.length);
|
||
lengths.version = Math.max(lengths.version, item.version.length);
|
||
});
|
||
return lengths;
|
||
};
|
||
const lengths = getMaxLengths(data);
|
||
const padString = (str, length) => str + ' '.repeat(Math.max(length - str.length, 0));
|
||
try {
|
||
console.log(
|
||
chalk.blue(padString('Key', lengths.key)),
|
||
chalk.green(padString('Status', lengths.status)),
|
||
chalk.yellow(padString('Type', lengths.type)),
|
||
chalk.red(padString('Version', lengths.version)),
|
||
);
|
||
} catch (error) {
|
||
console.error('error', error);
|
||
}
|
||
data.forEach((item) => {
|
||
console.log(
|
||
chalk.blue(padString(item.key, lengths.key)),
|
||
chalk.green(padString(item.status, lengths.status)),
|
||
chalk.blue(padString(item.type, lengths.type)),
|
||
chalk.green(padString(item.version, lengths.version)),
|
||
);
|
||
});
|
||
} else {
|
||
console.log('error', chalk.red(res.message || '获取列表失败'));
|
||
}
|
||
return;
|
||
}
|
||
if (opts.restart) {
|
||
const res = await backServices.queryServiceOperate(opts.restart, 'restart');
|
||
if (res.code === 200) {
|
||
console.log('restart success');
|
||
} else {
|
||
console.error('restart failed', res.message);
|
||
}
|
||
return;
|
||
}
|
||
if (opts.start) {
|
||
const res = await backServices.queryServiceOperate(opts.start, 'start');
|
||
if (res.code === 200) {
|
||
console.log('start success');
|
||
} else {
|
||
console.error('start failed', res.message);
|
||
}
|
||
return;
|
||
}
|
||
if (opts.stop) {
|
||
const res = await backServices.queryServiceOperate(opts.stop, 'stop');
|
||
if (res.code === 200) {
|
||
console.log('stop success');
|
||
} else {
|
||
console.log(chalk.red('stop failed'), res.message);
|
||
}
|
||
return;
|
||
}
|
||
if (opts.info) {
|
||
const res = await backServices.queryServiceList();
|
||
if (res.code === 200) {
|
||
const data = res.data as ServiceItem[];
|
||
const item = data.find((item) => item.key === opts.info);
|
||
if (!item) {
|
||
console.log('not found');
|
||
return;
|
||
}
|
||
console.log(chalk.blue(item.key), chalk.green(item.status), chalk.yellow(item.type), chalk.red(item.version));
|
||
console.log('description:', chalk.blue(item.description));
|
||
} else {
|
||
console.log(chalk.red(res.message || '获取列表失败'));
|
||
}
|
||
}
|
||
if (opts.delete) {
|
||
// const res = await backServices.queryServiceOperate(opts.delete, 'delete');
|
||
const res = await backServices.queryServiceDelect(opts.delete);
|
||
if (res.code === 200) {
|
||
console.log('delete success');
|
||
} else {
|
||
console.log(chalk.red('delete failed'), res.message);
|
||
}
|
||
}
|
||
});
|
||
const detectCommand = new Command('detect').description('检测服务, 当返回内容不为true,则是有新增的内容').action(async () => {
|
||
const res = await backServices.queryServiceDetect();
|
||
console.log('detect', res);
|
||
});
|
||
program.addCommand(servicesCommand);
|
||
servicesCommand.addCommand(detectCommand);
|