/** * 更新发布工具 * * Copyright © 2025 imengyu.top imengyu-update-server */ import { program } from 'commander'; import { password, input, confirm, select } from '@inquirer/prompts'; import { writeFile, readFile } from 'node:fs/promises'; import { postAppUpdate, postWebUpdate } from './postUpdate.mjs'; import Table from 'cli-table3'; import md5 from 'md5'; import axios from 'axios'; import path from 'path'; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; //基础配置 //======================================== const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const constant = { ServerUrl: 'http://update-server1.imengyu.top/', TokenSave: path.resolve(__dirname, './_token.json'), }; let currentData = { token: '', identifier: '', }; readFile(constant.TokenSave).then((res) => { currentData = JSON.parse(res); if (!currentData.identifier) currentData.identifier = `commandClient${Math.floor(Math.random() * 1000)}`; start(); }).catch(() => { start(); }) const axiosInstance = axios.create({ baseURL: constant.ServerUrl, timeoutErrorMessage: '请求超时,请检查网络连接', responseType: 'json', withCredentials: false, validateStatus: () => true, }); axiosInstance.interceptors.request.use((value) => { value.headers['authorization'] = JSON.stringify({ auth: currentData?.token?.authName, validity: currentData?.token?.authKey, nonce: "aaaaaaaaaa", identifier: currentData.identifier, key: 'abc123', }); value.url = value.url + (value.url.includes('?') ? '&' : '?' ) + `identifier=${currentData.identifier}` return value; }); axiosInstance.interceptors.response.use((value) => { if (value.data.success) return value.data; else return Promise.reject(value.data); }); function getErrorMessage(e) { return e instanceof Error ? e.message : (typeof e === 'object' ? e : ('' + e)); } //登录相关 //======================================== async function checkLogged() { try { await axiosInstance.get('/auth'); console.log('已登录'); } catch (e) { console.error('获取状态失败:', getErrorMessage(e)); } } async function login(user) { try { const pass = await password({ message: '输入密码' }); const res = await axiosInstance.post('/auth?rember=true', { method: 'key', key: `${user}@${md5(pass)}`, }) currentData.token = { authName: res.data.authName, authKey: res.data.authKey, }; writeFile(constant.TokenSave, JSON.stringify(currentData)); console.log('登录成功'); } catch (e) { console.error('登录失败', getErrorMessage(e)); } } async function logout() { currentToken = ''; writeFile(constant.TokenSave, JSON.stringify(currentData)); try { await axiosInstance.delete('/auth'); console.log('退出登录成功'); } catch(e) { console.error('退出登录失败', getErrorMessage(e)); } } //版本相关 //======================================== async function viewVersion(type) { if (type === 'all' || !type) { const data = await axiosInstance.get('/version/list'); const table = new Table({ head: ['ID', '版本'], colWidths: [10, 20 ] }); data.data.forEach((d) => { table.push([ d.id, d.version ]); }) console.log(table.toString()); } else { let data = null; try { if (Number.isNaN(Number(type))) data = await axiosInstance.get('/version/get-by-name?name=' + type); else data = await axiosInstance.get('/version/' + type); } catch (e) { console.error('Failed to load version info', e); return; } const table = new Table({ head: ['key', 'data'], colWidths: [30, 60] }); table.push( [ 'ID', data.data.id ], [ '状态', stateConstant[data.data.status] ], [ '版本号', data.data.version ], [ '创建时间', new Date(data.data.createAt).toString() ], [ '设置', data.data.config ], [ '激活的Web更新ID', data.data.webUpdateId ], [ '激活的App更新ID', data.data.appUpdateId ], [ '激活的下一个App更新ID', data.data.appUpdateNextId ], ); console.log(table.toString()); } } async function getVersion(action, type) { switch(action) { case 'view': await viewVersion(type); break; case 'new': { const version = await input({ message: 'Enter version name, like (1.0.0)' }); try { await axiosInstance.post('/version', { version: version, status: 1, config: "{}", }); console.log('Add version success'); } catch (e) { console.error('Failed to add version', e); } break; } case 'delete': { const versionId = type ? type : await input({ message: '输入版本ID' }); if (!await confirm({ message: `确定删除版本 ${versionId}?`, default: false })) return; if (!await confirm({ message: '确认删除版本?此操作会删除所属版本的所有更新项目、存储等,无法恢复,是否确定删除?', default: false })) return; try { await axiosInstance.delete('/version/' + versionId); console.log('删除版本成功'); } catch (e) { console.error('删除版本失败', e); } break; } case 'set-state': { const versionId = type ? type : await input({ message: '输入版本ID' }); const state = await select({ message: '设置状态', choices: [ { name: 'NotEnable', value: 0, }, { name: 'Normal', value: 1, }, { name: 'Deprecated', value: 2, }, ], }); try { await axiosInstance.put('/version/' + versionId, { status: state }); console.log('设置状态成功'); } catch (e) { console.error('设置状态失败', e); } break; } case 'set-config': { const versionId = type ? type : await input({ message: '输入版本ID' }); const config = await input({ message: '输入配置Json' }); try { await axiosInstance.put('/version/' + versionId, { config: config, }); console.log('设置配置成功'); } catch (e) { console.error('设置配置失败', e); } break; } case 'set-active-app-update': { const versionId = await input({ message: '输入版本ID' }); const updateId = await input({ message: '输入更新ID' }); const isNext = await confirm({ message: 'Set as next active?', default: false }); try { await axiosInstance.post('/update/active/app', { versionId, updateId, isNext }); console.log('成功'); } catch (e) { console.error('失败', e); } break; } case 'set-active-web-update': { const versionId = await input({ message: '输入版本ID' }); const updateId = await input({ message: '输入更新ID' }); try { await axiosInstance.post('/update/active/web', { versionId, updateId }); console.log('成功'); } catch (e) { console.error('失败', e); } break; } default: console.error('未知参数', action); break; } } //选择方法 //======================================== export async function selectVersion(requireString = false, defaultVersionId = null) { const data = (await axiosInstance.get('/version/list')).data; if (data.length === 0) { console.error('没有版本'); return; } const resultId = (await select({ choices: data.map(p => ({ value: p.id, name: p.version, })), default: defaultVersionId, message: '选择一个版本', })); if (requireString) { return data.find(p => p.id === resultId).version } return resultId; } export async function selectUpdate() { const versionId = await selectVersion(); const data = (await axiosInstance.get('/version/update?search=' + JSON.stringify({ versionId }))).data; if (data.length === 0) { console.error('没有更新'); return; } const resultId = (await select({ choices: data.map(p => ({ value: p.id, name: p.version, })), default: defaultVersionId, message: '选择一个更新', })); return resultId; } //更新相关 //======================================== const stateConstant = [ 'Deleted', 'Normal', 'Deprecated' ]; const typeConstant = [ 'Unknow', 'Web', 'app' ]; const storageTypeConstant = [ 'Unknow', 'LocalStorage', 'AliOSS' ]; async function postUpdate(type, options) { switch (type) { case 'web': { await postWebUpdate(axiosInstance, options); break; } case 'app': { await postAppUpdate(axiosInstance, options); break; } default: console.error('Unknow type', type); break; } } async function deprecateOrDeleteUpdate(updateId) { if (!updateId) updateId = await selectUpdate(); const { currentUpdateInfo, currentVersionInfo } = await viewUpdate(updateId); const deprecate = (await select({ choices: [ { name: 'Deprecate', value: 0, }, { name: 'Delete', value: 1, }, ], message: '删除或弃用?', })) === 0; if (deprecate && currentUpdateInfo.type !== 1) { console.log('只有Web更新允许弃用'); return; } if (currentUpdateInfo.status === 0) { console.log(`当前状态 ${stateConstant[currentUpdateInfo.status]} 无法弃用`); return; } if (deprecate) { if (!await confirm({ message: `确定弃用当前版本 ${currentUpdateInfo.versionCode} ?弃用会删除存储文件以及备份。此操作无法恢复!`, default: false })) return; } else { if (!await confirm({ message: `确定删除当前版本 ${currentUpdateInfo.versionCode} ?此操作无法恢复!`, default: false })) return; } try { await axiosInstance.post(`/update/${deprecate ? 'deprecate' : 'delete'}`, { updateId }); console.log(`${deprecate ? '弃用' : '删除'} 成功`); } catch (e) { console.error(`操作失败`, e); } } async function viewUpdate(updateId) { let currentUpdateInfo = null let currentVersionInfo = null try { currentUpdateInfo = (await axiosInstance.get('/update/' + updateId)).data; } catch (e) { console.error('加载更新信息失败', updateId); } try { currentVersionInfo = (await axiosInstance.get('/version/' + currentUpdateInfo.versionId)).data; } catch (e) { console.error('加载版本信息失败', currentUpdateInfo.versionId); } const table = new Table({ head: ['key', 'data'], colWidths: [20, 40] }); table.push( [ 'ID', currentUpdateInfo.id ], [ '所属应用', currentUpdateInfo.name ], [ '更新信息', currentUpdateInfo.updateInfo ], [ '版本号', currentUpdateInfo.versionCode ], [ '创建时间', new Date(currentUpdateInfo.createAt).toString() ], [ '类型', typeConstant[currentUpdateInfo.type] ], [ '状态', stateConstant[currentUpdateInfo.status] ], [ '强制更新', currentUpdateInfo.force ], [ '公共访问路径', currentUpdateInfo.publicUrl ], [ '存储类型', storageTypeConstant[currentUpdateInfo.storageType] ], [ '存储路径', currentUpdateInfo.storagePath ], ); console.log(table.toString()); if (currentUpdateInfo.activeWebVersionName) { table.push( [ '使用中的Web版本', currentUpdateInfo.activeWebVersionName ], ); } if (currentUpdateInfo.activeAppVersionName) { table.push( [ '使用中的App版本', currentUpdateInfo.activeAppVersionName ], ); } return { currentUpdateInfo, currentVersionInfo, } } async function getUpdate(action, type, all, options) { const typeNotANumber = isNaN(new Number(type)); if (action === 'view' && (!type || typeNotANumber)) { let hasSerch = false; const search = {}; const sort = { field: "createAt", order: "descend" } if (typeNotANumber && type !== 'all') { hasSerch = true; search.version = type; } const data = await axiosInstance.get('/update/list?full=true' + (hasSerch ? ('&search=' + JSON.stringify(search)) : '') + '&sort=' + JSON.stringify(sort)); const table = new Table({ head: ['ID', '版本', '版本号', '类型', '状态'], colWidths: [10, 10, 20, 10, 15 ] }); if (type !== 'all' && all !== 'all' && data.data.length > 10) { data.data = data.data.slice(0, 10); console.log('filter!', all); } data.data.forEach((d) => { table.push([ d.id, (d.activeAppVersionName ? `${d.activeAppVersionName} (App)` : ( d.activeWebVersionName? `${d.activeWebVersionName} (Web)` : '无' )), d.versionCode, typeConstant[d.type], stateConstant[d.status] ]); }) console.log(table.toString()); } else { switch (action) { case 'post': { await postUpdate(type, options); break; } case 'delete': { await deprecateOrDeleteUpdate(type); break; } case 'view': { viewUpdate(type); break; } default: console.error('Unknow action', action); break; } } } async function testVersion(version) { try { const data = await axiosInstance.get('/update-get-info?version=' + version); console.log('版本信息', data.data); } catch(e) { console.log('获取失败', e); } } //程序入口 //============================================ program .command('login ') .description('登录') .action(login); program .command('logstate') .description('检查登录状态') .action(checkLogged); program .command('logout') .description('退出登录') .action(logout); program .command('test ') .description('测试更新入口') .action(testVersion); program .command('version [type]') .description('查看版本信息/发布版本/删除版本/设置版本, action 可选 view/new/delete/set-web-update/set-app-update/set-next-app-update') .action(getVersion); program .command('update [type] [all]') .description('查看更新信息/发布更新, action 可选 view/post, view type 可选 id/all; post type 可选 web/app') .option('--skip', '跳过构建') .option('--ndelete', '不删除构建文件') .action(getUpdate); function start() { program.parse(process.argv); } process.on('unhandledRejection', (reason, p) => { console.error('Promise: ', p, 'Reason: ', reason) })