|
@@ -0,0 +1,524 @@
|
|
|
+/**
|
|
|
+ * 更新发布工具
|
|
|
+ *
|
|
|
+ * 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 <user>')
|
|
|
+ .description('登录')
|
|
|
+ .action(login);
|
|
|
+program
|
|
|
+ .command('logstate')
|
|
|
+ .description('检查登录状态')
|
|
|
+ .action(checkLogged);
|
|
|
+program
|
|
|
+ .command('logout')
|
|
|
+ .description('退出登录')
|
|
|
+ .action(logout);
|
|
|
+
|
|
|
+program
|
|
|
+ .command('test <version>')
|
|
|
+ .description('测试更新入口')
|
|
|
+ .action(testVersion);
|
|
|
+
|
|
|
+program
|
|
|
+ .command('version <action> [type]')
|
|
|
+ .description('查看版本信息/发布版本/删除版本/设置版本, action 可选 view/new/delete/set-web-update/set-app-update/set-next-app-update')
|
|
|
+ .action(getVersion);
|
|
|
+program
|
|
|
+ .command('update <action> [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)
|
|
|
+})
|