envision-cli/src/command/publish.ts
2025-04-05 13:17:55 +08:00

594 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);