diff --git a/install/install.js b/install/install.js deleted file mode 100644 index 4bd88dcb3..000000000 --- a/install/install.js +++ /dev/null @@ -1,337 +0,0 @@ -/* eslint-disable no-unreachable */ -/* eslint-disable consistent-return */ -/* eslint-disable no-undef */ -/// - -const locales = { - zh: { - 'install.start': '开始运行 Hydro 安装工具', - 'info.mirror': '将首选 %s 镜像。可以使用 MIRROR=tsinghua|tencent|official 更改。', - 'warn.avx2': '检测到您的 CPU 不支持 avx2 指令集,将使用 mongodb@v4.4', - 'error.rootRequired': '请先使用 sudo su 切换到 root 用户后再运行该工具。', - 'error.unsupportedArch': '不支持的架构 %s ,请尝试手动安装。', - 'error.osreleaseNotFound': '无法获取系统版本信息(/etc/os-release 文件未找到),请尝试手动安装。', - 'error.unsupportedOS': '不支持的操作系统 %s ,请尝试手动安装,', - 'install.preparing': '正在初始化安装...', - 'install.mongodb': '正在安装 mongodb...', - 'install.nvm': '正在安装 NVM...', - 'error.nodeWithoutNVMDetected': '检测到您的系统中安装了 Node,但未使用 NVM,请尝试手动安装或卸载当前 Node 后再试。', - 'install.nodejs': '正在安装 NodeJS...', - 'error.nodeVersionPraseFail': '无法解析 Node 版本号,请尝试手动安装。', - 'install.pm2': '正在安装 PM2...', - 'install.createDatabaseUser': '正在创建数据库用户...', - 'install.compiler': '正在安装编译器...', - 'install.hydro': '正在安装 Hydro...', - 'install.done': 'Hydro 安装成功!', - 'extra.restartTerm': '请重启终端(或重新连接ssh)并切换到 root 用户执行剩下的操作。', - 'extra.dbUser': '数据库用户名: hydro', - 'extra.dbPassword': '数据库密码: %s', - 'info.skip': '步骤已跳过。', - }, - en: { - 'install.start': 'Starting Hydro installation tool', - 'info.mirror': 'Using preferred %s mirror. You can use MIRROR=tsinghua|tencent|official to change.', - 'warn.avx2': 'Your CPU does not support avx2, will use mongodb@v4.4', - 'error.rootRequired': 'Please run this tool as root user.', - 'error.unsupportedArch': 'Unsupported architecture %s, please try to install manually.', - 'error.osreleaseNotFound': 'Unable to get system version information (/etc/os-release file not found), please try to install manually.', - 'error.unsupportedOS': 'Unsupported operating system %s, please try to install manually.', - 'install.preparing': 'Initializing installation...', - 'install.mongodb': 'Installing mongodb...', - 'install.nvm': 'Installing NVM...', - 'error.nodeWithoutNVMDetected': 'Detected Node installation without NVM, please try to install manually or uninstall current Node first.', - 'install.nodejs': 'Installing NodeJS...', - 'error.nodeVersionPraseFail': 'Unable to parse Node version, please try to install manually.', - 'install.pm2': 'Installing PM2...', - 'install.createDatabaseUser': 'Creating database user...', - 'install.compiler': 'Installing compiler...', - 'install.hydro': 'Installing Hydro...', - 'install.done': 'Hydro installation completed!', - 'extra.restartTerm': 'Please restart your terminal (or reconnect ssh) and switch to root user to execute the remaining operations.', - 'extra.dbUser': 'Database username: hydro', - 'extra.dbPassword': 'Database password: %s', - 'info.skip': 'Step skipped.', - }, -}; -let locale = __env.LANG?.includes('zh') ? 'zh' : 'en'; -if (__env.TERM === 'linux') locale = 'en'; -log.info = ((orig) => (str, ...args) => orig(locales[locale][str] || str, ...args) && 0)(log.info); -log.warn = ((orig) => (str, ...args) => orig(locales[locale][str] || str, ...args) && 0)(log.warn); -log.fatal = ((orig) => (str, ...args) => orig(locales[locale][str] || str, ...args) && 0)(log.fatal); - -if (__user !== 'root') log.fatal('error.rootRequired'); -if (__arch !== 'amd64') log.fatal('error.unsupportedArch', __arch); -if (__os !== 'linux') log.fatal('error.unsupportedOS', __os); -if (!__env.HOME) log.fatal('$HOME not found'); -if (!fs.exist('/etc/os-release')) log.fatal('error.osreleaseNotFound'); -const osinfoFile = fs.readfile('/etc/os-release'); -const lines = osinfoFile.split('\n'); -const values = {}; -for (const line of lines) { - if (!line.trim()) continue; - const d = line.split('='); - if (d[1].startsWith('"')) values[d[0].toLowerCase()] = d[1].substr(1, d[1].length - 2); - else values[d[0].toLowerCase()] = d[1]; -} -let avx2 = true; -const cpuInfoFile = fs.readfile('/proc/cpuinfo'); -if (!cpuInfoFile.includes('avx2')) { - avx2 = false; - log.warn('warn.avx2'); -} -let migration; -let retry = 0; -log.info('install.start'); -let DATABASE_PASSWORD = randomstring(32); -// TODO read from args -const CN = true; - -const nixBin = `${__env.HOME}/.nix-profile/bin`; -const mount = `\ -mount: - - type: bind - source: ${nixBin} - target: /bin - readonly: true - - type: bind - source: ${nixBin} - target: /usr/bin - readonly: true - - type: bind - source: /nix - target: /nix - readonly: true - - type: bind - source: /dev/null - target: /dev/null - - type: bind - source: /dev/urandom - target: /dev/urandom - - type: tmpfs - target: /w - data: size=512m,nr_inodes=8k - - type: tmpfs - target: /tmp - data: size=512m,nr_inodes=8k -proc: true -workDir: /w -hostName: executor_server -domainName: executor_server -uid: 1536 -gid: 1536 -`; - -function removeOptionalEsbuildDeps() { - const yarnGlobalPath = exec('yarn global dir').output?.trim() || ''; - if (!yarnGlobalPath) return false; - const file = fs.readfile(`${yarnGlobalPath}/package.json`); - const data = JSON.parse(file); - data.resolutions = data.resolutions || {}; - Object.assign(data.resolutions, Object.fromEntries([ - '@esbuild/linux-loong64', - ...['android', 'windows', 'darwin', 'freebsd'].flatMap((i) => [`${i}-64`, `${i}-arm64`, `${i}-32`]), - ...['32', 'arm', 'arm64', 'mips64', 'ppc64', 'riscv64', 's390x'].map((i) => `esbuild-linux-${i}`), - ...['netbsd', 'openbsd', 'sunos'].map((i) => `esbuild-${i}-64`), - ].map((i) => [i, 'link:/dev/null']))); - fs.writefile(`${yarnGlobalPath}/package.json`, JSON.stringify(data, null, 2)); - return true; -} - -const steps = [ - { - init: 'install.preparing', - operations: [ - 'mkdir -p /data/db /data/file ~/.hydro', - () => { - if (locale === 'zh') { - log.info('扫码加入QQ群:'); - exec('echo https://qm.qq.com/cgi-bin/qm/qr\\?k\\=0aTZfDKURRhPBZVpTYBohYG6P6sxABTw | qrencode -o - -m 2 -t UTF8', {}, 0); - } - log.info(`Hydro includes anonymous system telemetry, -which helps developers figure out the most commonly used operating system and platform. -To disable this feature, checkout our sourcecode.`); - }, - 'bash -c "bash <(curl https://hydro.ac/nix.sh)"', - () => { - setenv('PATH', `${__env.HOME}/.nix-profile/bin:${__env.PATH}`); - }, - 'nix-env -iA nixpkgs.coreutils nixpkgs.bash nixpkgs.unzip nixpkgs.zip nixpkgs.diffutils nixpkgs.qrencode', - () => { - return; // Not implemented yet - if (fs.exist('/home/judge/src')) { - const res = cli.prompt('migrate.hustojFound'); - if (res.toLowerCase().trim() === 'y') migration = 'hustoj'; - } - - const docker = !exec1('docker -v').code; - if (!docker) return; - // TODO check more places - if (fs.exist('/root/OnlineJudgeDeploy/docker-compose.yml')) { - const res = cli.prompt('migrate.qduojFound'); - if (res.toLowerCase().trim() === 'y') migration = 'qduoj'; - } - }, - ], - }, - { - init: 'install.mongodb', - skip: () => !exec('mongod --version').code, - operations: [ - `nix-env -iA hydro.mongodb${avx2 ? 5 : 4}${CN ? '-cn' : ''}`, - ], - }, - { - init: 'install.mongodb-tools', - skip: () => !exec('mongoexport --version').code, - operations: [ - 'nix-env -iA nixpkgs.mongodb-tools', - ], - }, - { - init: 'install.mongo', - skip: () => !exec('mongo --version').code, - operations: [ - `nix-env -iA hydro.mongosh${avx2 ? 5 : 4}${CN ? '-cn' : ''}`, - ], - }, - { - init: 'install.nodejs', - skip: () => !exec('node -v').code && !exec('yarn -v').code, - operations: [ - 'nix-env -iA nixpkgs.nodejs nixpkgs.yarn nixpkgs.esbuild', - ], - }, - { - init: 'install.pm2', - skip: () => !exec('pm2 -v').code, - operations: [ - ['yarn global add pm2', { retry: true }], - ], - }, - { - init: 'install.compiler', - operations: [ - 'nix-env -iA nixpkgs.gcc nixpkgs.fpc nixpkgs.python3', - ], - }, - { - init: 'install.sandbox', - skip: () => !exec('hydro-sandbox --help').code, - operations: [ - 'nix-env -iA hydro.sandbox', - ], - }, - { - init: 'install.hydro', - operations: [ - () => removeOptionalEsbuildDeps(), - ['yarn global add hydrooj @hydrooj/ui-default @hydrooj/hydrojudge', { retry: true }], - () => fs.writefile(`${__env.HOME}/.hydro/addon.json`, '["@hydrooj/ui-default","@hydrooj/hydrojudge"]'), - ], - }, - { - init: 'install.createDatabaseUser', - skip: () => fs.exist(`${__env.HOME}/.hydro/config.json`), - operations: [ - 'pm2 start mongod', - () => sleep(5000), - () => fs.writefile('/tmp/createUser.js', `\ - db.createUser({ - user: 'hydro', - pwd: '${DATABASE_PASSWORD}', - roles: [{ role: 'readWrite', db: 'hydro' }] - })`), - 'mongo 127.0.0.1:27017/hydro /tmp/createUser.js', - () => fs.writefile(`${__env.HOME}/.hydro/config.json`, JSON.stringify({ - host: '127.0.0.1', - port: 27017, - name: 'hydro', - username: 'hydro', - password: DATABASE_PASSWORD, - })), - 'pm2 stop mongod', - 'pm2 del mongod', - ], - }, - { - init: 'install.starting', - operations: [ - ['pm2 stop all', { ignore: true }], - () => fs.writefile(`${__env.HOME}/.hydro/mount.yaml`, mount), - 'pm2 start mongod --name mongodb -- --auth --bind_ip 0.0.0.0', - () => sleep(1000), - `pm2 start bash --name hydro-sandbox -- -c "ulimit -s unlimited && hydro-sandbox -mount-conf ${__env.HOME}/.hydro/mount.yaml"`, - 'pm2 start hydrooj', - 'pm2 startup', - 'pm2 save', - ], - }, - { - init: 'install.migrateHustoj', - skip: () => migration !== 'hustoj', - silent: true, - operations: [ - ['yarn global add @hydrooj/migrate', { retry: true }], - 'hydrooj addon add @hydrooj/migrate', - () => { - const config = { - host: 'localhost', - port: 3306, - name: 'jol', - dataDir: '/home/judge/data', - // TODO: auto-read uname&passwd&contestType - username: 'debian-sys-maint', - password: '', - contestType: 'acm', - }; - exec2(`hydrooj cli script migrateHustoj ${JSON.stringify(config)}`); - }, - 'pm2 restart hydrooj', - ], - }, - { - init: 'install.done', - operations: [ - () => { - DATABASE_PASSWORD = loadconfig(`${__env.HOME}/.hydro/config.json`).password; - }, - () => log.info('extra.restartTerm'), - () => log.info('extra.dbUser'), - () => log.info('extra.dbPassword', DATABASE_PASSWORD), - ], - }, -]; - -for (let i = 0; i < steps.length; i++) { - const step = steps[i]; - if (!step.silent) log.info(step.init); - if (!(step.skip && step.skip())) { - for (let op of step.operations) { - if (!(op instanceof Array)) op = [op, {}]; - if (op[0].toString().startsWith('nix-env')) op[1].retry = true; - if (typeof op[0] === 'string') { - retry = 0; - exec(op[0], op[1]); - while (__code !== 0 && op[1].ignore !== true) { - if (op[1].retry && retry < 30) { - log.warn('Retry... (%s)', op[0]); - exec(op[0], op[1]); - retry++; - } else log.fatal('Error when running %s', op[0]); - } - } else { - retry = 0; - let res = op[0](op[1]); - while (res === 'retry') { - if (retry < 30) { - log.warn('Retry...'); - res = op[0](op[1]); - retry++; - } else log.fatal('Error installing'); - } - } - } - } else if (!step.silent) log.info('info.skip'); -} - -exit(0); diff --git a/install/install.sh b/install/install.sh new file mode 100644 index 000000000..fc92ca638 --- /dev/null +++ b/install/install.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -e +echo "Executing Hydro install script v3.0.0" +echo "Hydro includes anonymous system telemetry, +which helps developers figure out the most commonly used operating system and platform. +To disable this feature, checkout our sourcecode." +mkdir -p /data/db /data/file ~/.hydro +bash <(curl https://hydro.ac/nix.sh) +export PATH=$HOME/.nix-profile/bin:$PATH +nix-env -iA nixpkgs.nodejs nixpkgs.pm2 nixpkgs.yarn nixpkgs.esbuild nixpkgs.coreutils nixpkgs.bash nixpkgs.unzip nixpkgs.zip nixpkgs.diffutils nixpkgs.qrencode +echo "扫码加入QQ群:" +echo https://qm.qq.com/cgi-bin/qm/qr\?k\=0aTZfDKURRhPBZVpTYBohYG6P6sxABTw | qrencode -o - -m 2 -t UTF8 +echo "// File created by Hydro install script\n" >/tmp/install.js +cat >/tmp/install.b64 << EOF123 +%PLACEHOLDER% +EOF123 +cat /tmp/install.b64 | base64 -d >>/tmp/install.js +node /tmp/install.js +set +e diff --git a/install/install.ts b/install/install.ts new file mode 100644 index 000000000..97cc866ea --- /dev/null +++ b/install/install.ts @@ -0,0 +1,295 @@ +/* eslint-disable import/no-dynamic-require */ +/* eslint-disable no-sequences */ +import { execSync, ExecSyncOptions } from 'child_process'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; + +const exec = (command: string, args?: ExecSyncOptions) => { + try { + return { + output: execSync(command, args).toString(), + code: 0, + }; + } catch (e) { + return { + code: e.status, + message: e.message, + }; + } +}; +const sleep = (t: number) => new Promise((r) => { setTimeout(r, t); }); +const locales = { + zh: { + 'install.start': '开始运行 Hydro 安装工具', + 'warn.avx2': '检测到您的 CPU 不支持 avx2 指令集,将使用 mongodb@v4.4', + 'error.rootRequired': '请先使用 sudo su 切换到 root 用户后再运行该工具。', + 'error.unsupportedArch': '不支持的架构 %s ,请尝试手动安装。', + 'error.osreleaseNotFound': '无法获取系统版本信息(/etc/os-release 文件未找到),请尝试手动安装。', + 'error.unsupportedOS': '不支持的操作系统 %s ,请尝试手动安装,', + 'install.preparing': '正在初始化安装...', + 'install.mongodb': '正在安装 mongodb...', + 'install.createDatabaseUser': '正在创建数据库用户...', + 'install.compiler': '正在安装编译器...', + 'install.hydro': '正在安装 Hydro...', + 'install.done': 'Hydro 安装成功!', + 'extra.dbUser': '数据库用户名: hydro', + 'extra.dbPassword': '数据库密码: %s', + 'info.skip': '步骤已跳过。', + }, + en: { + 'install.start': 'Starting Hydro installation tool', + 'warn.avx2': 'Your CPU does not support avx2, will use mongodb@v4.4', + 'error.rootRequired': 'Please run this tool as root user.', + 'error.unsupportedArch': 'Unsupported architecture %s, please try to install manually.', + 'error.osreleaseNotFound': 'Unable to get system version information (/etc/os-release file not found), please try to install manually.', + 'error.unsupportedOS': 'Unsupported operating system %s, please try to install manually.', + 'install.preparing': 'Initializing installation...', + 'install.mongodb': 'Installing mongodb...', + 'install.createDatabaseUser': 'Creating database user...', + 'install.compiler': 'Installing compiler...', + 'install.hydro': 'Installing Hydro...', + 'install.done': 'Hydro installation completed!', + 'extra.dbUser': 'Database username: hydro', + 'extra.dbPassword': 'Database password: %s', + 'info.skip': 'Step skipped.', + }, +}; +let locale = process.env.LANG?.includes('zh') ? 'zh' : 'en'; +if (process.env.TERM === 'linux') locale = 'en'; +const processLog = (orig) => (str, ...args) => (orig(locales[locale][str] || str, ...args), 0); +const log = { + info: processLog(console.log), + warn: processLog(console.warn), + fatal: (str, ...args) => (processLog(console.error)(str, ...args), process.exit(1)), +}; + +if (!process.getuid) log.fatal('error.unsupportedOs'); +else if (process.getuid() !== 0) log.fatal('error.rootRequired'); +if (!['x64', 'arm64'].includes(process.arch)) log.fatal('error.unsupportedArch', process.arch); +if (!process.env.HOME) log.fatal('$HOME not found'); +if (!existsSync('/etc/os-release')) log.fatal('error.osreleaseNotFound'); +const osinfoFile = readFileSync('/etc/os-release', 'utf-8'); +const lines = osinfoFile.split('\n'); +const values = {}; +for (const line of lines) { + if (!line.trim()) continue; + const d = line.split('='); + if (d[1].startsWith('"')) values[d[0].toLowerCase()] = d[1].substr(1, d[1].length - 2); + else values[d[0].toLowerCase()] = d[1]; +} +let avx2 = true; +const cpuInfoFile = readFileSync('/proc/cpuinfo', 'utf-8'); +if (!cpuInfoFile.includes('avx2')) { + avx2 = false; + log.warn('warn.avx2'); +} +let migration; +let retry = 0; +log.info('install.start'); +const defaultDict = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'; +String.random = function random(digit = 32, dict = defaultDict) { + let str = ''; + for (let i = 1; i <= digit; i++) str += dict[Math.floor(Math.random() * dict.length)]; + return str; +}; +let DATABASE_PASSWORD = String.random(32); +// TODO read from args +const CN = true; + +const nixBin = `${process.env.HOME}/.nix-profile/bin`; +const entry = (source: string, target = source, ro = true) => `\ + - type: bind + source: ${source} + target: ${target}${ro ? '\n readonly: true' : ''}`; +const mount = `mount: +${entry(nixBin, '/bin')} +${entry(nixBin, '/usr/bin')} +${entry('/nix', '/nix')} +${entry('/dev/null', '/dev/null', false)} +${entry('/dev/urandom', '/dev/urandom', false)} + - type: tmpfs + target: /w + data: size=512m,nr_inodes=8k + - type: tmpfs + target: /tmp + data: size=512m,nr_inodes=8k +proc: true +workDir: /w +hostName: executor_server +domainName: executor_server +uid: 1536 +gid: 1536 +`; + +function removeOptionalEsbuildDeps() { + const yarnGlobalPath = exec('yarn global dir').output?.trim() || ''; + if (!yarnGlobalPath) return false; + const pkgjson = `${yarnGlobalPath}/package.json`; + const data = existsSync(pkgjson) ? require(pkgjson) : {}; + data.resolutions = data.resolutions || {}; + Object.assign(data.resolutions, Object.fromEntries([ + '@esbuild/linux-loong64', + ...['android', 'windows', 'darwin', 'freebsd'].flatMap((i) => [`${i}-64`, `${i}-arm64`, `${i}-32`]), + ...['32', 'arm', 'mips64', 'ppc64', 'riscv64', 's390x'].map((i) => `esbuild-linux-${i}`), + ...['netbsd', 'openbsd', 'sunos'].map((i) => `esbuild-${i}-64`), + ].map((i) => [i, 'link:/dev/null']))); + exec(`mkdir -p ${yarnGlobalPath}`); + writeFileSync(pkgjson, JSON.stringify(data, null, 2)); + return true; +} + +const steps = [ + { + init: 'install.preparing', + operations: [ + () => { + // Not implemented yet + // if (fs.existsSync('/home/judge/src')) { + // const res = cli.prompt('migrate.hustojFound'); + // if (res.toLowerCase().trim() === 'y') migration = 'hustoj'; + // } + + // const docker = !exec1('docker -v').code; + // if (!docker) return; + // // TODO check more places + // if (fs.exist('/root/OnlineJudgeDeploy/docker-compose.yml')) { + // const res = cli.prompt('migrate.qduojFound'); + // if (res.toLowerCase().trim() === 'y') migration = 'qduoj'; + // } + }, + ], + }, + { + init: 'install.mongodb', + operations: [ + `nix-env -iA hydro.mongodb${avx2 ? 5 : 4}${CN ? '-cn' : ''}`, + 'nix-env -iA nixpkgs.mongodb-tools', + `nix-env -iA hydro.mongosh${avx2 ? 5 : 4}${CN ? '-cn' : ''}`, + ], + }, + { + init: 'install.compiler', + operations: [ + 'nix-env -iA nixpkgs.gcc nixpkgs.fpc nixpkgs.python3', + ], + }, + { + init: 'install.sandbox', + skip: () => !exec('hydro-sandbox --help').code, + operations: [ + 'nix-env -iA hydro.sandbox', + ], + }, + { + init: 'install.hydro', + operations: [ + () => removeOptionalEsbuildDeps(), + ['yarn global add hydrooj @hydrooj/ui-default @hydrooj/hydrojudge', { retry: true }], + () => writeFileSync(`${process.env.HOME}/.hydro/addon.json`, '["@hydrooj/ui-default","@hydrooj/hydrojudge"]'), + ], + }, + { + init: 'install.createDatabaseUser', + skip: () => existsSync(`${process.env.HOME}/.hydro/config.json`), + operations: [ + 'pm2 start mongod', + () => sleep(3000), + () => writeFileSync('/tmp/createUser.js', `db.createUser(${JSON.stringify({ + user: 'hydro', + pwd: DATABASE_PASSWORD, + roles: [{ role: 'readWrite', db: 'hydro' }], + })})`), + ['mongo 127.0.0.1:27017/hydro /tmp/createUser.js', { retry: true }], + () => writeFileSync(`${process.env.HOME}/.hydro/config.json`, JSON.stringify({ + host: '127.0.0.1', + port: 27017, + name: 'hydro', + username: 'hydro', + password: DATABASE_PASSWORD, + })), + 'pm2 stop mongod', + 'pm2 del mongod', + ], + }, + { + init: 'install.starting', + operations: [ + ['pm2 stop all', { ignore: true }], + () => writeFileSync(`${process.env.HOME}/.hydro/mount.yaml`, mount), + 'pm2 start mongod --name mongodb -- --auth --bind_ip 0.0.0.0', + () => sleep(1000), + `pm2 start bash --name hydro-sandbox -- -c "ulimit -s unlimited && hydro-sandbox -mount-conf ${process.env.HOME}/.hydro/mount.yaml"`, + 'pm2 start hydrooj', + 'pm2 startup', + 'pm2 save', + ], + }, + { + init: 'install.migrateHustoj', + skip: () => migration !== 'hustoj', + silent: true, + operations: [ + ['yarn global add @hydrooj/migrate', { retry: true }], + 'hydrooj addon add @hydrooj/migrate', + () => { + const config = { + host: 'localhost', + port: 3306, + name: 'jol', + dataDir: '/home/judge/data', + // TODO: auto-read uname&passwd&contestType + username: 'debian-sys-maint', + password: '', + contestType: 'acm', + }; + exec(`hydrooj cli script migrateHustoj ${JSON.stringify(config)}`); + }, + 'pm2 restart hydrooj', + ], + }, + { + init: 'install.done', + operations: [ + () => { + DATABASE_PASSWORD = require(`${process.env.HOME}/.hydro/config.json`).password; + }, + () => log.info('extra.dbUser'), + () => log.info('extra.dbPassword', DATABASE_PASSWORD), + ], + }, +]; + +async function main() { + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + if (!step.silent) log.info(step.init); + if (!(step.skip && step.skip())) { + for (let op of step.operations) { + if (!(op instanceof Array)) op = [op, {}] as any; + if (op[0].toString().startsWith('nix-env')) op[1].retry = true; + if (typeof op[0] === 'string') { + retry = 0; + let res = exec(op[0], { stdio: 'inherit' }); + while (res.code && op[1].ignore !== true) { + if (op[1].retry && retry < 30) { + log.warn('Retry... (%s)', op[0]); + res = exec(op[0], { stdio: 'inherit' }); + retry++; + } else log.fatal('Error when running %s', op[0]); + } + } else { + retry = 0; + let res = op[0](op[1]); + while (res === 'retry') { + if (retry < 30) { + log.warn('Retry...'); + // eslint-disable-next-line no-await-in-loop + res = await op[0](op[1]); + retry++; + } else log.fatal('Error installing'); + } + } + } + } else if (!step.silent) log.info('info.skip'); + } +} +main().catch(log.fatal); diff --git a/install/jssh.d.ts b/install/jssh.d.ts deleted file mode 100644 index 6d59b33a6..000000000 --- a/install/jssh.d.ts +++ /dev/null @@ -1,778 +0,0 @@ -/** 全局变量对象 */ -declare var global: any; - -/** 是否 self-contained 方式执行 */ -declare const __selfcontained: boolean; - -/** CPU数量 */ -declare const __cpucount: number; - -/** 操作系统类型,darwin, freebsd, linux, windows */ -declare const __os: string; - -/** 处理器架构,386, amd64, arm, s390x */ -declare const __arch: string; - -/** JSSH版本号 */ -declare const __version: string; - -/** JSSH二进制文件路径 */ -declare const __bin: string; - -/** 当前进程PID */ -declare const __pid: number; - -/** 临时文件目录 */ -declare const __tmpdir: string; - -/** 当前用户HOME目录 */ -declare const __homedir: string; - -/** 当前用户名 */ -declare const __user: string; - -/** 当前主机名 */ -declare const __hostname: string; - -/** 当前脚本目录名 */ -declare var __dirname: string; - -/** 当前脚本文件名 */ -declare var __filename: string; - -/** 当前命令行参数 */ -declare const __args: string[]; - -/** 当前环境变量 */ -declare const __env: Record; - -/** 最近一次执行命令输出的内容 */ -declare const __output: string; - -/** 最近一次执行命令输出内容的字节数 */ -declare const __outputbytes: number; - -/** 最近一次执行命令进程退出code */ -declare const __code: number; - -/** 已自动加载的全局脚本文件列表 */ -declare const __globalfiles: string[]; - -/** - * 加载 CommonJS 模块 - * @param modulename 模块名称 - * @return 模块内容 - */ -// @ts-ignore -declare function require(modulename: string): any; - -/** - * 以eval方式执行指定脚本文件 - * @param filename 文件路径或URL - * @param content 文件内容,若指定此参数则无需实际读取文件内容 - * @return 返回结果 - */ -declare function evalfile(filename: string, content?: string): any; - -/** - * 获取指定字符串的字节长度 - * @param data 字符串 - * @return 字节长度 - */ -declare function bytesize(data: string): number; - -/** - * 读取标准输入的内容,返回字符串 - */ -declare function stdin(): string; - -/** - * 读取标准输入的内容,返回 Uint8Array - */ -declare function stdinbytes(): Uint8Array; - -/** - * 格式化文本内容 - * @param format 模板,支持%s等格式 - * @param args 参数列表 - * @return 格式化后的文本内容 - */ -declare function format(format: any, ...args: any[]): string; - -/** - * 输出到控制台 - * @param format 模板,支持%s等格式 - * @param args 参数列表 - * @return 是否成功 - */ -declare function print(format: any, ...args: any[]): boolean; - -/** - * 输出一行到控制台 - * @param format 模板,支持%s等格式 - * @param args 参数列表 - * @return 是否成功 - */ -declare function println(format: any, ...args: any[]): boolean; - -/** - * 在stdout输出一行日志 - * @param message - * @return 是否成功 - */ -declare function stdoutlog(message: string): boolean; - -/** - * 在stderr输出一行日志 - * @param message - * @return 是否成功 - */ -declare function stderrlog(message: string): boolean; - -/** - * 从控制台获取用户一行的字符串输入 - * @return 用户输入的字符串 - */ -declare function readline(): string; - -/** - * 睡眠 - * @param milliseconds 毫秒 - * @return 毫秒 - */ -declare function sleep(milliseconds: number): number; - -/** - * 退出进程 - * @param code 进程退出code - */ -declare function exit(code?: number): void; - -/** - * 加载配置文件,支持*.json,*.yaml,*.toml格式 - * @param file 文件名 - * @param format 格式,如果没有指定则自动根据文件后缀名判断 - * @return 配置内容 - */ -declare function loadconfig( - file: string, - format?: "json" | "yaml" | "toml" -): any; - -/** - * base64编码 - * @param data 原始内容 - * @return 编码后的内容 - */ -declare function base64encode(data: string): string; - -/** - * base64解码 - * @param data 编码后的内容 - * @return 原始内容 - */ -declare function base64decode(data: string): string; - -/** - * MD5编码 - * @param data 原始内容 - * @return 编码后的内容 - */ -declare function md5(data: string): string; - -/** - * SHA1编码 - * @param data 原始内容 - * @return 编码后的内容 - */ -declare function sha1(data: string): string; - -/** - * SAH256编码 - * @param data 原始内容 - * @return 编码后的内容 - */ -declare function sha256(data: string): string; - -/** - * 生成随机字符串 - * @param size 长度 - * @param chars 可用的字符集,默认为 ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 - * @return 生成的随机字符串 - */ -declare function randomstring(size: number, chars?: string): string; - -/** - * 格式化日期时间,格式参考PHP的 date() 函数,文档参考 https://locutus.io/php/datetime/date/ - * @param format 格式 - * @param timestamp 秒时间戳或Date对象 - * @return 日期时间字符串 - */ -declare function formatdate(format: string, timestamp?: number | Date): string; - -/** - * 深度合并两个对象 - * @param target 第一个对象 - * @param src 第二个对象,仅当第一个对象不存在该键才覆盖 - * @return 合并后的新对象 - */ -declare function deepmerge(target: any, src: any): any; - -/** - * 设置环境变量 - * @param name 环境变量名称 - * @param value 环境变量值 - * @return 是否成功 - */ -declare function setenv(name: string, value: string): boolean; - -/** - * 执行命令 - * @param cmd 命令 - * @param env 额外的环境变量 - * @param mode 输出模式,0=直接pipe输出结果到标准输出,1=执行中途不直接输出,存储到返回结果{output}中,2=pipe输出并且存储到返回结果{output}中 - * @return 进程信息 - */ -declare function exec( - cmd: string, - env?: Record, - mode?: number -): ExecResult; - -/** - * 执行命令 mode=1 - * @param cmd 命令 - * @param env 额外的环境变量 - * @return 进程信息 - */ -declare function exec1(cmd: string, env?: Record): ExecResult; - -/** - * 执行命令 mode=2 - * @param cmd 命令 - * @param env 额外的环境变量 - * @return 进程信息 - */ -declare function exec2(cmd: string, env?: Record): ExecResult; - -/** - * 后台执行命令 - * @param cmd 命令 - * @param env 额外的环境变量 - * @param mode 输出模式,0=直接pipe输出结果到标准输出,1=执行中途不直接输出,存储到返回结果{output}中,2=pipe输出并且存储到返回结果{output}中 - * @return 进程信息 - */ -declare function bgexec( - cmd: string, - env?: Record, - mode?: number -): ExecResult; - -/** - * 改变当前工作目录 - * @param dir 目录路径 - * @return 是否成功 - */ -declare function chdir(dir: string): boolean; - -/** - * 改变当前工作目录 - * @param dir 目录路径 - * @return 是否成功 - */ -declare function cd(dir: string): boolean; - -/** - * 获取当前工作目录 - * @return 当前工作目录路径 - */ -declare function cwd(): string; - -/** - * 获取当前工作目录 - * @return 当前工作目录路径 - */ -declare function pwd(): string; - -/** - * 获得网络接口信息 - * @return 网络接口数组 - */ -declare function networkinterfaces(): Record; - -interface NetworkInterface { - index: number; - mac: string; - list: { - address: string; - netmask: string; - family: string; - cidr: string; - internal: boolean; - multicast: boolean; - unspecified: boolean; - }[]; -} - -/** 文件相关操作模块 */ -declare const fs: FsModule; - -/** 文件路径相关操作模块 */ -declare const path: PathModule; - -/** 命令行参数相关操作模块 */ -declare const cli: CliModule; - -/** HTTP相关操作模块 */ -declare const http: HttpModule; - -/** 日志相关操作模块 */ -declare const log: LogModule; - -/** SSH相关操作模块 */ -declare const ssh: SshModule; - -/** Socket相关操作模块 */ -declare const socket: SocketModule; - -interface FsModule { - /** - * 读取目录 - * @param path 路径 - * @return 文件信息列表 - */ - readdir(path: string): FileStat[]; - - /** - * 读取文件内容 - * @param path 路径 - * @return 文件内容 - */ - readfile(path: string): string; - - /** - * 读取文件内容 - * @param path 路径 - * @return 文件内容 - */ - readfilebytes(path: string): Uint8Array; - - /** - * 读取文件信息 - * @param path 路径 - * @return 文件信息 - */ - stat(path: string): FileStat; - - /** - * 判断文件是否存在 - * @param path 路径 - * @return 是否存在 - */ - exist(path: string): boolean; - - /** - * 写入文件 - * @param path 路径 - * @param data 内容 - * @return 是否成功 - */ - writefile(path: string, data: string | Uint8Array): boolean; - - /** - * 追加内容到文件末尾 - * @param path 路径 - * @param data 内容 - * @return 是否成功 - */ - appendfile(path: string, data: string | Uint8Array): boolean; -} - -interface FileStat { - /** 文件名 */ - name: string; - /** 是否为目录 */ - isdir: boolean; - /** 文件mode,如0644 */ - mode: number; - /** 文件最后修改秒时间戳 */ - modtime: number; - /** 文件大小 */ - size: number; -} - -interface PathModule { - /** - * 拼接文件路径 - * @param args 子路径列表 - * @return 拼接后的路径 - */ - join(...args: string[]): string; - - /** - * 获取绝对路径 - * @param path 原始路径 - * @return 绝对路径 - */ - abs(path: string): string; - - /** - * 获取文件名 - * @param path 路径 - * @return 文件名 - */ - base(path: string): string; - - /** - * 获取文件扩展名 - * @param path 路径 - * @return 文件扩展名 - */ - ext(path: string): string; - - /** - * 获取路径的上级目录 - * @param path 路径 - * @return 上级目录 - */ - dir(path: string): string; -} - -interface CliModule { - /** - * 获取指定名称的参数 - * @param flag 参数名称 - * @return 参数值 - */ - get(flag: string): string; - - /** - * 获取指定索引的参数 - * @param index 参数索引 - * @return 参数值 - */ - get(index: number): string; - - /** - * 获取指定名称的参数的布尔值,f,false,0表示假,其余为真 - * @param flag 参数名称 - * @return 参数值 - */ - bool(flag: string): boolean; - - /** - * 获取args参数列表 - * @return 参数列表 - */ - args(): string[]; - - /** - * 获取opts参数 - * @return 参数Map - */ - opts(): Record; - - /** - * 等待用户输入一行,按Enter确认 - * @param message 提示 - * @return 输入的内容 - */ - prompt(message?: string): string; - - /** - * 注册子命令处理函数 - * @param name 命令名称,* 表示其他情况 - * @param callback 回调函数 - */ - subcommand(name: string, callback: () => void): void; - - /** - * 开始解析执行子命令 - */ - subcommandstart(): void; -} - -interface HttpModule { - /** - * 设置HTTP请求的超时时间 - * @param milliseconds 毫秒 - * @return 毫秒 - */ - timeout(milliseconds: number): number; - - /** - * 发送HTTP请求 - * @param method 请求方法 - * @param url 请求URL - * @param headers 请求头 - * @param body 请求体 - * @return 响应结果 - */ - request( - method: string, - url: String, - headers?: Record, - body?: string - ): HttpResponse; - - /** - * 通过HTTP下载文件 - * @param url 文件地址 - * @param filename 目标文件名,如果未指定则创建临时文件 - * @return 存储的文件名 - */ - download(url: string, filename?: string): string; - - /** - * 发送HTTP GET请求 - * @param url 请求URL - * @param headers 请求头 - * @return 响应结果 - */ - get(url: String, headers?: Record): HttpResponse; - - /** - * 发送HTTP HEAD请求 - * @param url 请求URL - * @param headers 请求头 - * @return 响应结果 - */ - head(url: String, headers?: Record): HttpResponse; - - /** - * 发送HTTP OPTIONS请求 - * @param url 请求URL - * @param headers 请求头 - * @return 响应结果 - */ - options(url: String, headers?: Record): HttpResponse; - - /** - * 发送HTTP POST请求 - * @param url 请求URL - * @param headers 请求头 - * @param body 请求体 - * @return 响应结果 - */ - post( - url: String, - headers?: Record, - body?: string - ): HttpResponse; - - /** - * 发送HTTP PUT请求 - * @param url 请求URL - * @param headers 请求头 - * @param body 请求体 - * @return 响应结果 - */ - put( - url: String, - headers?: Record, - body?: string - ): HttpResponse; - - /** - * 发送HTTP DELETE请求 - * @param url 请求URL - * @param headers 请求头 - * @param body 请求体 - * @return 响应结果 - */ - delete( - url: String, - headers?: Record, - body?: string - ): HttpResponse; -} - -interface HttpResponse { - /** 状态码 */ - status: number; - /** 响应头 */ - headers: Record; - /** 响应体 */ - body: string; -} - -interface LogModule { - /** - * 日志输出到控制台 - * @param format 模板,支持%s等格式 - * @param args 参数列表 - * @return 是否成功 - */ - debug(format: any, ...args: any[]): boolean; - - /** - * 日志输出到控制台 - * @param format 模板,支持%s等格式 - * @param args 参数列表 - * @return 是否成功 - */ - info(format: any, ...args: any[]): boolean; - - /** - * 日志输出到控制台 - * @param format 模板,支持%s等格式 - * @param args 参数列表 - * @return 是否成功 - */ - warn(format: any, ...args: any[]): boolean; - - /** - * 日志输出到控制台 - * @param format 模板,支持%s等格式 - * @param args 参数列表 - * @return 是否成功 - */ - error(format: any, ...args: any[]): boolean; - - /** - * 日志输出到控制台,并结束进程 - * @param format 模板,支持%s等格式 - * @param args 参数列表 - * @return 是否成功 - */ - fatal(format: any, ...args: any[]): boolean; -} - -interface ExecResult { - /** - * 进程PID - */ - pid: number; - /** - * 进程退出code - */ - code?: number; - /** - * 进程输出内容,仅当combineOutput=true时有效 - */ - output?: string; - /** - * 进出输出内容字节数,仅当combineOutput=true时有效 - */ - outputbytes?: number; -} - -interface SshModule { - /** - * 设置SSH配置 - * @param name 配置名 - * @param value 配置值 - * @return 是否成功 - */ - set( - name: "user" | "password" | "key" | "keypass" | "auth" | "timeout" | "port", - value: any - ): boolean; - - /** - * 连接到指定服务器 - * @param host 服务器地址 - * @return 是否成功 - */ - open(host: string): boolean; - - /** - * 关闭服务器连接 - * @return 是否成功 - */ - close(): boolean; - - /** - * 设置环境变量 - * @param name 环境变量名称 - * @param value 环境变量值 - * @return 是否成功 - */ - setenv(name: string, value: string): boolean; - - /** - * 执行命令 - * @param cmd 命令 - * @param env 额外的环境变量 - * @param mode 输出模式,0=直接pipe输出结果到标准输出,1=执行中途不直接输出,存储到返回结果{output}中,2=pipe输出并且存储到返回结果{output}中 - * @return 进程信息 - */ - exec(cmd: string, env?: Record, mode?: number): SshExecResult; - - /** - * 执行命令 mode=1 - * @param cmd 命令 - * @param env 额外的环境变量 - * @return 进程信息 - */ - exec1(cmd: string, env?: Record): SshExecResult; - - /** - * 执行命令 mode=2 - * @param cmd 命令 - * @param env 额外的环境变量 - * @return 进程信息 - */ - exec2(cmd: string, env?: Record): SshExecResult; - - /** - * 模拟TTY设备执行命令 - * @param cmd 命令 - * @param env 额外的环境变量 - * @return 进程信息 - */ - pty(cmd: string, env?: Record): SshExecResult; -} - -interface SshExecResult { - /** - * 进程退出code - */ - code?: number; - /** - * 进程输出内容,仅当combineOutput=true时有效 - */ - output?: string; - /** - * 进出输出内容字节数,仅当combineOutput=true时有效 - */ - outputbytes?: number; -} - -interface SocketModule { - /** - * 设置超时时间 - * @param milliseconds 毫秒 - * @return 毫秒 - */ - timeout(milliseconds: number): number; - - /** - * TCP发送消息 - * @param host 地址 - * @param port 端口 - * @param data 数据 - * @return 服务器返回的内容 - */ - tcpsend(host: string, port: number, data: string): string; - - /** - * TCP端口连接测试 - * @param host 地址 - * @param port 端口 - * @return 是否成功 - */ - tcptest(host: string, port: number): boolean; -} - -interface SqlExecResult { - lastInsertId: number; - rowsAffected: number; -} - -/** - * 简单断言 - * @param ok 要断言的值 - * @param message 提示信息 - */ -declare function assert(ok: boolean, message?: string); diff --git a/package.json b/package.json index 9295f0384..50c738a9f 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "main": "package.json", "scripts": { "build": "node build/prepare.js && tsc -b --verbose", + "build:installer": "esbuild ./install/install.js --minify --format=cjs | base64", "build:watch": "node build/prepare.js && tsc -b --watch", "build:ui": "yarn build:ui:gulp && node packages/ui-default/build", "build:ui:gulp": "node packages/ui-default/build --gulp", diff --git a/packages/hydrojudge/.npmignore b/packages/hydrojudge/.npmignore new file mode 100644 index 000000000..9d33853c0 --- /dev/null +++ b/packages/hydrojudge/.npmignore @@ -0,0 +1,2 @@ +vendor/testlib/tests +node_modules \ No newline at end of file diff --git a/packages/hydrojudge/package.json b/packages/hydrojudge/package.json index bfcf5f499..b9d31701e 100644 --- a/packages/hydrojudge/package.json +++ b/packages/hydrojudge/package.json @@ -1,7 +1,7 @@ { "name": "@hydrooj/hydrojudge", "bin": "bin/hydrojudge.js", - "version": "3.0.1", + "version": "3.0.3", "main": "package.json", "author": "undefined ", "repository": "https://github.com/hydro-dev/Hydro.git", diff --git a/packages/hydrojudge/src/sandbox.ts b/packages/hydrojudge/src/sandbox.ts index b2679e54d..fd655bf61 100644 --- a/packages/hydrojudge/src/sandbox.ts +++ b/packages/hydrojudge/src/sandbox.ts @@ -71,7 +71,7 @@ function parseArgs(execute: string): string[] { function proc(params: Parameter): Cmd { const copyOut = supportOptional ? params.copyOut - : params.copyOut.map((i) => (i.endsWith('?') ? i.substring(0, i.length - 1) : i)); + : (params.copyOut || []).map((i) => (i.endsWith('?') ? i.substring(0, i.length - 1) : i)); const size = parseMemoryMB(getConfig('stdio_size')); const rate = getConfig('rate'); const copyOutCached = [...(params.copyOutCached || [])]; diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 63804ea3c..fd3d0eea1 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.17.10", + "version": "3.17.12", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index f23116410..ce4fc29af 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -32,7 +32,12 @@ interface AcmDetail extends AcmJournal { } function buildContestRule(def: ContestRule): ContestRule { - const _originalRule = { scoreboard: def.scoreboard, stat: def.stat }; + const _originalRule = { + scoreboard: def.scoreboard, + scoreboardRow: def.scoreboardRow, + scoreboardHeader: def.scoreboardHeader, + stat: def.stat, + }; def.scoreboard = (def._originalRule?.scoreboard || def.scoreboard).bind(def); def.scoreboardHeader = (def._originalRule?.scoreboardHeader || def.scoreboardHeader).bind(def); def.scoreboardRow = (def._originalRule?.scoreboardRow || def.scoreboardRow).bind(def); @@ -234,10 +239,7 @@ const oi = buildContestRule({ return columns; }, async scoreboardRow(isExport, _, tdoc, pdict, udoc, rank, tsdoc, meta) { - const tsddict = {}; - for (const item of tsdoc.journal || []) { - if (!tsddict[item.pid] || tsddict[item.pid].score < item.score || this.submitAfterAccept) tsddict[item.pid] = item; - } + const tsddict = tsdoc.detail || {}; const row: ScoreboardNode[] = [ { type: 'rank', value: rank.toString() }, { type: 'user', value: udoc.uname, raw: tsdoc.uid }, diff --git a/packages/ui-default/build/main.ts b/packages/ui-default/build/main.ts index 07da427e3..e5d5abdf5 100644 --- a/packages/ui-default/build/main.ts +++ b/packages/ui-default/build/main.ts @@ -122,9 +122,9 @@ async function main() { fs.copyFileSync('public/default.theme.css', `public/default-${pkg.version}.theme.css`); } if (argv.options.production) { - fs.removeSync('public/vditor/dist/js/echarts'); - fs.removeSync('public/vditor/dist/js/graphviz'); - fs.removeSync('public/vditor/dist/js/mermaid'); + for (const f of ['echarts', 'graphviz', 'mermaid', 'mathjax']) { + fs.removeSync(`public/vditor/dist/js/${f}`); + } const files = fs.readdirSync('public'); files.filter((i) => /(^[in]\..+|worker)\.js\.map$/.test(i)).forEach((i) => fs.removeSync(`public/${i}`)); } diff --git a/packages/ui-default/components/autocomplete/components/AutoComplete.tsx b/packages/ui-default/components/autocomplete/components/AutoComplete.tsx index 949d01975..88299462a 100644 --- a/packages/ui-default/components/autocomplete/components/AutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/components/AutoComplete.tsx @@ -130,7 +130,7 @@ const AutoComplete = forwardRef(function Impl(props: AutoCompleteProps, re const dispatchChange = () => { if (!multi) onChange(inputRef.current?.value); - else onChange(selectedKeys.join(',')); + else onChange([...selectedKeys, inputRef.current?.value].filter((v) => v?.trim().length > 0).join(',')); }; let first = !multi; @@ -148,7 +148,7 @@ const AutoComplete = forwardRef(function Impl(props: AutoCompleteProps, re const handleInputChange = debounce((e?) => queryList(e ? e.target.value : ''), 300); - const toggleItem = (item: T, key = itemKey(item)) => { + const toggleItem = (item: T, key = itemKey(item), preserve = false) => { const shouldKeepOpen = allowEmptyQuery && inputRef.current.value === '' && !multi; if (multi) { const idx = selectedKeys.indexOf(key); @@ -167,14 +167,14 @@ const AutoComplete = forwardRef(function Impl(props: AutoCompleteProps, re setSelected((s) => [...s, item]); setSelectedKeys((s) => [...s, key]); } - inputRef.current.value = ''; + if (!preserve) inputRef.current.value = ''; inputRef.current.focus(); } else { setSelected([item]); setSelectedKeys([key]); inputRef.current.value = key; - dispatchChange(); } + dispatchChange(); if (!shouldKeepOpen) { setItemList([]); setCurrentItem(null); @@ -231,8 +231,8 @@ const AutoComplete = forwardRef(function Impl(props: AutoCompleteProps, re useImperativeHandle(ref, () => ({ getSelectedItems: () => selected, - getSelectedItemKeys: () => selectedKeys, - getSelectedItemsAsString: () => selectedKeys.join(','), + getSelectedItemKeys: () => [...selectedKeys, inputRef.current?.value].filter((v) => v?.trim().length > 0), + getSelectedItemsAsString: () => [...selectedKeys, inputRef.current?.value].filter((v) => v?.trim().length > 0).join(','), setSelectedItems: (items) => { setSelected(items); setSelectedKeys(items.map((i) => itemKey(i))); @@ -285,7 +285,7 @@ const AutoComplete = forwardRef(function Impl(props: AutoCompleteProps, re return (
{item ? itemText(item) : key}
- toggleItem(item, key)} /> + toggleItem(item, key, true)} />
); })} diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 030c690b1..c88262614 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.40.7", + "version": "4.40.8", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js",