/** * 更新发布工具 * * Copyright © 2025 imengyu.top imengyu-update-server */ import { confirm , input } from '@inquirer/prompts'; import { writeFile, readFile, access, unlink, readdir, stat, constants } from 'node:fs/promises'; import { exec } from 'node:child_process'; import fs from 'fs'; import archiver from 'archiver'; import path from 'node:path'; import OSS from 'ali-oss'; import cliProgress from 'cli-progress'; import { selectVersion } from './index.mjs'; import { config } from './postConfig.mjs'; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; //基础配置 //======================================== const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); function readFileRange(file, start, length) { return new Promise((resolve, reject) => { fs.open(file, 'r', (err, fd) => { if (err) { reject('Error opening file:', er); return; } const buffer = Buffer.alloc(length); fs.read(fd, buffer, 0, length, start, (err, bytesRead, buffer) => { if (err) { reject('Error reading file:', err) return; } fs.close(fd, (err) => { if (err) { reject('Error closing file:', err) return ; } resolve(buffer); }); }); }); }) } export function pad(num, n) { var strNum = num.toString(); var len = strNum.length; while (len < n) { strNum = "0" + strNum; len++; } return strNum; } async function getConfig() { let postConfig = { lastVersion: '', lastUpdateInfo: '', lastSubmitDay: '', lastTodaySubVersion: 0, }; try { postConfig = JSON.parse(await readFile(path.resolve(__dirname, './_config.json'))); } catch { // } if (postConfig.lastSubmitDay != new Date().getDate()) postConfig.lastTodaySubVersion = 0; return postConfig; } async function saveConfig(postConfig) { postConfig.lastSubmitDay = new Date().getDate(); await writeFile(path.resolve(__dirname, './_config.json'), JSON.stringify(postConfig)); } //App更新与提交 //======================================== export async function postAppUpdate(axiosInstance, param) { const postConfig = await getConfig(); const versionId = await selectVersion(false, postConfig.lastVersion); const updateInfo = await input({ message: '输入更新信息', default: postConfig.lastUpdateInfo }); await axiosInstance.post('/update-post', { config: { type: 2, test: true, versionId, uploadWebConfig: config.uploadWebConfig, submitKey: config.submitKey } }); postConfig.lastVersion = versionId; postConfig.lastUpdateInfo = updateInfo; postConfig.lastTodaySubVersion++; const updateSource = (await select({ choices: [ { name: '重新构建', value: 'rebuild', }, { name: '已上传的阿里OSS文件路径', value: 'uploaded-alioss', }, ], message: '选择上传来源', default: 'rebuild', })); const force = await confirm({ message: '强制更新?', default: false }); const updateNext = hotfix ? false : await confirm({ message: '作为下个版本?', default: false }); const versionCode = config.buildAppGetVersion(); await saveConfig(postConfig); if (updateSource === 'rebuild') await config.buildAppCallback(param, versionCode, postConfig.lastTodaySubVersion); else if (updateSource === 'uploaded-alioss') { const fileName = await input({ message: '输入已上传的阿里OSS文件路径' }); try { const result = (await axiosInstance.post('/update-post', { type: 2, ossConfig: { ossPath: fileName, ossPublic: '', }, uploadAppConfig: { updateAsNext: updateNext, }, versionId, updateInfo, versionCode: versionCode })).data; console.log('上传成功'); console.log('新更新ID: ' + result.updateId); } catch (e) { console.error('上传失败', e); } return; } else { console.error('错误的选择'); return; } const uploadFile = await config.buildAppGetUploadFile(param); try { await access(uploadFile, constants.R_OK) } catch { console.error(`Failed to access ${uploadFile}, did you created it?`); return; } console.log('开始上传'); //小于8mb则小文件上传,否则使用阿里OSS上传 const fileInfo = await stat(uploadFile); if (fileInfo.size < 8 * 1024 * 1024) { const appData = (await readFile(uploadFile)); const formData = new FormData(); formData.append("file", new Blob([ appData ], { type : 'application/zip' })); formData.append("type", 2); formData.append("versionId", versionId); formData.append("updateInfo", updateInfo); formData.append("uploadAppConfig", { updateAsNext: updateNext }); formData.append("force", force); formData.append("versionCode", versionCode); try { const result = (await axiosInstance.post('/update-post', formData, { headers: { 'Content-Type': 'multipart/form-data' } })).data; console.log('上传成功'); console.log('新更新ID: ' + result.updateId); console.log('删除构建文件'); await unlink(uploadFile); } catch (e) { console.error('上传失败', e); } } else { //阿里OSS上传 //请求STS进行临时授权 const stsToken = (await axiosInstance.post('/update-ali-oss-sts')).data; const client = new OSS({ region: stsToken.Region, accessKeyId: stsToken.AccessKeyId, accessKeySecret: stsToken.AccessKeySecret, stsToken: stsToken.SecurityToken, bucket: stsToken.Bucket, refreshSTSToken: async () => { const refreshToken = (await axiosInstance.get("/update-ali-oss-sts")).data; return { accessKeyId: refreshToken.AccessKeyId, accessKeySecret: refreshToken.AccessKeySecret, stsToken: refreshToken.SecurityToken, }; }, }); //小于96mb则直接上传,否则分片上传 const fileName = `/${await config.buildAppGetOSSFileName(param)}`; console.log('Start upload to ali oss'); let returnUrl = ''; if (fileInfo.size < 96 * 1024 * 1024) { const result = await client.put(fileName, uploadFile); returnUrl = result.url; } else { const bar1 = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic); bar1.start(100, 0); await aliOSSMultipartUpload(client, fileName, uploadFile, (p) => { bar1.update(p * 100); }); bar1.stop(); } console.log('Upload to ali oss done'); try { const result = (await axiosInstance.post('/update-post', { type: 2, ossConfig: { ossPath: fileName, ossPublic: returnUrl, }, uploadAppConfig: { updateAsNext: updateNext, }, versionId, updateInfo, versionCode: versionCode })).data; console.log('上传成功'); console.log('新更新ID: ' + result.updateId); } catch (e) { console.error('上传失败', e); } } } //Web更新与提交 //======================================== export async function postWebUpdate(axiosInstance, param) { const skipBuild = param.skip const noDelete = param.ndelete; const postConfig = await getConfig(); const versionId = await selectVersion(false, postConfig.lastVersion); const updateInfo = await input({ message: '输入更新信息', default: postConfig.lastUpdateInfo }); postConfig.lastVersion = versionId; postConfig.lastUpdateInfo = updateInfo; postConfig.lastTodaySubVersion++; await axiosInstance.post('/update-post', { config: { type: 1, test: true, versionId, uploadWebConfig: config.uploadWebConfig, submitKey: config.submitKey } }); const now = new Date(); const versionCode = await config.buildWebVersionGenerateCommand(now, postConfig.lastTodaySubVersion); if (config.buildWebOutVersionPath) await writeFile(path.resolve(__dirname, config.buildWebOutVersionPath), versionCode); await saveConfig(postConfig); if (!skipBuild && config.buildWebCommand) { console.log('正在执行构建...'); await new Promise((resolve, reject) => { exec(config.buildWebCommand, function(err, stdout) { if (err) reject(err); else resolve(); }); }); console.log('构建完成'); } const distDir = path.resolve(__dirname, config.buildWebOutDir); try { await access(distDir, constants.R_OK) } catch { console.error(`Failed to access ${distDir}`); return; } const outputPath = __dirname + '/upload.zip'; if (!skipBuild) { console.log('开始压缩zip...'); const output = fs.createWriteStream(outputPath); const archive = archiver('zip', { zlib: { level: 9 } // Sets the compression level. }); archive.pipe(output); const files = await readdir(distDir); for (const file of files) { const filestat = await stat(distDir + '/' + file); if (filestat.isDirectory()) { archive.directory(distDir + '/' + file, file); } else { archive.file(distDir + '/' + file, { name: file }); } } console.log('等待压缩zip...'); const waitArchive = new Promise((resolve, reject) => { output.on('close', function() { console.log(archive.pointer() + ' total bytes'); console.log('archiver has been finalized and the output file descriptor has closed.'); resolve(); }); archive.on('error', function(err) { reject(err); }); }) await archive.finalize(); await waitArchive; } console.log('开始上传zip'); let success = false; //小于8mb则小文件上传,否则分片上传 const fileInfo = await stat(outputPath); const formData = new FormData(); const submitConfig = { type: 1, versionId, updateInfo, versionCode, uploadWebConfig: config.uploadWebConfig, } if (fileInfo.size < 8 * 1024 * 1024) { const uploadZipContent = await readFile(outputPath); formData.append("file", new Blob([ uploadZipContent ], { type : 'application/zip' }), 'upload.zip'); } else { const multuploadInfo = (await axiosInstance.post('/update-large-token', { fileSize: fileInfo.size, fileName: path.basename(outputPath) })).data; const chunkSize = multuploadInfo.splitPartSize; const bar1 = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic); bar1.start(100, 0); for (let i = 0; i < multuploadInfo.allChunks; i++) { const start = i * chunkSize; const len = Math.min(start + chunkSize, fileInfo.size) - start; const uploadZipContent = await readFileRange(outputPath, start, len); const subFormData = new FormData(); subFormData.append("file", new Blob([ uploadZipContent ], { type : 'application/zip' }), 'upload.zip'); subFormData.append("key", multuploadInfo.key); subFormData.append("info", JSON.stringify({})); (await axiosInstance.post('/update-large', subFormData, { headers: { 'Content-Type': 'multipart/form-data' } })).data; bar1.update(Math.floor(i / multuploadInfo.allChunks * 100)); } bar1.stop(); submitConfig.multuploadedKey = multuploadInfo.key; } try { formData.append("config", JSON.stringify(submitConfig)); const result = (await axiosInstance.post('/update-post', formData, { headers: { 'Content-Type': 'multipart/form-data' } })).data; console.log('上传成功'); console.log('新更新ID: ' + result.updateId); success = true; } catch (e) { console.error('上传失败', e); } if (!success || noDelete) return; console.log('删除zip'); await unlink(outputPath); }