fix(i18n): retain Python dependency and notification audit translations

This commit is contained in:
whyour
2026-10-14 10:25:13 +08:00
17 changed files with 524 additions and 142 deletions
+3
View File
@@ -21,3 +21,6 @@ JWT_EXPIRES_IN=
QINIU_AK=
QINIU_SK=
QINIU_SCOPE=
# Python 虚拟环境路径,留空则使用系统 Python
# PYTHON_VENV_DIR=./.venv
+10
View File
@@ -0,0 +1,10 @@
# Node.js 原生部署与 Python venv
设置 `QL_DIR` 为安装目录,`QL_DATA_DIR` 为以 `/data` 结尾的绝对持久化数据路径。
如需隔离 Python,请在启动进程的环境中显式设置 `PYTHON_VENV_DIR`(绝对路径或相对 `QL_DIR` 的路径),例如 `export PYTHON_VENV_DIR=/srv/qinglong/.venv`。未设置时保留现有 Python prefix 行为。
- `qinglong`(`shell/start.sh`)安装系统依赖和 Node 工具,并安装 Python requests,然后调用公共启动脚本。
- `npm run start:simple`(`shell/start-simplify.sh`)使用已准备的 Node、pm2、nginx、jq 等依赖,不安装系统包、全局 npm 包或 requests;显式指定的 venv 不存在时仍会创建。请先在 venv 中安装 requests 和任务所需依赖。
- 两种启动方式均保留已有 `.env`;使用 venv 时清除继承的 `PYTHONPATH`/`PYTHONUSERBASE`,依赖查询、安装和卸载使用该 venv 的 Python。
该模式需具备现有启动流程所要求的 nginx 配置和 pm2 服务权限。生产环境设置变量应通过 systemd 的 `Environment` 或相应进程配置完成。已有系统 Python 依赖不自动迁移至 venv,需在 venv 中重新安装。
+1
View File
@@ -27,6 +27,7 @@ export const SAMPLE_FILES = [
];
export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME;
export const PYTHON_VENV_DIR = process.env.PYTHON_VENV_DIR;
export const NotificationModeStringMap = {
0: 'gotify',
+135 -38
View File
@@ -1,19 +1,25 @@
import * as fs from 'fs/promises';
import { statSync } from 'fs';
import * as path from 'path';
import { exec, execSync } from 'child_process';
import psTreeFun from 'ps-tree';
import { promisify } from 'util';
import { load } from 'js-yaml';
import config from './index';
import { PYTHON_INSTALL_DIR, TASK_COMMAND } from './const';
import { PYTHON_INSTALL_DIR, PYTHON_VENV_DIR, TASK_COMMAND } from './const';
import Logger from '../loaders/logger';
import { writeFileWithLock } from '../shared/utils';
import { DependenceTypes } from '../data/dependence';
import { FormData } from 'undici';
import os from 'os';
import { maybeSudo, isInContainer } from './container';
import { shellQuote } from '../shared/shellQuote';
import { resolveFileAccess } from '../shared/fileAccess';
import { mirrorWriteCommand, normalizeMirror, rewriteMirrorSources } from '../shared/linuxMirror';
import {
mirrorWriteCommand,
normalizeMirror,
rewriteMirrorSources,
} from '../shared/linuxMirror';
export * from './share';
@@ -671,7 +677,9 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
throw new Error('Invalid timezone');
}
await promiseExec(maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`));
await promiseExec(
maybeSudo(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`),
);
await promiseExec(`echo "${timezone}" | ${maybeSudo('tee /etc/timezone')}`);
return true;
@@ -681,61 +689,150 @@ export async function setSystemTimezone(timezone: string): Promise<boolean> {
}
}
// Helper function to check if a name is a GitHub URL
function getPythonExecutable(): string {
return PYTHON_VENV_DIR
? shellQuote(path.resolve(config.rootPath, PYTHON_VENV_DIR, 'bin/python3'))
: 'python3';
}
function getPythonPip(): string {
return PYTHON_VENV_DIR ? `${getPythonExecutable()} -m pip` : 'pip3';
}
function isGitHubUrl(name: string): boolean {
// Support git+https://, git+http://, https://, and http:// URLs
// This covers GitHub URLs and other git-compatible repositories
return !!name.match(/^(git\+https?:\/\/|https?:\/\/)/i);
}
// Helper function to check if a name is a requirements file
function isRequirementsFile(name: string): boolean {
return !!name.match(/requirements.*\.(txt|in)$/i);
}
// Helper function to check if a name is a pyproject.toml file
function isPyprojectToml(name: string): boolean {
return name.endsWith('pyproject.toml');
}
export function getGetCommand(type: DependenceTypes, name: string): string {
const trimmedName = name.trim();
// For Python dependencies installed from GitHub or requirements files,
// we can't reliably check if they're installed, so skip the check
if (type === DependenceTypes.python3) {
if (
isGitHubUrl(trimmedName) ||
isRequirementsFile(trimmedName) ||
isPyprojectToml(trimmedName)
) {
// Return a command that will always indicate not installed
// This ensures GitHub URLs and requirements files are always installed
return 'echo ""';
}
}
const baseCommands = {
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`,
[DependenceTypes.python3]: `
python3 -c "exec('''
name='${name}'
try:
from importlib.metadata import version
print(version(name))
except:
import importlib.util as u
import importlib.metadata as m
spec=u.find_spec(name)
print(name if spec else '')
''')"`,
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? `apk info -es ${name}`
: maybeSudo(`dpkg-query -s ${name}`),
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${trimmedName}" | head -1`,
[DependenceTypes.python3]: `${getPythonExecutable()} -c ${shellQuote(
`from importlib.metadata import version\nimport importlib.util as u\nname=${JSON.stringify(
trimmedName,
)}\ntry:\n print(version(name))\nexcept Exception:\n print(name if u.find_spec(name) else '')`,
)}`,
[DependenceTypes.linux]:
getOsTypeSync() === 'Alpine'
? `apk info -es ${trimmedName}`
: maybeSudo(`dpkg-query -s ${trimmedName}`),
};
return baseCommands[type];
}
export function getInstallCommand(type: DependenceTypes, name: string): string {
if (type === DependenceTypes.python3) {
const prefix =
PYTHON_INSTALL_DIR && !PYTHON_VENV_DIR
? ` --prefix=${shellQuote(PYTHON_INSTALL_DIR)}`
: '';
const pip = `${getPythonPip()} install --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
}${prefix}`;
name = name.trim();
if (isRequirementsFile(name) || isPyprojectToml(name)) {
const file = [config.scriptPath, config.repoPath]
.filter(Boolean)
.map((root) => resolveFileAccess(root, [name]))
.find((file) => {
if (!file) return false;
try {
return statSync(file).isFile();
} catch {
return false;
}
});
if (!file)
throw new Error(
'Python dependency file must be inside scripts or repo',
);
return isRequirementsFile(name)
? `${pip} -r ${shellQuote(file)}`
: `${pip} ${shellQuote(path.dirname(file))}`;
}
return `${pip} -- ${shellQuote(name)}`;
}
const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm add -g',
[DependenceTypes.python3]:
'pip3 install --disable-pip-version-check --root-user-action=ignore',
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? 'apk add --no-check-certificate'
: maybeSudo('apt-get install -y'),
[DependenceTypes.linux]:
getOsTypeSync() === 'Alpine'
? 'apk add --no-check-certificate'
: maybeSudo('apt-get install -y'),
};
let command = baseCommands[type];
if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) {
command = `${command} --prefix=${PYTHON_INSTALL_DIR}`;
}
return `${command} ${name.trim()}`;
return `${baseCommands[type]} ${name.trim()}`;
}
export function getUninstallCommand(
type: DependenceTypes,
name: string,
): string {
if (type === DependenceTypes.python3) {
name = name.trim();
if (isRequirementsFile(name)) {
const file = [config.scriptPath, config.repoPath]
.filter(Boolean)
.map((root) => resolveFileAccess(root, [name]))
.find((file) => {
if (!file) return false;
try {
return statSync(file).isFile();
} catch {
return false;
}
});
if (!file)
throw new Error(
'Python dependency file must be inside scripts or repo',
);
return `${getPythonPip()} uninstall --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
} -y -r ${shellQuote(file)}`;
}
if (isPyprojectToml(name) || isGitHubUrl(name)) {
// A source URL/project can install several distributions and has no safe
// inverse. Removing its panel entry leaves installed packages intact.
return `printf '%s\\n' ${shellQuote(
'Remove installed Python distributions separately by package name.',
)}`;
}
return `${getPythonPip()} uninstall --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
} -y -- ${shellQuote(name)}`;
}
const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm remove -g',
[DependenceTypes.python3]:
'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y',
[DependenceTypes.linux]: getOsTypeSync() === 'Alpine'
? 'apk del'
: maybeSudo('apt-get remove -y'),
[DependenceTypes.linux]:
getOsTypeSync() === 'Alpine' ? 'apk del' : maybeSudo('apt-get remove -y'),
};
return `${baseCommands[type]} ${name.trim()}`;
}
+34 -12
View File
@@ -32,10 +32,11 @@ export default class DependenceService {
constructor(
@Inject('logger') private logger: winston.Logger,
private sockService: SockService,
) { }
) {}
public async create(payloads: Dependence[]): Promise<Dependence[]> {
const tabs = payloads.map((x) => {
getInstallCommand(x.type, x.name);
const tab = new Dependence({ ...x, status: DependenceStatus.queued });
return tab;
});
@@ -59,6 +60,7 @@ export default class DependenceService {
...other,
status: DependenceStatus.queued,
});
getInstallCommand(tab.type, tab.name);
const newDoc = await this.updateDb(tab);
this.installDependenceOneByOne([newDoc]);
return newDoc;
@@ -296,6 +298,23 @@ export default class DependenceService {
force: boolean = false,
) {
return taskLimit.runDependeny(dependency, () => {
let validatedCommand: string;
try {
validatedCommand = isInstall
? getInstallCommand(dependency.type, dependency.name.trim())
: getUninstallCommand(dependency.type, dependency.name.trim());
} catch (error) {
taskLimit.removeQueuedDependency(dependency);
return DependenceModel.update(
{
status: isInstall
? DependenceStatus.installFailed
: DependenceStatus.removeFailed,
log: [String(error)],
},
{ where: { id: dependency.id } },
).then(() => null);
}
return new Promise(async (resolve) => {
if (taskLimit.firstDependencyId !== dependency.id) {
return resolve(null);
@@ -345,9 +364,7 @@ export default class DependenceService {
: DependenceStatus.removing;
await DependenceModel.update({ status }, { where: { id: depIds } });
let command = isInstall
? getInstallCommand(dependency.type, depName)
: getUninstallCommand(dependency.type, depName);
let command = validatedCommand;
if (isLinuxDependence) {
command = isInstall
? `${linuxCommand.install} ${depName.trim()}`
@@ -470,15 +487,20 @@ export default class DependenceService {
const message =
'\n' +
tf('依赖%s%s,结束时间 %s,耗时 %s 秒',
actionText,
resultText,
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(endTime.diff(startTime, 'second')),
);
tf(
'依赖%s%s,结束时间 %s,耗时 %s 秒',
actionText,
resultText,
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(endTime.diff(startTime, 'second')),
);
const exitStatus = isSucceed
? (isInstall ? DependenceStatus.installed : DependenceStatus.removed)
: (isInstall ? DependenceStatus.installFailed : DependenceStatus.removeFailed);
? isInstall
? DependenceStatus.installed
: DependenceStatus.removed
: isInstall
? DependenceStatus.installFailed
: DependenceStatus.removeFailed;
this.sockService.sendMessage({
type: socketMessageType,
message,
@@ -0,0 +1,7 @@
# Python 依赖来源
依赖管理中可手动添加 Python 包名、`git+https://` 等 pip 支持的源 URL,或脚本/订阅仓库目录内的 requirements 文件、pyproject.toml 路径。文件路径支持目录空格,必须位于持久化 scripts 或 repo 根目录内;越界与指向根目录外的符号链接会被拒绝。
requirements 使用 `pip install -r`,pyproject 使用完整项目目录安装,保留相对依赖文件和项目源码。拉取订阅不会自动安装仓库声明的依赖。
删除 requirements 条目会调用 `pip uninstall -r`。源 URL 或 pyproject 条目无法可靠逆向推断其安装的所有分发包,因此删除该条目只移除面板记录;需另外按包名卸载已安装的包,日志会明确提示。
+6 -3
View File
@@ -28,7 +28,8 @@
"panel": "npm run build:back && node static/build/app.js",
"gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true,snakeToCamel=false",
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
"postinstall": "max setup 2>/dev/null || true"
"postinstall": "max setup 2>/dev/null || true",
"start:simple": "bash shell/start-simplify.sh"
},
"gitHooks": {
"pre-commit": "lint-staged"
@@ -76,7 +77,9 @@
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.16",
"proxy-addr": "2.0.8",
"websocket-driver": "0.7.5"
"websocket-driver": "0.7.5",
"brace-expansion@1": "1.1.21",
"brace-expansion@2": "2.1.7"
}
},
"dependencies": {
@@ -171,7 +174,7 @@
"concurrently": "^7.0.0",
"file-saver": "2.0.2",
"lint-staged": "^13.0.3",
"moment": "2.30.1",
"moment": "2.31.0",
"monaco-editor": "0.33.0",
"nodemon": "^3.0.1",
"prettier": "^2.5.1",
+14 -23
View File
@@ -6,6 +6,8 @@ overrides:
'@codemirror/view': 6.39.16
proxy-addr: 2.0.8
websocket-driver: 0.7.5
brace-expansion@1: 1.1.21
brace-expansion@2: 2.1.7
dependencies:
'@ant-design/plots':
@@ -278,8 +280,8 @@ devDependencies:
specifier: ^13.0.3
version: 13.3.0
moment:
specifier: 2.30.1
version: 2.30.1
specifier: 2.31.0
version: 2.31.0
monaco-editor:
specifier: 0.33.0
version: 0.33.0
@@ -876,7 +878,7 @@ packages:
'@babel/runtime': 7.26.0
antd: 4.24.16(react-dom@18.3.1)(react@18.3.1)
classnames: 2.5.1
moment: 2.30.1
moment: 2.31.0
rc-util: 5.43.0(react-dom@18.3.1)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
@@ -5369,7 +5371,7 @@ packages:
fast-deep-equal: 3.1.3
intl: 1.2.5
lodash: 4.17.21
moment: 2.30.1
moment: 2.31.0
qiankun: 2.10.16
react-intl: 3.12.1(react@18.3.1)
react-redux: 8.1.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1)(react@18.3.1)(redux@4.2.1)
@@ -5866,7 +5868,7 @@ packages:
classnames: 2.5.1
copy-to-clipboard: 3.3.3
lodash: 4.17.21
moment: 2.30.1
moment: 2.31.0
rc-cascader: 3.7.3(react-dom@18.3.1)(react@18.3.1)
rc-checkbox: 3.0.1(react-dom@18.3.1)(react@18.3.1)
rc-collapse: 3.4.2(react-dom@18.3.1)(react@18.3.1)
@@ -6357,18 +6359,6 @@ packages:
big-integer: 1.6.52
dev: true
/brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
/brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
dependencies:
balanced-match: 1.0.2
dev: true
/braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
@@ -8662,7 +8652,7 @@ packages:
/file-stream-rotator@0.6.1:
resolution: {integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==}
dependencies:
moment: 2.30.1
moment: 2.31.0
dev: false
/fill-range@7.1.1:
@@ -10733,11 +10723,12 @@ packages:
/moment-timezone@0.5.46:
resolution: {integrity: sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw==}
dependencies:
moment: 2.30.1
moment: 2.31.0
dev: false
/moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
/moment@2.31.0:
resolution:
integrity: sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==
/monaco-editor@0.33.0:
resolution: {integrity: sha512-VcRWPSLIUEgQJQIE0pVT8FcGBIgFoxz7jtqctE+IiCxWugD0DwgyQBcZBhdSrdMC84eumoqMZsGl2GTreOzwqw==}
@@ -12724,7 +12715,7 @@ packages:
classnames: 2.5.1
date-fns: 2.30.0
dayjs: 1.11.13
moment: 2.30.1
moment: 2.31.0
rc-trigger: 5.3.4(react-dom@18.3.1)(react@18.3.1)
rc-util: 5.43.0(react-dom@18.3.1)(react@18.3.1)
react: 18.3.1
@@ -13951,7 +13942,7 @@ packages:
dottie: 2.0.6
inflection: 1.13.4
lodash: 4.17.21
moment: 2.30.1
moment: 2.31.0
moment-timezone: 0.5.46
pg-connection-string: 2.7.0
retry-as-promised: 7.0.4
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -e
: "${QL_DIR:?Please set QL_DIR}"
: "${QL_DATA_DIR:?Please set QL_DATA_DIR}"
[[ "$QL_DIR" = /* && "$QL_DATA_DIR" = /*/data ]] || { echo "QL_DIR and QL_DATA_DIR must be absolute paths (data directory ends in /data)" >&2; exit 1; }
command="$1"
export PNPM_HOME="${PNPM_HOME:-${QL_DATA_DIR}/dep_cache/node}"
if [[ -n "${PYTHON_VENV_DIR:-}" ]]; then
[[ "$PYTHON_VENV_DIR" = /* ]] || PYTHON_VENV_DIR="${QL_DIR}/${PYTHON_VENV_DIR}"
export PYTHON_VENV_DIR
if [[ ! -x "$PYTHON_VENV_DIR/bin/python3" ]]; then
python3 -m venv "$PYTHON_VENV_DIR"
fi
[[ -x "$PYTHON_VENV_DIR/bin/python3" ]] || { echo "Invalid Python venv" >&2; exit 1; }
unset PYTHON_HOME PYTHONUSERBASE PYTHONPATH
export PATH="${PYTHON_VENV_DIR}/bin:${PATH}:${PNPM_HOME}"
export PIP_CACHE_DIR="${PYTHON_VENV_DIR}/pip"
else
export PYTHON_SHORT_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
export PYTHON_HOME="${PYTHON_HOME:-${QL_DATA_DIR}/dep_cache/python3}"
export PYTHONUSERBASE="$PYTHON_HOME"
export PATH="${PATH}:${PNPM_HOME}:${PYTHON_HOME}/bin"
export PIP_CACHE_DIR="${PYTHON_HOME}/pip"
export PYTHONPATH="${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages"
fi
export NODE_PATH="${NODE_PATH:-/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules}"
mkdir -p "$PIP_CACHE_DIR"
cd "${QL_DIR}"
if [[ ! -e .env ]]; then
# Preserve user configuration, including a file created concurrently.
(umask 077; set -C; cat .env.example > .env)
fi
chmod u+x "${QL_DIR}"/shell/*.sh
. "${QL_DIR}/shell/share.sh"
. "${QL_DIR}/shell/env.sh"
log_with_style() {
local level="$1"
local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
}
log_with_style "INFO" "🚀 1. 检测配置文件..."
import_config "$@"
make_dir /etc/nginx/conf.d
make_dir /run/nginx
fix_config
pm2 l &>/dev/null
log_with_style "INFO" "🔄 2. 启动 nginx..."
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
log_with_style "INFO" "⚙️ 3. 启动 pm2 服务..."
reload_pm2
if [[ $command != "reload" ]]; then
if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 4. 启动 bot..."
nohup ql bot >$dir_log/bot.log 2>&1 &
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 5. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 &
fi
pm2 startup
pm2 save
fi
log_with_style "SUCCESS" "🎉 启动成功!"
+8 -60
View File
@@ -77,65 +77,13 @@ if [[ $command != "reload" ]]; then
npm install -g pnpm@8.3.1 pm2 ts-node typescript@5
fi
export PYTHON_SHORT_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
export PNPM_HOME=${QL_DIR}/data/dep_cache/node
export PYTHON_HOME=${QL_DIR}/data/dep_cache/python3
export PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin
export NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules
export PIP_CACHE_DIR=${PYTHON_HOME}/pip
export PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages
if [[ $command != "reload" ]]; then
pip3 install --prefix ${PYTHON_HOME} requests
fi
cd "${QL_DIR}"
if [[ ! -e .env ]]; then
# Preserve user configuration, including a file created concurrently.
(umask 077; set -C; cat .env.example > .env)
fi
chmod 777 ${QL_DIR}/shell/*.sh
. ${QL_DIR}/shell/share.sh
. ${QL_DIR}/shell/env.sh
log_with_style() {
local level="$1"
local message="$2"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}"
}
log_with_style "INFO" "🚀 1. 检测配置文件..."
import_config "$@"
make_dir /etc/nginx/conf.d
make_dir /run/nginx
fix_config
pm2 l &>/dev/null
log_with_style "INFO" "🔄 2. 启动 nginx..."
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
log_with_style "INFO" "⚙️ 3. 启动 pm2 服务..."
reload_pm2
if [[ $command != "reload" ]]; then
if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 4. 启动 bot..."
nohup ql bot >$dir_log/bot.log 2>&1 &
if [[ "$command" != "reload" ]]; then
if [[ -n "${PYTHON_VENV_DIR:-}" ]]; then
[[ "$PYTHON_VENV_DIR" = /* ]] || export PYTHON_VENV_DIR="${QL_DIR}/${PYTHON_VENV_DIR}"
[[ -x "$PYTHON_VENV_DIR/bin/python3" ]] || python3 -m venv "$PYTHON_VENV_DIR"
"$PYTHON_VENV_DIR/bin/python3" -m pip install requests
else
pip3 install --prefix "${PYTHON_HOME:-${QL_DATA_DIR}/dep_cache/python3}" requests
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 5. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 &
fi
pm2 startup
pm2 save
fi
log_with_style "SUCCESS" "🎉 启动成功!"
exec /bin/bash "${QL_DIR}/shell/start-simplify.sh" "$@"
+3 -1
View File
@@ -705,5 +705,7 @@
"发送状态": "Delivery status",
"通知来源": "Notification source",
"脚本通知": "Script notification",
"系统通知": "System notification"
"系统通知": "System notification",
"Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python supports package names, Git URLs, and existing requirements.txt or pyproject.toml files under the scripts or repository directory.",
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "Package name, Git URL, requirements.txt or pyproject.toml path"
}
+3 -1
View File
@@ -705,5 +705,7 @@
"发送状态": "发送状态",
"通知来源": "通知来源",
"脚本通知": "脚本通知",
"系统通知": "系统通知"
"系统通知": "系统通知",
"Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径",
"支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "支持包名、GitHub链接、requirements.txt或pyproject.toml路径"
}
+18 -2
View File
@@ -22,6 +22,9 @@ const DependenceModal = ({
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [selectedType, setSelectedType] = useState(
DependenceTypes[defaultType as any],
);
const handleOk = async (values: any) => {
setLoading(true);
@@ -90,7 +93,7 @@ const DependenceModal = ({
label={intl.get('依赖类型')}
initialValue={DependenceTypes[defaultType as any]}
>
<Select>
<Select onChange={(value) => setSelectedType(value)}>
{config.dependenceTypes.map((x, i) => (
<Option key={i} value={i}>
{x}
@@ -121,11 +124,24 @@ const DependenceModal = ({
whitespace: true,
},
]}
tooltip={
selectedType === DependenceTypes.python3
? intl.get(
'Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径',
)
: undefined
}
>
<Input.TextArea
rows={4}
autoSize={{ minRows: 1, maxRows: 5 }}
placeholder={intl.get('请输入依赖名称')}
placeholder={
selectedType === DependenceTypes.python3
? intl.get(
'支持包名、GitHub链接、requirements.txt或pyproject.toml路径',
)
: intl.get('请输入依赖名称')
}
/>
</Form.Item>
<Form.Item name="remark" label={intl.get('备注')}>
@@ -0,0 +1,63 @@
const test = require('node:test'),
assert = require('node:assert/strict'),
fs = require('node:fs'),
os = require('node:os'),
path = require('node:path'),
{ execFileSync } = require('node:child_process');
const load = require('../helpers/load-security-module.cjs');
test('Python source arguments round-trip literally and local files stay inside maintained roots', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-pip-source-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const scripts = path.join(root, 'scripts'),
repo = path.join(root, 'repo');
fs.mkdirSync(scripts);
fs.mkdirSync(repo);
fs.mkdirSync(path.join(scripts, 'project space'));
const requirement = path.join(scripts, 'project space', 'requirements.txt');
fs.writeFileSync(requirement, 'requests');
fs.writeFileSync(
path.join(scripts, 'project space', 'pyproject.toml'),
'[project]\nname="sample"',
);
const outside = path.join(root, 'requirements.txt');
fs.writeFileSync(outside, 'outside');
fs.symlinkSync(outside, path.join(scripts, 'requirements-link.txt'));
const api = load('back/config/util.ts', {
'./index': { scriptPath: scripts, repoPath: repo },
'./const': { PYTHON_INSTALL_DIR: '/prefix with spaces' },
'../loaders/logger': {},
'../shared/utils': {},
'../data/dependence': {
DependenceTypes: { nodejs: 0, python3: 1, linux: 2 },
},
});
function args(cmd) {
return execFileSync('/bin/bash', [
'-c',
'pip3(){ printf "%s\\0" "$@"; }; ' + cmd,
])
.toString()
.split('\0')
.slice(0, -1);
}
const req = args(api.getInstallCommand(1, 'project space/requirements.txt'));
assert.equal(req.at(-1), requirement);
assert.equal(req.at(-2), '-r');
assert.ok(req.includes('--prefix=/prefix with spaces'));
assert.equal(
args(api.getInstallCommand(1, 'project space/pyproject.toml')).at(-1),
path.dirname(requirement),
);
const url = 'git+https://example.invalid/repo.git#egg=pkg;$(echo INJECTED)';
assert.equal(args(api.getInstallCommand(1, url)).at(-1), url);
assert.throws(() => api.getInstallCommand(1, '../requirements.txt'));
assert.throws(() => api.getInstallCommand(1, 'requirements-link.txt'));
assert.equal(
args(api.getUninstallCommand(1, 'project space/requirements.txt')).at(-1),
requirement,
);
assert.match(
api.getUninstallCommand(1, url),
/Remove installed Python distributions separately/,
);
});
+70
View File
@@ -0,0 +1,70 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const load = require('../helpers/load-security-module.cjs');
test('requirements install, probe and removal stay in the selected venv', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-source-venv-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const venv = path.join(root, 'virtual env');
const scripts = path.join(root, 'scripts');
fs.mkdirSync(scripts);
execFileSync('python3', ['-m', 'venv', venv]);
const wheel = path.join(scripts, 'ql_venv_fixture-1.0-py3-none-any.whl');
execFileSync('python3', [
'-c',
`import zipfile,sys
with zipfile.ZipFile(sys.argv[1],'w') as z:
z.writestr('ql_venv_fixture.py','value = 42')
z.writestr('ql_venv_fixture-1.0.dist-info/METADATA','Metadata-Version: 2.1\\nName: ql-venv-fixture\\nVersion: 1.0\\n')
z.writestr('ql_venv_fixture-1.0.dist-info/WHEEL','Wheel-Version: 1.0\\nGenerator: fixture\\nRoot-Is-Purelib: true\\nTag: py3-none-any\\n')
z.writestr('ql_venv_fixture-1.0.dist-info/RECORD','')`,
wheel,
]);
fs.writeFileSync(path.join(scripts, 'requirements.txt'), wheel + '\n');
const api = load('back/config/util.ts', {
'./index': {
rootPath: root,
scriptPath: scripts,
repoPath: path.join(root, 'repo'),
},
'./const': {
PYTHON_VENV_DIR: 'virtual env',
PYTHON_INSTALL_DIR: '/unused-global-prefix',
},
'../loaders/logger': {},
'../shared/utils': {},
'../data/dependence': {
DependenceTypes: { nodejs: 0, python3: 1, linux: 2 },
},
});
const run = (command) =>
execFileSync('/bin/bash', ['-c', command], {
encoding: 'utf8',
env: {
...process.env,
PIP_NO_INDEX: '1',
PIP_DISABLE_PIP_VERSION_CHECK: '1',
},
});
const install = api.getInstallCommand(1, 'requirements.txt');
assert.doesNotMatch(install, /--prefix=/);
run(install);
assert.match(run(api.getGetCommand(1, 'ql-venv-fixture')), /1\.0/);
assert.equal(
run(
`'${path.join(
venv,
'bin/python3',
)}' -c 'import ql_venv_fixture; print(ql_venv_fixture.value)'`,
).trim(),
'42',
);
run(api.getUninstallCommand(1, 'requirements.txt'));
assert.throws(() =>
run(`'${path.join(venv, 'bin/python3')}' -c 'import ql_venv_fixture'`),
);
});
+69
View File
@@ -0,0 +1,69 @@
const test = require('node:test'),
assert = require('node:assert/strict'),
fs = require('node:fs'),
os = require('node:os'),
path = require('node:path'),
{ execFileSync } = require('node:child_process');
const load = require('../helpers/load-security-module.cjs');
test('venv installs, uninstalls and probes use the selected Python, including spaces', () => {
const methods = load('back/config/util.ts', {
'./index': { rootPath: '/application' },
'./const': {
PYTHON_VENV_DIR: 'virtual env',
PYTHON_INSTALL_DIR: '/legacy',
},
'../loaders/logger': {},
'../shared/utils': {},
'../data/dependence': {
DependenceTypes: { nodejs: 0, python3: 1, linux: 2 },
},
});
for (const name of [
'getInstallCommand',
'getUninstallCommand',
'getGetCommand',
]) {
const cmd = methods[name](1, 'requests');
assert.match(cmd, /'\/application\/virtual env\/bin\/python3'/);
assert.doesNotMatch(cmd, /--prefix=/);
}
});
test('venv setup keeps Python packages isolated and retains existing startup configuration', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-venv-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const ql = path.join(dir, 'application with spaces');
fs.mkdirSync(ql);
const venv = path.join(dir, 'venv with spaces');
execFileSync('python3', ['-m', 'venv', venv]);
const source = fs.readFileSync('shell/start-simplify.sh', 'utf8');
const snippet = source.slice(
source.indexOf('export PNPM_HOME='),
source.indexOf('\ncd "${QL_DIR}"'),
);
const result = execFileSync(
'/bin/bash',
[
'-c',
snippet +
'\npython3 -c "import sys; print(sys.prefix); print(sys.base_prefix)"',
],
{
env: {
...process.env,
QL_DIR: ql,
QL_DATA_DIR: path.join(dir, 'data'),
PYTHON_VENV_DIR: venv,
PYTHONPATH: '/legacy/modules',
PYTHONUSERBASE: '/legacy',
PATH: process.env.PATH,
},
encoding: 'utf8',
},
)
.trim()
.split('\n');
assert.equal(fs.realpathSync(result[0]), fs.realpathSync(venv));
assert.notEqual(result[1], venv);
assert.match(source, /set -C; cat .env.example > .env/);
assert.doesNotMatch(source, /npm install|pip3 install|chmod 777/);
});
+2 -2
View File
@@ -7,8 +7,8 @@ const { spawnSync } = require('node:child_process');
// Run the actual startup prefix, stopping before service initialization.
const source = fs
.readFileSync('shell/start.sh', 'utf8')
.split('. ${QL_DIR}/shell/share.sh')[0];
.readFileSync('shell/start-simplify.sh', 'utf8')
.split('. "${QL_DIR}/shell/share.sh"')[0];
for (const command of ['start', 'reload']) {
for (const existing of [false, true]) {
test(`${command} ${