Compare commits

..
Author SHA1 Message Date
whyour 26d8a721ce Merge develop into notification audit and retain dependency translations 2026-10-14 10:25:13 +08:00
whyour a3f8856dbe Merge pull request #2888 from whyour/copilot/add-install-methods-module
Add GitHub URL and requirements file support for Python dependencies
2026-10-14 10:21:19 +08:00
whyour 90efa1155e Merge pull request #3009 from jiandanc/develop
feat: 支持 Python venv 隔离环境,适配 Node.js 原生部署
2026-10-14 10:19:50 +08:00
whyour 546b82eed0 Merge pull request #3094 from whyour/dependabot/npm_and_yarn/npm_and_yarn-89554ca2ab
chore(deps): update moment and brace-expansion without runtime migration
2026-10-14 10:18:16 +08:00
26 changed files with 167 additions and 1474 deletions
+11 -38
View File
@@ -716,20 +716,17 @@ function isPyprojectToml(name: string): boolean {
return name.endsWith('pyproject.toml');
}
export function isPythonSourceDependency(name: string): boolean {
const source = name.trim();
return (
isGitHubUrl(source) || isRequirementsFile(source) || isPyprojectToml(source)
);
}
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 (isPythonSourceDependency(trimmedName)) {
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 ""';
@@ -739,30 +736,10 @@ export function getGetCommand(type: DependenceTypes, name: string): string {
const baseCommands = {
[DependenceTypes.nodejs]: `pnpm ls -g | grep "${trimmedName}" | head -1`,
[DependenceTypes.python3]: `${getPythonExecutable()} -c ${shellQuote(
`from importlib.metadata import version, PackageNotFoundError
import importlib.util
import sys
try:
try:
from packaging.requirements import Requirement
except ImportError:
from pip._vendor.packaging.requirements import Requirement
requirement = Requirement(sys.argv[1])
if requirement.marker and not requirement.marker.evaluate():
print('not applicable')
else:
try:
installed = version(requirement.name)
except PackageNotFoundError:
if not requirement.specifier and not requirement.extras and not requirement.url:
if importlib.util.find_spec(requirement.name):
print(requirement.name)
else:
if requirement.specifier.contains(installed, prereleases=True):
print(installed)
except Exception:
pass`,
)} ${shellQuote(trimmedName)}`,
`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}`
@@ -847,13 +824,9 @@ export function getUninstallCommand(
'Remove installed Python distributions separately by package name.',
)}`;
}
const packageName =
name.match(
/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]+\])?(?=\s*(?:[<>=!~;@]|$))/,
)?.[1] || name;
return `${getPythonPip()} uninstall --disable-pip-version-check${
PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore'
} -y -- ${shellQuote(packageName)}`;
} -y -- ${shellQuote(name)}`;
}
const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm remove -g',
@@ -944,7 +917,7 @@ async function _updateLinuxMirror(
throw new Error('Unsupported OS type for updating mirrors.');
const target = mirror || defaults[osType];
normalizeMirror(target);
const S = osType !== 'Alpine' && isInContainer() ? 'sudo ' : '';
const S = isInContainer() ? 'sudo ' : '';
const update =
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
let file =
+12 -35
View File
@@ -20,7 +20,6 @@ import {
getInstallCommand,
getUninstallCommand,
getGetCommand,
isPythonSourceDependency,
} from '../config/util';
import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
@@ -77,25 +76,21 @@ export default class DependenceService {
for (const doc of docs) {
taskLimit.removeQueuedDependency(doc);
}
const unInstalledDeps = docs.filter(
(x) => x.status !== DependenceStatus.installed,
);
const installedDeps = docs.filter(
(x) =>
x.status === DependenceStatus.installed ||
// Older list refreshes marked unprobeable sources as failed. They may
// still have installed distributions (including partial pip installs).
(x.status === DependenceStatus.installFailed &&
x.type === DependenceTypes.python3 &&
isPythonSourceDependency(x.name)),
(x) => x.status === DependenceStatus.installed,
);
const unInstalledDeps = docs.filter((x) => !installedDeps.includes(x));
await this.removeDb(unInstalledDeps.map((x) => x.id!));
if (installedDeps.length) {
await DependenceModel.update(
{ status: DependenceStatus.queued, log: [] },
{ where: { id: installedDeps.map((x) => x.id!) } },
{ where: { id: ids } },
);
this.installDependenceOneByOne(installedDeps, false, force);
this.installDependenceOneByOne(docs, false, force);
}
return docs;
}
@@ -161,15 +156,9 @@ export default class DependenceService {
},
});
const checks = await concurrentRun(
docs
.filter(
(doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
)
.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
docs.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
5,
);
const missingIds = (checks || []).filter(
@@ -188,10 +177,7 @@ export default class DependenceService {
let depName = dependency.name.trim();
const depVersionStr = versionDependenceCommandTypes[dependency.type];
let depVersion = '';
if (
dependency.type !== DependenceTypes.python3 &&
depName.includes(depVersionStr)
) {
if (depName.includes(depVersionStr)) {
const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
);
@@ -319,9 +305,6 @@ export default class DependenceService {
: getUninstallCommand(dependency.type, dependency.name.trim());
} catch (error) {
taskLimit.removeQueuedDependency(dependency);
if (!isInstall && force) {
return this.removeDb([dependency.id!]).then(() => null);
}
return DependenceModel.update(
{
status: isInstall
@@ -404,13 +387,7 @@ export default class DependenceService {
this.updateLog(depIds, message);
// 判断是否已经安装过依赖
// pip must resolve optional dependencies even if the base distribution
// is already present; its version alone cannot prove extras are ready.
if (
isInstall &&
!force &&
!(isPythonDependence && depName.includes('['))
) {
if (isInstall && !force) {
let getCommand = getGetCommand(dependency.type, depName);
const depVersionStr = versionDependenceCommandTypes[dependency.type];
if (isLinuxDependence) {
@@ -418,7 +395,7 @@ export default class DependenceService {
}
let depVersion = '';
if (!isPythonDependence && depName.includes(depVersionStr)) {
if (depName.includes(depVersionStr)) {
const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
);
+2 -2
View File
@@ -119,12 +119,12 @@ reload_pm2
if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 3. 启动 bot..."
nohup ql bot >"$dir_log/bot.log" 2>&1 &
nohup ql bot >$dir_log/bot.log 2>&1 &
fi
if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
nohup ql extra >"$dir_log/extra.log" 2>&1 &
nohup ql extra >$dir_log/extra.log 2>&1 &
fi
log_with_style "SUCCESS" "🎉 容器启动成功!"
+3 -3
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env bash
create_token() {
local token_command="ts-node-transpile-only ${dir_root}/back/token.ts"
local token_file="${dir_root}/static/build/token.js"
if [[ -f $token_file ]]; then
__ql_token__=$(node "$token_file")
else
__ql_token__=$(ts-node-transpile-only "${dir_root}/back/token.ts")
token_command="node ${token_file}"
fi
__ql_token__=$(eval "$token_command")
}
get_token() {
+28 -30
View File
@@ -39,16 +39,16 @@ random_delay() {
gen_array_scripts() {
local dir_current=$(pwd)
local i="-1"
cd "$dir_scripts" || return $?
for file in *; do
cd $dir_scripts
for file in $(ls); do
if [[ -f $file ]] && [[ $file == *.js && $file != sendNotify.js ]]; then
let i++
array_scripts[i]=$file
array_scripts_name[i]=$(grep -- "new Env" "$file" | awk -F '[(]' '{print $2}' | awk -F '[)]' '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1)
array_scripts[i]=$(echo "$file" | perl -pe "s|$dir_scripts/||g")
array_scripts_name[i]=$(grep "new Env" $file | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1)
[[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="<未识别出活动名称>"
fi
done
cd "$dir_current" || return $?
cd $dir_current
}
## 使用说明
@@ -72,11 +72,11 @@ usage() {
## run nohup,$1:文件名,不含路径,带后缀
run_nohup() {
local file_name=$1
nohup node "$file_name" &>"$log_path" &
nohup node $file_name &>$log_path &
}
env_str_to_array() {
. "$file_env"
. $file_env
local IFS="&"
read -ra array <<<"${!env_param}"
array_length=${#array[@]}
@@ -122,7 +122,7 @@ enter_script_workdir() {
_target_dir="${dir_scripts}/${work_dir}"
fi
if [[ -d "${_target_dir}" ]]; then
cd "${_target_dir}" || return $?
cd "${_target_dir}"
if [[ ${file_param} =~ "/" ]]; then
local script_name="${file_param##*/}"
if [[ "${use_dot_prefix}" == "true" ]]; then
@@ -136,13 +136,13 @@ enter_script_workdir() {
t '警告:工作目录不存在 %s' "${_target_dir}"
fi
cd "$dir_scripts" || return $?
cd $dir_scripts
if [[ ${file_param} =~ "/" ]]; then
local script_dir="${file_param%/*}"
local script_name="${file_param##*/}"
if [[ -d ${script_dir} ]]; then
cd "$script_dir" || return $?
cd ${script_dir}
if [[ "${use_dot_prefix}" == "true" ]]; then
file_param="./${script_name}"
else
@@ -159,12 +159,12 @@ run_normal() {
random_delay "$file_param"
fi
enter_script_workdir || return $?
enter_script_workdir
if [[ $isJsOrPythonFile == 'false' ]]; then
clear_non_sh_env
fi
run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}"
run_task_command $which_program $file_param "${script_params[@]}"
}
handle_env_split() {
@@ -175,7 +175,7 @@ handle_env_split() {
env_str_to_array
local tempArr=$(echo $num_param | sed "s/-max/-${array_length}/g" | sed "s/max-/${array_length}-/g" | perl -pe "s|(\d+)(-\|~\|_)(\d+)|{\1..\3}|g")
local runArr=($(eval echo $tempArr))
array_run=($(awk -v RS=' ' '!a[$1]++' <<<"${runArr[*]}"))
array_run=($(awk -v RS=' ' '!a[$1]++' <<<${runArr[@]}))
}
## 并发执行时,设定的 RandomDelay 不会生效,即所有任务立即执行
@@ -192,10 +192,10 @@ run_concurrent() {
time=$(date "+$mtime_format")
single_log_time=$(format_log_time "$mtime_format" "$time")
enter_script_workdir || return $?
enter_script_workdir
local j=0
for i in "${array_run[@]}"; do
for i in ${array_run[@]}; do
single_log_path="$dir_log/$log_dir/${single_log_time}_$((j + 1)).log"
let j++
@@ -203,16 +203,16 @@ run_concurrent() {
export "${env_param}=${array[$i - 1]}"
clear_non_sh_env
fi
envParam="${env_param}" numParam="${i}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}" &>"$single_log_path" &
eval envParam="${env_param}" numParam="${i}" run_task_command $which_program $file_param "${script_params[@]}" &>$single_log_path &
done
wait
local k=0
for i in "${array_run[@]}"; do
for i in ${array_run[@]}; do
single_log_path="$dir_log/$log_dir/${single_log_time}_$((k + 1)).log"
let k++
cat "$single_log_path"
[[ -f $single_log_path ]] && rm -f "$single_log_path"
cat $single_log_path
[[ -f $single_log_path ]] && rm -f $single_log_path
done
}
@@ -229,7 +229,7 @@ run_designated() {
if [[ $isJsOrPythonFile == 'false' ]]; then
local n=0
for i in "${array_run[@]}"; do
for i in ${array_run[@]}; do
array_str[n]=${array[$i - 1]}
let n++
done
@@ -241,9 +241,9 @@ run_designated() {
clear_non_sh_env
fi
enter_script_workdir || return $?
enter_script_workdir
envParam="${env_param}" numParam="${num_param}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}"
envParam="${env_param}" numParam="${num_param}" run_task_command $which_program $file_param "${script_params[@]}"
}
## 运行其他命令
@@ -273,7 +273,7 @@ run_else() {
if [[ -n "$script_for_dir" ]]; then
local saved_file_param="$file_param"
file_param="$script_for_dir"
enter_script_workdir true || return $?
enter_script_workdir true
local adjusted_script="$file_param"
file_param="$saved_file_param"
@@ -289,17 +289,17 @@ run_else() {
set -- "${new_args[@]}"
else
# 没有找到脚本参数,只 cd 到 scripts 目录
enter_script_workdir true || return $?
enter_script_workdir true
shift
fi
else
# file_param 本身就是脚本,直接用 enter_script_workdir 处理
enter_script_workdir true || return $?
enter_script_workdir true
shift
fi
clear_non_sh_env
run_task_command ${which_program:+"$which_program"} "$file_param" "$@"
run_task_command $which_program $file_param "$@"
}
check_file() {
@@ -313,14 +313,12 @@ check_file() {
export PREV_NODE_OPTIONS="${NODE_OPTIONS:=}"
export PREV_PYTHONPATH="${PYTHONPATH:=}"
if [[ $1 == *.js ]] || [[ $1 == *.ts ]] || [[ $1 == *.mjs ]]; then
local preload_path=${file_preload_js//\\/\\\\}
preload_path=${preload_path//\"/\\\"}
export NODE_OPTIONS="-r \"${preload_path}\" ${NODE_OPTIONS}"
export NODE_OPTIONS="-r ${file_preload_js} ${NODE_OPTIONS}"
else
export PYTHONPATH="${dir_preload}:${dir_config}:${PYTHONPATH}"
fi
else
. "$file_env"
. $file_env
fi
fi
}
+2 -8
View File
@@ -1,12 +1,4 @@
#!/usr/bin/env bash
# BSD grep accepts \d as a digit; GNU grep treats it as a literal d.
string=$(awk '$1 == "version:" { print $2; exit }' version.yaml) || exit 1
if [[ ! $string =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[[:alnum:].-]+)?(\+[[:alnum:].-]+)?$ ]]; then
printf 'Invalid release version: %s\n' "$string" >&2
exit 1
fi
version="v$string"
echo -e "开始发布"
echo -e "切换master分支"
@@ -17,6 +9,8 @@ git push --set-upstream origin master -f
echo -e "更新cdn文件"
ts-node-transpile-only sample/tool.ts
string=$(cat version.yaml | grep "version" | egrep "[^ ]*" -o | egrep "\d\.*")
version="v$string"
echo -e "当前版本$version"
echo -e "删除已经存在的本地tag"
+16 -21
View File
@@ -3,47 +3,42 @@
days=$1
remove_js_log() {
local log log_date log_timestamp diff_time log_path encoded_path result
local now
now=$(date +%s) || return $?
while IFS= read -r -d '' log; do
log_date=${log##*/}
log_date=${log_date:0:10}
local log_full_path_list=$(find $dir_log -name "*.log")
local diff_time
for log in $log_full_path_list; do
local log_date=$(echo $log | awk -F "/" '{print $NF}' | cut -c1-10)
if ! [[ $log_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
if [[ $is_macos -eq 1 ]]; then
log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log") || continue
log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log")
else
log_date=$(stat -c %y "$log" | cut -d ' ' -f 1)
fi
fi
if [[ $is_macos -eq 1 ]]; then
# BSD date otherwise fills the omitted time from the current clock.
log_timestamp=$(date -j -f "%Y-%m-%d %H:%M:%S" "$log_date 00:00:00" +%s) || continue
diff_time=$(($(date +%s) - $(date -j -f "%Y-%m-%d" "$log_date" +%s)))
else
log_timestamp=$(date +%s -d "$log_date") || continue
diff_time=$(($(date +%s) - $(date +%s -d "$log_date")))
fi
diff_time=$((now - log_timestamp))
if [[ $diff_time -gt $((${days} * 86400)) ]]; then
log_path=${log#"$dir_log"/}
encoded_path=$(printf '%s' "$log_path" | jq -sRr @uri) || continue
result=$(find_cron_api "log_path=$encoded_path")
local log_path=$(echo "$log" | sed "s,${dir_log}/,,g")
local result=$(find_cron_api "log_path=$log_path")
t '查询文件 %s' "$log_path"
if [[ -z $result ]]; then
t '删除中~'
rm -vf -- "$log"
rm -vf $log
else
t '正在被 %s 使用,跳过~' "$result"
fi
fi
done < <(find "$dir_log" -type f -name "*.log" -print0)
done
}
remove_empty_dir() {
local dir
for dir in "$dir_log"/*/ "$dir_log"/.[!.]*/ "$dir_log"/..?*/; do
[[ -d "$dir" ]] || continue
# rmdir also protects a directory populated between discovery and removal.
rmdir -- "$dir" 2>/dev/null || true
cd $dir_log
for dir in $(ls); do
if [[ -d $dir ]] && [[ -z $(ls $dir) ]]; then
rm -rf $dir
fi
done
}
+41 -45
View File
@@ -77,19 +77,16 @@ load_ql_envs() {
}
import_config() {
[[ -f $file_config_user ]] && . "$file_config_user"
[[ -f $dir_preload/lang_env.sh ]] && . "$dir_preload/lang_env.sh"
[[ -f $file_config_user ]] && . $file_config_user
[[ -f $dir_preload/lang_env.sh ]] && . $dir_preload/lang_env.sh
# Language packs need global associative arrays (Bash 4.2+). Probe the
# capability so Bash 3.2 and 4.0/4.1 retain the Chinese-key fallback.
# 加载语言包(bash 4+ 支持 declare -A,不兼容时回退输出中文 key)
local lang=${QL_LANG:-zh}
local lang_file="$dir_shell/lang/${lang}.sh"
if (declare -gA __ql_lang_probe) 2>/dev/null; then
if [[ -f $lang_file ]]; then
. "$lang_file"
elif [[ -f "$dir_shell/lang/zh.sh" ]]; then
. "$dir_shell/lang/zh.sh"
fi
if [[ ${BASH_VERSINFO[0]} -ge 4 ]] && [[ -f $lang_file ]]; then
. $lang_file
elif [[ ${BASH_VERSINFO[0]} -ge 4 ]]; then
. "$dir_shell/lang/zh.sh"
fi
load_ql_envs
@@ -135,7 +132,7 @@ unset_proxy() {
make_dir() {
local dir=$1
if [[ ! -d $dir ]]; then
mkdir -p -- "$dir"
mkdir -p $dir
fi
}
@@ -179,57 +176,57 @@ define_cmd() {
}
fix_config() {
make_dir "$dir_tmp"
make_dir "$dir_static"
make_dir "$dir_data"
make_dir "$dir_config"
make_dir "$dir_log"
make_dir "$dir_db"
make_dir "$dir_scripts"
make_dir "$dir_list_tmp"
make_dir "$dir_repo"
make_dir "$dir_raw"
make_dir "$dir_update_log"
make_dir "$dir_dep"
make_dir $dir_tmp
make_dir $dir_static
make_dir $dir_data
make_dir $dir_config
make_dir $dir_log
make_dir $dir_db
make_dir $dir_scripts
make_dir $dir_list_tmp
make_dir $dir_repo
make_dir $dir_raw
make_dir $dir_update_log
make_dir $dir_dep
if [[ ! -s $file_config_user ]]; then
cp -f "$file_config_sample" "$file_config_user"
cp -f $file_config_sample $file_config_user
fi
if [[ ! -f $file_task_before ]]; then
cp -f "$file_task_sample" "$file_task_before"
cp -f $file_task_sample $file_task_before
fi
if [[ ! -f $file_task_after ]]; then
cp -f "$file_task_sample" "$file_task_after"
cp -f $file_task_sample $file_task_after
fi
if [[ ! -f $file_extra_shell ]]; then
cp -f "$file_extra_sample" "$file_extra_shell"
cp -f $file_extra_sample $file_extra_shell
fi
if [[ ! -s $file_notify_py ]]; then
cp -f "$file_notify_py_sample" "$file_notify_py"
cp -f $file_notify_py_sample $file_notify_py
fi
if [[ ! -s $file_notify_js ]]; then
cp -f "$file_notify_js_sample" "$file_notify_js"
cp -f $file_notify_js_sample $file_notify_js
fi
if [[ ! -s $file_test_js ]]; then
cp -f "$file_test_js_sample" "$file_test_js"
cp -f $file_test_js_sample $file_test_js
fi
if [[ ! -s $file_test_py ]]; then
cp -f "$file_test_py_sample" "$file_test_py"
cp -f $file_test_py_sample $file_test_py
fi
if [[ ! -s $dep_notify_js ]]; then
cp -f "$file_notify_js_sample" "$dep_notify_js"
cp -f $file_notify_js_sample $dep_notify_js
fi
if [[ ! -s $dep_notify_py ]]; then
cp -f "$file_notify_py_sample" "$dep_notify_py"
cp -f $file_notify_py_sample $dep_notify_py
fi
}
@@ -249,17 +246,17 @@ npm_install_2() {
local dir_current=$(pwd)
local dir_work=$1
cd "$dir_work" || return $?
cd $dir_work
t '安装 %s 依赖包...\n' "$dir_work"
npm_install_sub
cd "$dir_current" || return $?
cd $dir_current
}
diff_and_copy() {
local copy_source=$1
local copy_to=$2
if [[ ! -s $copy_to ]] || [[ $(diff "$copy_source" "$copy_to") ]]; then
cp -f "$copy_source" "$copy_to"
if [[ ! -s $copy_to ]] || [[ $(diff $copy_source $copy_to) ]]; then
cp -f $copy_source $copy_to
fi
}
@@ -268,13 +265,12 @@ git_clone_scripts() {
local dir="$2"
local branch="$3"
local proxy="$4"
local branch_args=()
[[ $branch ]] && branch_args=(-b "$branch")
[[ $branch ]] && local part_cmd="-b $branch "
t '开始拉取仓库 %s 到 %s\n' "${uniq_path}" "$dir"
set_proxy "$proxy"
git clone -q --depth=1 "${branch_args[@]}" "$url" "$dir"
git clone -q --depth=1 $part_cmd $url $dir
exit_status=$?
unset_proxy
@@ -287,7 +283,7 @@ random_range() {
}
delete_pm2() {
cd "$dir_root" || return $?
cd $dir_root
# Try to delete PM2 processes, but don't fail if PM2 is not available
pm2 delete ecosystem.config.js 2>/dev/null || true
# Also try to kill any directly spawned node processes
@@ -295,7 +291,7 @@ delete_pm2() {
}
reload_pm2() {
cd "$dir_root" || return $?
cd $dir_root
restore_env_vars
# Try to start PM2, but handle failures gracefully
@@ -314,7 +310,7 @@ reload_pm2() {
if [[ "$QL_CONTAINER" == "true" ]]; then
nohup node static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
else
nohup node static/build/app.js >"$dir_log/qinglong.log" 2>&1 &
nohup node static/build/app.js >$dir_log/qinglong.log 2>&1 &
fi
local node_pid=$!
@@ -402,7 +398,7 @@ handle_task_start() {
}
run_task_before() {
. "$file_task_before" "$@"
. $file_task_before "$@"
if [[ ${task_before:=} ]]; then
t '执行前置命令\n'
@@ -412,7 +408,7 @@ run_task_before() {
}
run_task_after() {
. "$file_task_after" "$@"
. $file_task_after "$@"
if [[ ${task_after:=} ]]; then
t '\n执行后置命令\n'
+2 -2
View File
@@ -63,12 +63,12 @@ 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 &
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 &
nohup ql extra >$dir_log/extra.log 2>&1 &
fi
pm2 startup
+11 -30
View File
@@ -4,46 +4,31 @@
set -e
set -x
# Configuration and language helpers are not loaded during bootstrap errors.
startup_message() {
local format="$1"
shift
printf -- "$format\n" "$@" >&2
}
if [[ ! $QL_DIR ]]; then
npm_dir=$(npm root -g 2>/dev/null) || npm_dir=""
pnpm_dir=""
if command -v pnpm >/dev/null 2>&1; then
pnpm_dir=$(pnpm root -g 2>/dev/null) || pnpm_dir=""
fi
if [[ -n "$npm_dir" && -d "$npm_dir/@whyour/qinglong" ]]; then
npm_dir=$(npm root -g)
pnpm_dir=$(pnpm root -g)
if [[ -d "$npm_dir/@whyour/qinglong" ]]; then
QL_DIR="$npm_dir/@whyour/qinglong"
elif [[ -n "$pnpm_dir" && -d "$pnpm_dir/@whyour/qinglong" ]]; then
elif [[ -d "$pnpm_dir/@whyour/qinglong" ]]; then
QL_DIR="$pnpm_dir/@whyour/qinglong"
else
startup_message '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装'
t '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装'
fi
if [[ $QL_DIR ]]; then
startup_message '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR"
t '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR"
fi
exit 1
fi
if [[ $QL_DIR != /* ]]; then
startup_message 'QL_DIR 必须为绝对路径,例如 /ql'
exit 1
fi
if [[ ! $QL_DATA_DIR ]]; then
startup_message '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量'
t '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量'
exit 1
fi
if [[ $QL_DATA_DIR != /*/data ]]; then
startup_message 'QL_DATA_DIR 必须为以 /data 结尾的绝对路径,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中'
if [[ $QL_DATA_DIR != */data ]]; then
t 'QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中'
exit 1
fi
@@ -53,11 +38,7 @@ if [[ $command != "reload" ]]; then
# 安装依赖
os_name="${QL_OS_TYPE:-}"
if [ -z "$os_name" ]; then
if [[ -r /etc/os-release ]]; then
os_name=$(source /etc/os-release && echo "$ID")
else
os_name=$(uname -s)
fi
os_name=$(source /etc/os-release && echo "$ID")
fi
# 非 root 用户使用 sudo
@@ -88,7 +69,7 @@ if [[ $command != "reload" ]]; then
$SUDO apt-get install -y git curl wget tzdata perl openssl jq nginx procps netcat-openbsd openssh-client
;;
*)
startup_message '暂不支持此系统部署 %s' "$os_name"
t '暂不支持此系统部署 %s' "$os_name"
exit 1
;;
esac
+10 -22
View File
@@ -36,31 +36,19 @@ run_task_command() {
) &
timeout_watcher=$!
set +m
finish_task_timeout() {
local code="$1"
trap - EXIT
{
# Bash can report a killed job between any two cleanup commands.
# Keep redirection active until the watchdog is fully reaped.
kill -KILL -- "-$timeout_watcher" || :
wait "$timeout_watcher" || :
} 2>/dev/null
trap 'kill -KILL -- "-$timeout_watcher" 2>/dev/null || :
if kill -TERM -- "-$timeout_worker" 2>/dev/null; then sleep 0.1; fi
kill -KILL -- "-$timeout_worker" 2>/dev/null || :
wait "$timeout_worker" 2>/dev/null || :
rm -rf -- "$timeout_dir"
exit "$code"
}
# Older Bash can omit a subshell's EXIT trap in sourced pipelines. Call
# cleanup explicitly on completion/signals, retaining EXIT as a fallback.
trap 'finish_task_timeout "$?"' EXIT
trap 'finish_task_timeout 130' INT
trap 'finish_task_timeout 143' TERM
trap 'finish_task_timeout 129' HUP
trap 'finish_task_timeout 131' QUIT
wait "$timeout_watcher" 2>/dev/null || :
rm -rf -- "$timeout_dir"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
trap 'exit 131' QUIT
# Do not clean up a timer whose process group has not initialized yet.
while [[ ! -f "$timeout_dir/ready" ]]; do
kill -0 "$timeout_watcher" 2>/dev/null || finish_task_timeout 125
kill -0 "$timeout_watcher" 2>/dev/null || exit 125
sleep 0.001
done
wait "$timeout_worker"
@@ -68,8 +56,8 @@ run_task_command() {
if [[ -f "$timeout_dir/expired" ]]; then
# Let the timer finish escalation even if the task leader exited first.
wait "$timeout_watcher" 2>/dev/null || :
finish_task_timeout 124
exit 124
fi
finish_task_timeout "$timeout_code"
exit "$timeout_code"
)
}
+7 -7
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
dir_shell=$QL_DIR/shell
. "$dir_shell/share.sh"
. "$dir_shell/api.sh"
. "$dir_shell/task-timeout.sh"
. $dir_shell/share.sh
. $dir_shell/api.sh
. $dir_shell/task-timeout.sh
trap 'single_hanle SIGINT' INT
trap 'single_hanle SIGTERM' TERM
@@ -48,7 +48,7 @@ handle_log_path() {
fi
if [[ -z ${ID:=} ]]; then
ID=$(cat "$list_crontab_user" | grep -E "$cmd_task.* $file_param" | perl -pe "s|.*ID=(.*) $cmd_task.* $file_param\.*|\1|" | head -1 | awk -F " " '{print $1}')
ID=$(cat $list_crontab_user | grep -E "$cmd_task.* $file_param" | perl -pe "s|.*ID=(.*) $cmd_task.* $file_param\.*|\1|" | head -1 | awk -F " " '{print $1}')
fi
local suffix=""
if [[ ! -z $ID ]]; then
@@ -89,10 +89,10 @@ handle_log_path() {
log_path="$real_log_path"
fi
printf -v cmd '2>&1 | tee -a %q' "$dir_log/$log_path"
cmd="2>&1 | tee -a $dir_log/$log_path"
make_dir "$dir_log/$log_dir"
if [[ "${no_tee:=}" == "true" ]]; then
printf -v cmd '>> %q 2>&1' "$dir_log/$log_path"
cmd=">> $dir_log/$log_path 2>&1"
fi
if [[ "${real_time:=}" == "true" ]]; then
@@ -162,5 +162,5 @@ define_program "${task_shell_params[@]}"
handle_log_path "${task_shell_params[@]}"
init_begin_time
eval '. "$dir_shell/otask.sh"' "$cmd"
eval . $dir_shell/otask.sh "$cmd"
exit 0
+17 -23
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
dir_shell=$QL_DIR/shell
. "$dir_shell/share.sh"
. "$dir_shell/api.sh"
. $dir_shell/share.sh
. $dir_shell/api.sh
load_ql_envs
. "$dir_shell/env.sh"
. $dir_shell/env.sh
send_mark=$dir_shell/send_mark
@@ -165,20 +165,17 @@ update_raw() {
t '开始下载:%s 保存路径:%s\n' "${raw_url}" "$dir_raw/${raw_file_name}"
set_proxy "$proxy"
if wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" "$raw_url"; then
exit_status=0
else
exit_status=$?
fi
wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" ${raw_url}
exit_status=$?
unset_proxy
if [[ $exit_status -eq 0 ]]; then
mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}" || return $?
if [[ $? -eq 0 ]]; then
mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}"
t '下载 %s 成功...\n' "${raw_file_name}"
cd "$dir_raw" || return $?
cd $dir_raw
local filename="raw_${raw_file_name}"
local cron_id=$(cat "$list_crontab_user" | grep -E "$cmd_task.* $filename" | perl -pe "s|.*ID=(.*) $cmd_task.* $filename\.*|\1|" | head -1 | awk -F " " '{print $1}')
cp -f "$raw_file_name" "$dir_scripts/${filename}" || return $?
local cron_id=$(cat $list_crontab_user | grep -E "$cmd_task.* $filename" | perl -pe "s|.*ID=(.*) $cmd_task.* $filename\.*|\1|" | head -1 | awk -F " " '{print $1}')
cp -f $raw_file_name $dir_scripts/${filename}
if [[ -z $cron_id ]] && [[ ${autoAddCron} == true ]]; then
cron_line=$(
perl -ne "{
@@ -205,7 +202,6 @@ update_raw() {
else
t '下载 %s 失败,保留之前正常下载的版本...\n' "${raw_file_name}"
[[ -f "$dir_raw/${raw_file_name}.new" ]] && rm -f "$dir_raw/${raw_file_name}.new"
return "$exit_status"
fi
}
@@ -213,7 +209,7 @@ update_raw() {
## 调用用户自定义的extra.sh
run_extra_shell() {
if [[ -f $file_extra_shell ]]; then
. "$file_extra_shell"
. $file_extra_shell
else
t '%s文件不存在,跳过执行...\n' "$file_extra_shell"
fi
@@ -270,8 +266,7 @@ show_service_logs() {
# Winston writes the active runtime log by date. Follow the filename so
# size-based rotations are handled without involving the PM2 log files.
local current_system_log="$system_log_dir/$(date +%F).log"
# GNU and BSD tail both support -F; --retry is GNU-only and implied by -F.
tail -n 0 -F "$current_system_log"
tail -n 0 --retry -F "$current_system_log"
}
## 脚本用法
@@ -489,9 +484,9 @@ main() {
local log_path="${log_dir}/${log_time}.log"
local file_path="$dir_log/$log_path"
printf -v cmd '2>&1 | tee -a %q' "$file_path"
cmd="2>&1 | tee -a $file_path"
if [[ "$no_tee" == "true" ]]; then
printf -v cmd '>> %q 2>&1' "$file_path"
cmd=">> $file_path 2>&1"
fi
if [[ "$real_time" == "true" ]]; then
cmd=""
@@ -537,20 +532,19 @@ main() {
get_uniq_path "$p2"
if [[ -n $p2 ]]; then
update_raw "$p2" "$p3" "$p4" "$p5"
command_status=$?
else
t '命令输入错误...\n'
eval usage $cmd
fi
;;
rmlog)
eval '. "$dir_shell/rmlog.sh" "$p2"' "$cmd"
eval . $dir_shell/rmlog.sh "$p2" $cmd
;;
bot)
eval '. "$dir_shell/bot.sh"' "$cmd"
eval . $dir_shell/bot.sh $cmd
;;
check)
eval '. "$dir_shell/check.sh"' "$cmd"
eval . $dir_shell/check.sh $cmd
;;
resetlet)
eval update_auth_config "\\\"retries\\\":0" "重置登录错误次数" $cmd
-2
View File
@@ -700,8 +700,6 @@
"已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.",
"保存失败": "Unable to save",
"通知日志": "Notification log",
"标题": "Title",
"内容": "Content",
"发送时间": "Sent at",
"推送渠道": "Channel",
"发送状态": "Delivery status",
-2
View File
@@ -700,8 +700,6 @@
"已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间",
"保存失败": "保存失败",
"通知日志": "通知日志",
"标题": "标题",
"内容": "内容",
"发送时间": "发送时间",
"推送渠道": "推送渠道",
"发送状态": "发送状态",
+4 -2
View File
@@ -4,7 +4,6 @@ import { SharedContext } from '@/layouts';
import { depthFirstSearch, findNode, getEditorMode } from '@/utils';
import config from '@/utils/config';
import { request } from '@/utils/http';
import { getCodeMirrorExtensions } from '@/utils/codemirror/language';
import { canPreviewInMonaco } from '@/utils/monaco';
import {
CloudDownloadOutlined,
@@ -16,6 +15,7 @@ import {
} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-layout';
import Editor from '@monaco-editor/react';
import { langs } from '@uiw/codemirror-extensions-langs';
import CodeMirror from '@uiw/react-codemirror';
import { history, useOutletContext } from '@umijs/max';
import {
@@ -747,7 +747,9 @@ const Script = () => {
{isPhone && (
<CodeMirror
value={value}
extensions={getCodeMirrorExtensions(mode)}
extensions={
mode ? [langs[mode as keyof typeof langs]()] : undefined
}
theme={theme.includes('dark') ? 'dark' : 'light'}
readOnly={!isEditing || saving}
onChange={(value) => {
-11
View File
@@ -1,11 +0,0 @@
import { langs } from '@uiw/codemirror-extensions-langs';
export function getCodeMirrorExtensions(mode: string) {
// Monaco calls this language "ini"; CodeMirror exposes it as "properties".
const language = mode === 'ini' ? 'properties' : mode;
if (!Object.prototype.hasOwnProperty.call(langs, language)) {
return [];
}
const factory = langs[language as keyof typeof langs];
return typeof factory === 'function' ? [factory()] : [];
}
@@ -61,7 +61,6 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
fileExist: async () => false,
getGetCommand: (_type, name) => `check:${name}`,
getInstallCommand: () => '',
isPythonSourceDependency: () => false,
getPid: async () => 0,
getUninstallCommand: () => '',
killTask: async () => {},
-238
View File
@@ -1,238 +0,0 @@
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');
const types = { nodejs: 0, python3: 1, linux: 2 };
const statuses = { installed: 1, installFailed: 2, queued: 6, cancelled: 7 };
const util = load('back/config/util.ts', {
'./index': {},
'./const': {},
'../loaders/logger': {},
'../shared/utils': {},
'../data/dependence': { DependenceTypes: types },
});
function serviceFor(docs) {
const probes = [],
removed = [],
uninstalls = [];
const Service = load('back/services/dependence.ts', {
'../config': {},
'../data/dependence': {
Dependence: class {},
DependenceTypes: types,
DependenceStatus: statuses,
versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' },
DependenceModel: {
findAll: async ({ where }) =>
docs.filter(
(d) =>
(where.status === undefined || d.status === where.status) &&
(!where.id || where.id.includes(d.id)),
),
update: async (values, { where }) => {
docs
.filter((d) => where.id.includes(d.id))
.forEach((d) => Object.assign(d, values));
},
destroy: async ({ where }) => removed.push(...where.id),
},
},
'../config/util': {
...util,
concurrentRun: (tasks) => Promise.all(tasks.map((fn) => fn())),
getGetCommand: (type, name) => {
probes.push(name);
return util.getGetCommand(type, name);
},
promiseExecSuccess: async () => '',
},
'../shared/pLimit': { removeQueuedDependency() {} },
'./sock': class {},
'../shared/i18n': {},
}).default;
const service = new Service({}, {});
service.installDependenceOneByOne = (dependencies, install, force) => {
uninstalls.push({ ids: dependencies.map((d) => d.id), install, force });
return Promise.resolve();
};
return { service, probes, removed, uninstalls };
}
test('listing preserves Python source results and still detects missing named packages', async () => {
const docs = [
' requirements with spaces/requirements.txt ',
'requirements-dev.in',
'project/pyproject.toml',
'git+https://example.invalid/repo.git',
'https://example.invalid/pkg.whl',
'missing-package',
].map((name, i) => ({ id: i + 1, name, type: 1, status: 1 }));
const { service, probes } = serviceFor(docs);
for (let i = 0; i < 2; i++) {
await service.dependencies({
searchValue: '',
type: 'python3',
status: '',
});
}
assert.deepEqual(
docs.map((d) => d.status),
[1, 1, 1, 1, 1, 2],
);
assert.deepEqual(probes, ['missing-package']);
// Source installs must continue to execute pip rather than treating the source as a package name.
assert.equal(util.getGetCommand(1, docs[0].name), 'echo ""');
});
test('deletion cleans previously misclassified Python sources and only queues intended records', async () => {
const docs = [
{ id: 1, name: 'requirements.txt', type: 1, status: 2 },
{ id: 2, name: 'present-package', type: 0, status: 1 },
{ id: 3, name: 'missing-package', type: 0, status: 2 },
{ id: 4, name: 'queued/requirements.txt', type: 1, status: 6 },
{ id: 5, name: 'cancelled/requirements.txt', type: 1, status: 7 },
{ id: 6, name: 'git+https://example.invalid/repo.git', type: 1, status: 2 },
];
const { service, removed, uninstalls } = serviceFor(docs);
await service.remove(docs.map((d) => d.id));
assert.deepEqual(removed, [3, 4, 5]);
assert.deepEqual(uninstalls, [
{ ids: [1, 2, 6], install: false, force: false },
]);
assert.equal(docs[2].status, 2);
});
test('Alpine container mirror command runs without sudo and writes valid repositories', async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-alpine-mirror-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const source =
'https://dl-cdn.alpinelinux.org/alpine/v3.23/main\nhttps://dl-cdn.alpinelinux.org/alpine/v3.23/community\n';
const api = load('back/config/util.ts', {
'fs/promises': { readFile: async () => source },
'./index': {},
'./const': {},
'./share': {},
'./container': { isInContainer: () => true },
'../loaders/logger': {},
'../shared/utils': {},
'../shared/fileAccess': {},
'../data/dependence': {},
});
const previous = process.env.QL_OS_TYPE;
let command;
try {
process.env.QL_OS_TYPE = 'alpine';
command = await api.updateLinuxMirrorFile('https://mirror.example/alpine');
} finally {
if (previous === undefined) delete process.env.QL_OS_TYPE;
else process.env.QL_OS_TYPE = previous;
}
// Redirect the generated command's paths and executable into the fixture only.
const file = path.join(root, 'repositories');
const update = path.join(root, 'updated');
const sandbox = command
.replaceAll('/etc/apk/repositories', file)
.replaceAll('/etc/apk', root);
execFileSync('/bin/bash', [
'-c',
`sudo(){ return 127; }; apk(){ test "$1" = update && touch '${update}'; }; ${sandbox}`,
]);
assert.ok(fs.existsSync(update));
assert.equal(
fs.readFileSync(file, 'utf8'),
source.replaceAll(
'https://dl-cdn.alpinelinux.org/alpine',
'https://mirror.example/alpine',
),
);
});
test('force deletion removes a source record even when its requirements file is gone', async () => {
const removed = [],
updates = [];
const Service = load('back/services/dependence.ts', {
'../config': {},
'../config/util': {
getUninstallCommand() {
throw Error('Python dependency file must be inside scripts or repo');
},
},
'../data/dependence': {
Dependence: class {},
DependenceTypes: types,
DependenceStatus: { ...statuses, removeFailed: 5 },
DependenceModel: {
destroy: async ({ where }) => removed.push(...where.id),
update: async (values) => updates.push(values),
},
},
'../shared/pLimit': {
runDependeny: (_dep, fn) => fn(),
removeQueuedDependency() {},
},
'./sock': class {},
'../shared/i18n': {},
}).default;
const service = new Service({}, {});
const dep = { id: 1, name: 'gone/requirements.txt', type: 1, status: 2 };
await service.installOrUninstallDependency(dep, false, false);
assert.deepEqual(removed, []);
assert.equal(updates[0].status, 5);
await service.installOrUninstallDependency(dep, false, true);
assert.deepEqual(removed, [1]);
});
test('Python extras run pip even when the base package version is already installed', async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-extra-install-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const marker = path.join(root, 'pip-ran');
const dep = {
id: 1,
name: 'fixture[optional]>=1',
type: 1,
status: 6,
log: [],
};
let probes = 0;
const Service = load('back/services/dependence.ts', {
'../config': {},
'../config/util': {
getInstallCommand: () => `printf installed > '${marker}'`,
getGetCommand: () => {
probes++;
return 'probe';
},
promiseExecSuccess: async () => '1.0',
detectOS: async () => 'Alpine',
fileExist: async () => false,
},
'../data/dependence': {
Dependence: class {},
DependenceTypes: types,
DependenceStatus: { ...statuses, installing: 0, cancelled: 7 },
versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' },
DependenceModel: {
findAll: async () => [dep],
update: async (values) => Object.assign(dep, values),
},
},
'../shared/pLimit': {
firstDependencyId: 1,
runDependeny: (_dep, fn) => fn(),
removeQueuedDependency() {},
updateDepLog: (fn) => fn(),
},
'./sock': class {},
'../shared/i18n': { t: (text) => text, tf: (text) => text },
}).default;
const service = new Service({}, { sendMessage() {} });
await service.installOrUninstallDependency(dep, true, false);
assert.equal(probes, 0);
assert.equal(fs.readFileSync(marker, 'utf8'), 'installed');
assert.equal(dep.status, 1);
});
-127
View File
@@ -1,127 +0,0 @@
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('#3097 Python requirement probes preserve version constraints, extras and installed distributions', async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-specifiers-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const venv = path.join(root, 'virtual env');
execFileSync('python3', ['-m', 'venv', venv]);
execFileSync('python3', [
'-c',
`import zipfile,sys,os
for version in ['1.2.3','2.1.0']:
name='ql_specifier_fixture'
with zipfile.ZipFile(os.path.join(sys.argv[1],name+'-'+version+'-py3-none-any.whl'),'w') as z:
z.writestr(name+'.py','value=42\\n')
info=name+'-'+version+'.dist-info/'
z.writestr(info+'METADATA','Metadata-Version: 2.1\\nName: ql-specifier-fixture\\nVersion: '+version+'\\nProvides-Extra: optional\\n')
z.writestr(info+'WHEEL','Wheel-Version: 1.0\\nGenerator: fixture\\nRoot-Is-Purelib: true\\nTag: py3-none-any\\n')
z.writestr(info+'RECORD','')`,
root,
]);
const api = load('back/config/util.ts', {
'./index': { rootPath: root },
'./const': { PYTHON_VENV_DIR: venv, PYTHON_INSTALL_DIR: '/unused-prefix' },
'../loaders/logger': {},
'../shared/utils': {},
'../data/dependence': {
DependenceTypes: { nodejs: 0, python3: 1, linux: 2 },
},
});
const env = { ...process.env, PIP_NO_INDEX: '1', PIP_FIND_LINKS: root };
for (const key of ['PYTHONPATH', 'PYTHONUSERBASE', 'PYTHON_HOME'])
delete env[key];
const run = (command) =>
execFileSync('/bin/bash', ['-c', command], {
encoding: 'utf8',
env,
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
run(api.getInstallCommand(1, 'ql-specifier-fixture==1.2.3'));
const Service = load('back/services/dependence.ts', {
'../config': {},
'../config/util': {
...api,
promiseExecSuccess: async (command) => run(command),
},
'../data/dependence': {
Dependence: class {},
DependenceTypes: { nodejs: 0, python3: 1, linux: 2 },
versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' },
},
'../shared/pLimit': {},
'./sock': class {},
'../shared/i18n': {},
}).default;
const service = new Service({}, {});
assert.equal(
await service.isDependenceInstalled({
type: 1,
name: 'ql-specifier-fixture==1.2.3.0',
}),
true,
);
assert.equal(
await service.isDependenceInstalled({
type: 1,
name: 'ql-specifier-fixture>=2',
}),
false,
);
for (const suffix of [
'',
'>=1.0',
'<2',
'>=1,<2',
'~=1.2',
'==1.*',
'!=2.0',
'[optional]>=1',
'==1.2.3.0',
]) {
assert.equal(
run(api.getGetCommand(1, 'ql-specifier-fixture' + suffix)),
'1.2.3',
suffix,
);
}
for (const suffix of ['>=2', '<1', '!=1.2.3', '==1.2.30']) {
assert.equal(
run(api.getGetCommand(1, 'ql-specifier-fixture' + suffix)),
'',
suffix,
);
}
assert.equal(run(api.getGetCommand(1, 'ql_specifier_fixture')), '1.2.3');
assert.equal(run(api.getGetCommand(1, 'json')), 'json');
assert.equal(
run(api.getGetCommand(1, 'ql-missing-specifier-package>=1')),
'',
);
assert.equal(
run(api.getGetCommand(1, 'ql-specifier-fixture; python_version < "0"')),
'not applicable',
);
run(api.getInstallCommand(1, 'ql-specifier-fixture>=2'));
assert.equal(
run(api.getGetCommand(1, 'ql-specifier-fixture>=2,<3')),
'2.1.0',
);
run(api.getUninstallCommand(1, 'ql-specifier-fixture[optional]>=2,<3'));
assert.equal(run(api.getGetCommand(1, 'ql-specifier-fixture')), '');
assert.equal(fs.existsSync(path.join(root, 'INJECTED')), false);
assert.equal(
run(
api.getGetCommand(
1,
`ql-specifier-fixture; __import__('os').system('touch ${root}/INJECTED')`,
),
),
'',
);
});
+1 -1
View File
@@ -101,7 +101,7 @@ test('ql log follows the current Winston system log without PM2', () => {
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, `tail <-n> <0> <-F> <${currentLog}>\n`);
assert.equal(result.stdout, `tail <-n> <0> <--retry> <-F> <${currentLog}>\n`);
assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false);
});
-560
View File
@@ -1,560 +0,0 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const test = require('node:test');
const repo = path.resolve(__dirname, '../..');
const bash = process.env.QL_TEST_BASH || '/bin/bash';
const source = (file) => fs.readFileSync(path.join(repo, file), 'utf8');
function extract(file, name) {
const text = source(file);
const start = text.indexOf(`${name}() {`);
assert.ok(start >= 0, name);
return text.slice(start, text.indexOf('\n}', start) + 2);
}
function fixture(t, prefix = `ql compatibility '" $literal `) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const write = (name, content, executable = false) => {
const file = path.join(root, name);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content, { mode: executable ? 0o755 : 0o644 });
return file;
};
const run = (code, env = {}, args = [], cwd = root) =>
spawnSync(bash, ['-c', code, 'compatibility-test', ...args], {
cwd,
env: { ...process.env, ...env },
encoding: 'utf8',
timeout: 15000,
});
return { root, write, run };
}
for (const [label, args] of [
['normal', ['nested folder/probe [1] $name.sh', '--']],
['explicit bash', ['bash', 'nested folder/probe [1] $name.sh']],
[
'designated',
['nested folder/probe [1] $name.sh', 'desi', 'QA_PANEL', '1-2', '--'],
],
[
'concurrent',
['nested folder/probe [1] $name.sh', 'conc', 'QA_PANEL', '1-2', '--'],
],
['automatic Node', ['nested folder/probe [1] $name.js', '--']],
['explicit Node', ['node', 'nested folder/probe [1] $name.js']],
]) {
for (const logging of ['tee', 'file']) {
test(`${label} task keeps literal paths and arguments with ${logging} logging`, (t) => {
const f = fixture(t);
for (const name of ['share.sh', 'task.sh', 'otask.sh', 'task-timeout.sh'])
f.write(`shell/${name}`, source(`shell/${name}`));
f.write(
'shell/api.sh',
'update_cron(){ :; }; record_cron_stat(){ :; }\n',
);
f.write('shell/preload/env.sh', 'export QA_PANEL="one&two"\n');
f.write('shell/preload/sitecustomize.js', '// fixture preloader\n');
f.write(
'data/config/config.sh',
'DefaultCronRule="0 0 * * *"\nCommandTimeoutTime="2s"\n',
);
f.write('data/config/task_before.sh', 'printf "BEFORE\\n"\n');
f.write('data/config/task_after.sh', 'printf "AFTER\\n"\n');
f.write('bin/pnpm', '#!/bin/bash\nprintf /fixture/global\n', true);
const literal = `argument with 'quotes' $literal ; echo NOT_EXECUTED`;
f.write(
'data/scripts/nested folder/probe [1] $name.sh',
'printf "ARG=%s\\nACCOUNT=%s\\n" "$1" "$QA_PANEL"\n',
);
f.write(
'data/scripts/nested folder/probe [1] $name.js',
'console.log("ARG=" + process.argv[2]);\n',
);
const started = Date.now();
const result = f.run(
'exec "$QA_BASH" "$QL_DIR/shell/task.sh" "$@"',
{
QA_BASH: bash,
QL_DIR: f.root,
QL_DATA_DIR: path.join(f.root, 'data'),
ID: '42',
no_delay: 'true',
real_time: 'false',
no_tee: logging === 'file' ? 'true' : 'false',
real_log_path: 'literal [log] $name.log',
PATH: path.join(f.root, 'bin') + path.delimiter + process.env.PATH,
},
[...args, literal],
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, '');
assert.ok(
Date.now() - started < 1800,
'completion must not wait for the two-second watchdog',
);
const log = fs.readFileSync(
path.join(f.root, 'data/log/literal [log] $name.log'),
'utf8',
);
assert.ok(log.includes(`ARG=${literal}\n`), log);
assert.match(log, /AFTER\n/);
assert.match(log, /完成 ✅/);
if (label === 'concurrent') {
assert.match(log, /ACCOUNT=one\n/);
assert.match(log, /ACCOUNT=two\n/);
}
});
}
}
test('shared initialization copies configuration and hooks under a directory containing spaces', (t) => {
const f = fixture(t);
f.write('shell/share.sh', source('shell/share.sh'));
for (const name of [
'config.sample.sh',
'task.sample.sh',
'extra.sample.sh',
'notify.py',
'notify.js',
'ql_sample.js',
'ql_sample.py',
])
f.write(
`sample/${name}`,
name === 'config.sample.sh'
? 'DefaultCronRule="0 0 * * *"\n'
: '# fixture\n',
);
const result = f.run(
'. "$QL_DIR/shell/share.sh"; fix_config; import_config; printf "%s" "$default_cron"',
{
QL_DIR: f.root,
QL_DATA_DIR: path.join(f.root, 'custom data'),
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, '');
assert.equal(result.stdout, '0 0 * * *');
assert.ok(
fs.existsSync(path.join(f.root, 'custom data/config/task_after.sh')),
);
});
test('token generation passes a compiled token filename literally', (t) => {
const f = fixture(t);
f.write('static/build/token.js', 'console.log("fixture-token");\n');
const result = f.run(
extract('shell/api.sh', 'create_token') +
'\ncreate_token; printf "%s" "$__ql_token__"',
{ dir_root: f.root },
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, 'fixture-token');
});
test('script discovery keeps filenames with spaces, glob characters and leading dashes', (t) => {
const f = fixture(t, 'ql-script-list | ');
for (const name of ['-dash name.js', 'probe [1].js', 'sendNotify.js'])
f.write(`scripts/${name}`, "new Env('fixture');\n");
const result = f.run(
extract('shell/otask.sh', 'gen_array_scripts') +
'\ngen_array_scripts; printf "%s\\n" "${array_scripts[@]}"; printf "NAME=%s\\n" "${array_scripts_name[@]}"',
{
dir_scripts: path.join(f.root, 'scripts'),
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, '');
assert.deepEqual(result.stdout.trim().split('\n').sort(), [
'-dash name.js',
'NAME=fixture',
'NAME=fixture',
'probe [1].js',
]);
});
test('token generation passes the TypeScript fallback filename literally', (t) => {
const f = fixture(t);
const result = f.run(
extract('shell/api.sh', 'create_token') +
`
ts-node-transpile-only(){ [[ "$1" == "$dir_root/back/token.ts" ]] || return 99; printf fallback-token; }
create_token || exit $?
printf '%s' "$__ql_token__"
`,
{ dir_root: f.root },
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, 'fallback-token');
});
test('repository cloning preserves a literal destination with and without a branch', (t) => {
const f = fixture(t);
const origin = path.join(f.root, 'origin repo');
for (const args of [
['init', '-b', 'develop', origin],
['-C', origin, 'config', 'user.name', 'Fixture'],
['-C', origin, 'config', 'user.email', 'fixture@example.invalid'],
]) {
const result = spawnSync('git', args, { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
}
fs.writeFileSync(path.join(origin, 'probe.txt'), 'fixture');
for (const args of [
['-C', origin, 'add', '.'],
['-C', origin, 'commit', '-m', 'fixture'],
]) {
const result = spawnSync('git', args, { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
}
for (const branch of ['', 'develop']) {
const target = path.join(f.root, branch || 'default clone');
const result = f.run(
[
extract('shell/share.sh', 'set_proxy'),
extract('shell/share.sh', 'unset_proxy'),
extract('shell/share.sh', 'git_clone_scripts'),
't(){ :; }; git_clone_scripts "$QA_ORIGIN" "$QA_TARGET" "$QA_BRANCH"; exit "$exit_status"',
].join('\n'),
{ QA_ORIGIN: origin, QA_TARGET: target, QA_BRANCH: branch },
);
assert.equal(result.status, 0, result.stderr);
assert.equal(
fs.readFileSync(path.join(target, 'probe.txt'), 'utf8'),
'fixture',
);
}
});
for (const condition of [
'missing data',
'invalid data',
'invalid root',
'npm only',
'missing module',
'unsupported OS',
]) {
test(`native startup reports ${condition} without depending on translation initialization`, (t) => {
const f = fixture(t);
const npmRoot = path.join(f.root, 'npm global');
fs.mkdirSync(path.join(npmRoot, '@whyour/qinglong'), { recursive: true });
f.write('bin/npm', '#!/bin/bash\nprintf "%s" "$QA_NPM_ROOT"\n', true);
f.write('bin/id', '#!/bin/bash\nprintf 1000\n', true);
const env = {
PATH: path.join(f.root, 'bin'),
QL_DIR: f.root,
QL_DATA_DIR: path.join(f.root, 'data'),
QA_NPM_ROOT: npmRoot,
QL_OS_TYPE: 'unsupported-fixture',
};
if (condition === 'missing data') env.QL_DATA_DIR = '';
if (condition === 'invalid data') env.QL_DATA_DIR = 'relative/data';
if (condition === 'invalid root') env.QL_DIR = 'relative';
if (condition === 'npm only' || condition === 'missing module')
env.QL_DIR = '';
if (condition === 'missing module')
fs.rmdirSync(path.join(npmRoot, '@whyour/qinglong'));
const result = f.run(source('shell/start.sh'), env);
assert.equal(result.status, 1, result.stderr);
assert.doesNotMatch(
result.stderr,
/command not found|No such file|integer expression/,
);
assert.match(
result.stderr,
condition === 'npm only'
? /export QL_DIR=/
: condition === 'missing module'
? /npm i -g @whyour\/qinglong/
: condition === 'unsupported OS'
? /暂不支持此系统/
: condition === 'invalid root'
? /QL_DIR/
: /QL_DATA_DIR/,
);
});
}
test('language loading falls back when global associative arrays are unavailable', (t) => {
const f = fixture(t, 'ql-locale-compat-');
f.write(
'shell/lang/en.sh',
'printf loaded > "$QA_MARKER"\ndeclare -gA LANG_MESSAGES=([probe]=translated)\n',
);
const result = f.run(
source('shell/share.sh') +
`
declare(){ if [[ "$1" == -gA ]]; then return 2; fi; builtin declare "$@"; }
import_config
t probe
`,
{
QL_DIR: f.root,
QL_LANG: 'en',
QA_MARKER: path.join(f.root, 'loaded'),
DefaultCronRule: '0 0 * * *',
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, 'probe\n');
assert.equal(fs.existsSync(path.join(f.root, 'loaded')), false);
});
test(
'supported Bash keeps translated messages global after configuration loading',
{
skip:
spawnSync(bash, ['-c', 'declare -gA probe'], { stdio: 'ignore' })
.status !== 0,
},
(t) => {
const f = fixture(t);
f.write(
'shell/lang/en.sh',
'declare -gA LANG_MESSAGES=([probe]=translated)\n',
);
const result = f.run(
source('shell/share.sh') + '\nimport_config; t probe',
{
QL_DIR: f.root,
QL_LANG: 'en',
DefaultCronRule: '0 0 * * *',
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, 'translated\n');
},
);
for (const fail of [false, true]) {
test(`raw download ${
fail
? 'failure preserves the installed script'
: 'success installs the new script'
} and clears its proxy`, (t) => {
const f = fixture(t);
f.write('raw/probe.js', 'previous-good-version');
f.write('scripts/raw_probe.js', 'previous-good-version');
f.write('crontab.list', '');
const result = f.run(
[
extract('shell/share.sh', 'set_proxy'),
extract('shell/share.sh', 'unset_proxy'),
extract('shell/update.sh', 'update_raw'),
`t(){ :; }
wget(){ printf '%s' "$QA_CONTENT" > "$4"; return "$QA_STATUS"; }
update_raw 'https://fixture.invalid/probe.js' 'http://fixture.invalid:8080' false false
result=$?
if env | grep -qE '^https?_proxy='; then exit 99; fi
exit "$result"`,
].join('\n'),
{
dir_raw: path.join(f.root, 'raw'),
dir_scripts: path.join(f.root, 'scripts'),
list_crontab_user: path.join(f.root, 'crontab.list'),
cmd_task: 'task',
uniq_path: 'probe',
QA_CONTENT: fail ? 'partial-download' : 'new-version',
QA_STATUS: fail ? '8' : '0',
},
);
assert.equal(result.status, fail ? 8 : 0, result.stderr);
const expected = fail ? 'previous-good-version' : 'new-version';
assert.equal(
fs.readFileSync(path.join(f.root, 'raw/probe.js'), 'utf8'),
expected,
);
assert.equal(
fs.readFileSync(path.join(f.root, 'scripts/raw_probe.js'), 'utf8'),
expected,
);
assert.equal(fs.existsSync(path.join(f.root, 'raw/probe.js.new')), false);
});
}
test('ql raw propagates download failure without replacing either installed copy', (t) => {
const f = fixture(t);
for (const name of ['share.sh', 'update.sh', 'env.sh'])
f.write(`shell/${name}`, source(`shell/${name}`));
f.write('shell/api.sh', '');
f.write('data/config/config.sh', 'DefaultCronRule="0 0 * * *"\n');
f.write('data/config/crontab.list', '');
f.write('data/raw/invalid_probe.js', 'old-version');
f.write('data/scripts/raw_invalid_probe.js', 'old-version');
f.write('bin/wget', '#!/bin/bash\nprintf partial > "$4"\nexit 8\n', true);
const result = f.run(
'exec "$QA_BASH" "$QL_DIR/shell/update.sh" raw https://fixture.invalid/probe.js "" false false',
{
QA_BASH: bash,
QL_DIR: f.root,
QL_DATA_DIR: path.join(f.root, 'data'),
ID: '',
PATH: path.join(f.root, 'bin') + path.delimiter + process.env.PATH,
},
);
assert.equal(result.status, 8, result.stderr);
assert.equal(
fs.readFileSync(path.join(f.root, 'data/raw/invalid_probe.js'), 'utf8'),
'old-version',
);
assert.equal(
fs.readFileSync(
path.join(f.root, 'data/scripts/raw_invalid_probe.js'),
'utf8',
),
'old-version',
);
});
const dateTools = [
{
name: 'host',
command: 'date',
macos: process.platform === 'darwin' ? '1' : '0',
},
];
if (
process.platform === 'darwin' &&
spawnSync('gdate', ['--version']).status === 0
)
dateTools.push({ name: 'GNU', command: 'gdate', macos: '0' });
for (const dateTool of dateTools) {
test(`${dateTool.name} retention handles literal filenames, active references and empty directories`, (t) => {
const f = fixture(t);
const old = f.write('logs/2020-01-01 two [words].log', 'old');
const active = f.write('logs/2020-01-01 active & protected.log', 'active');
f.write('logs/keep/important.txt', 'keep');
fs.mkdirSync(path.join(f.root, 'logs/empty directory'));
const result = f.run(
`
t(){ :; }
date(){ command "$QA_DATE" "$@"; }
find_cron_api(){ printf '%s\n' "$1" >> "$QA_QUERIES"; [[ "$1" == *active* ]] && printf active; }
. "$QA_SCRIPT" 7
`,
{
dir_log: path.join(f.root, 'logs'),
is_macos: dateTool.macos,
QA_DATE: dateTool.command,
QA_QUERIES: path.join(f.root, 'queries'),
QA_SCRIPT: path.join(repo, 'shell/rmlog.sh'),
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, '');
assert.equal(fs.existsSync(old), false);
assert.equal(fs.existsSync(active), true);
assert.equal(
fs.existsSync(path.join(f.root, 'logs/empty directory')),
false,
);
assert.ok(fs.existsSync(path.join(f.root, 'logs/keep/important.txt')));
const queries = fs
.readFileSync(path.join(f.root, 'queries'), 'utf8')
.trim()
.split('\n');
for (const query of queries)
assert.equal(
new URLSearchParams(query).get('log_path').includes('.log'),
true,
);
assert.ok(
queries.some(
(query) =>
new URLSearchParams(query).get('log_path') ===
'2020-01-01 active & protected.log',
),
);
});
test(`${dateTool.name} retention measures a dated log from local midnight`, (t) => {
const f = fixture(t, 'ql-retention-boundary-');
const file = f.write('logs/2026-01-01.log', 'boundary');
const result = f.run(
`
t(){ :; }; find_cron_api(){ :; }
date(){ if [[ "$*" == +%s ]]; then printf 1767830401; else command "$QA_DATE" "$@"; fi; }
. "$QA_SCRIPT" 7
`,
{
dir_log: path.join(f.root, 'logs'),
is_macos: dateTool.macos,
QA_DATE: dateTool.command,
QA_SCRIPT: path.join(repo, 'shell/rmlog.sh'),
TZ: 'UTC',
},
);
assert.equal(result.status, 0, result.stderr);
assert.equal(
fs.existsSync(file),
false,
'seven days plus one second is past retention',
);
});
}
test('log streaming options are accepted by the actual platform tail', (t) => {
const f = fixture(t);
const today = spawnSync('date', ['+%F'], { encoding: 'utf8' }).stdout.trim();
f.write(`syslog/${today}.log`, 'ready\n');
const result = f.run(
extract('shell/update.sh', 'show_service_logs') +
`
tail(){ printf '%s\\0' "$@"; }
show_service_logs --lines 0
`,
{ dir_data: f.root },
);
assert.equal(result.status, 0, result.stderr);
const args = result.stdout.split('\0').slice(0, -1);
const native = spawnSync('tail', args, {
encoding: 'utf8',
timeout: 250,
killSignal: 'SIGKILL',
});
assert.equal(native.error?.code, 'ETIMEDOUT', native.stderr);
assert.equal(native.stderr, '');
});
for (const version of ['2.23.0', '2.23.0-beta.1+build.2', 'invalid']) {
test(`legacy publishing ${
version === 'invalid'
? 'rejects an invalid version before Git changes'
: 'uses the complete version with GNU-compatible tools'
}`, (t) => {
const f = fixture(t);
f.write('version.yaml', `version: ${version}\n`);
f.write(
'bin/git',
'#!/bin/bash\nprintf "%s\\n" "$*" >> "$QA_GIT_CALLS"\n',
true,
);
f.write('bin/ts-node-transpile-only', '#!/bin/bash\nexit 0\n', true);
const result = f.run(source('shell/pub.sh'), {
PATH:
path.join(f.root, 'bin') +
path.delimiter +
(process.platform === 'darwin' &&
fs.existsSync('/opt/homebrew/opt/grep/libexec/gnubin')
? '/opt/homebrew/opt/grep/libexec/gnubin:'
: '') +
process.env.PATH,
QA_GIT_CALLS: path.join(f.root, 'git-calls'),
});
if (version === 'invalid') {
assert.notEqual(result.status, 0);
assert.equal(fs.existsSync(path.join(f.root, 'git-calls')), false);
} else {
assert.equal(result.status, 0, result.stderr);
assert.ok(
fs
.readFileSync(path.join(f.root, 'git-calls'), 'utf8')
.includes(`tag -a v${version} -m release v${version}`),
);
}
});
}
-34
View File
@@ -1,34 +0,0 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const test = require('node:test');
const source = fs.readFileSync(
path.resolve(__dirname, '../../shell/task-timeout.sh'),
'utf8',
);
test('timed task cleanup preserves output and exit codes without Bash job notifications', () => {
const result = spawnSync(
process.env.QL_TEST_BASH || '/bin/bash',
[
'-c',
source +
`
command_timeout_time=1h
for code in 0 7 0 7 0 7 0 7 0 7; do
# Keep a descendant alive after the task leader exits so cleanup must
# terminate the worker group as well as the sleeping watchdog.
run_task_command bash -c 'sleep 10 & echo TASK_OUTPUT; echo TASK_ERROR >&2; exit "$1"' task "$code"
actual=$?
[[ $actual == "$code" ]] || exit 99
done
`,
],
{ encoding: 'utf8', timeout: 15000 },
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, 'TASK_OUTPUT\n'.repeat(10));
assert.equal(result.stderr, 'TASK_ERROR\n'.repeat(10));
});
-153
View File
@@ -1,153 +0,0 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const psTree = require('ps-tree');
const test = require('node:test');
const bash = process.env.QL_TEST_BASH || '/bin/bash';
const helper = path.resolve(__dirname, '../../shell/task-timeout.sh');
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const tree = (pid) =>
new Promise((resolve, reject) =>
psTree(pid, (error, children) =>
error ? reject(error) : resolve(children),
),
);
async function until(predicate, message) {
const deadline = Date.now() + 5000;
while (!(await predicate())) {
assert.ok(Date.now() < deadline, message);
await delay(20);
}
}
function alive(pid) {
try {
process.kill(pid, 0);
if (process.platform === 'linux') {
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
if (
['Z', 'X', 'x'].includes(
stat.slice(stat.lastIndexOf(')') + 2).split(' ')[0],
)
)
return false;
} catch (error) {
if (error.code === 'ENOENT') return false;
throw error;
}
}
return true;
} catch (error) {
if (error.code === 'ESRCH') return false;
throw error;
}
}
for (const mode of ['timeout', 'manual']) {
test(
`${mode} cleans up TERM-resistant children, grandchildren and the watchdog`,
{ timeout: 12000, skip: process.platform === 'win32' },
async (t) => {
const root = fs.mkdtempSync(
path.join(os.tmpdir(), 'ql-timeout-processes-'),
);
const known = new Set();
let child;
t.after(async () => {
if (child) {
for (const p of await tree(child.pid).catch(() => []))
known.add(Number(p.PID));
}
for (const pid of known) {
if (pid > 1) {
try {
process.kill(pid, 'SIGKILL');
} catch {}
}
}
fs.rmSync(root, { recursive: true, force: true });
});
fs.writeFileSync(
path.join(root, 'child.sh'),
`
trap '' TERM
printf '%s' "$$" > "$QA_ROOT/child"
sleep 30 &
printf '%s' "$!" > "$QA_ROOT/grandchild"
wait
`,
);
child = spawn(
bash,
[
'-c',
`
. "$QA_HELPER"
task() {
trap '' TERM
/bin/sh -c 'printf "%s" "$PPID"' > "$QA_ROOT/worker"
"$QA_BASH" "$QA_ROOT/child.sh" &
wait
}
run_task_command task
exit $?
`,
],
{
detached: true,
env: {
...process.env,
QA_ROOT: root,
QA_HELPER: helper,
QA_BASH: bash,
command_timeout_time: mode === 'timeout' ? '1s' : '1h',
},
stdio: ['ignore', 'pipe', 'pipe'],
},
);
known.add(child.pid);
let result,
stderr = '';
child.stdout.resume();
child.stderr.on('data', (chunk) => (stderr += chunk));
child.on('exit', (code, signal) => {
result = { code, signal };
});
await until(
() =>
['worker', 'child', 'grandchild'].every((name) => {
const file = path.join(root, name);
return (
fs.existsSync(file) && Number(fs.readFileSync(file, 'utf8')) > 1
);
}),
'task process tree did not initialize',
);
const descendants = await tree(child.pid);
for (const p of descendants) known.add(Number(p.PID));
for (const name of ['worker', 'child', 'grandchild'])
known.add(Number(fs.readFileSync(path.join(root, name), 'utf8')));
assert.ok(
known.size >= 6,
'must observe the wrapper, watchdog and task descendants',
);
if (mode === 'manual') {
const worker = Number(
fs.readFileSync(path.join(root, 'worker'), 'utf8'),
);
const wrapper = descendants.find((p) => Number(p.PID) === worker);
assert.ok(wrapper, 'worker must belong to this test process tree');
process.kill(Number(wrapper.PPID), 'SIGTERM');
}
await until(() => result, 'task wrapper did not exit');
assert.equal(result.code, mode === 'timeout' ? 124 : 143, stderr);
await until(
() => [...known].every((pid) => !alive(pid)),
'task or watchdog process survived cleanup',
);
},
);
}
-57
View File
@@ -1,57 +0,0 @@
const assert = require('node:assert/strict');
const path = require('node:path');
const { createRequire } = require('node:module');
const test = require('node:test');
const { EditorState } = require('@codemirror/state');
const load = require('../helpers/load-security-module.cjs');
const { getCodeMirrorExtensions } = load(
path.resolve(__dirname, '../../src/utils/codemirror/language.ts'),
);
const { LANG_MAP } = load(path.resolve(__dirname, '../../src/utils/const.ts'));
const languageRequire = createRequire(
require.resolve('@uiw/codemirror-extensions-langs'),
);
const { syntaxTree } = languageRequire('@codemirror/language');
test('INI documents load and remain editable with CodeMirror syntax support', () => {
const doc = '; comment\n[section]\nkey=value\n';
const state = EditorState.create({
doc,
extensions: getCodeMirrorExtensions(LANG_MAP['.ini']),
});
assert.ok(syntaxTree(state).length > 0);
assert.ok(syntaxTree(state).topNode.firstChild, 'INI tokens were parsed');
const updated = state.update({
changes: {
from: doc.indexOf('value'),
to: doc.indexOf('value') + 5,
insert: 'edited',
},
}).state;
assert.equal(updated.doc.toString(), '; comment\n[section]\nkey=edited\n');
});
test('every configured script language supplies a valid CodeMirror extension', () => {
for (const mode of new Set(Object.values(LANG_MAP))) {
const extensions = getCodeMirrorExtensions(mode);
assert.equal(extensions.length, 1, mode);
assert.doesNotThrow(
() => EditorState.create({ doc: 'sample', extensions }),
mode,
);
}
});
test('unknown or empty languages fall back to editable plain text', () => {
for (const mode of ['', undefined, 'unknown', 'constructor', 'toString']) {
const extensions = getCodeMirrorExtensions(mode);
assert.deepEqual(extensions, []);
const state = EditorState.create({ doc: 'text', extensions });
assert.equal(
state
.update({ changes: { from: 4, insert: ' edited' } })
.state.doc.toString(),
'text edited',
);
}
});
-20
View File
@@ -1,20 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
test('notification log column titles resolve in every supported locale', () => {
const source = fs.readFileSync(
path.join(__dirname, '../../src/pages/setting/notifyLog.tsx'),
'utf8',
);
const titles = [...source.matchAll(/title:\s*intl\.get\('([^']+)'\)/g)].map(
(m) => m[1],
);
assert.ok(titles.includes('标题') && titles.includes('内容'));
for (const locale of ['zh-CN', 'en-US']) {
const messages = require(`../../src/locales/${locale}.json`);
for (const title of titles)
assert.ok(messages[title]?.trim(), `${locale}: ${title}`);
}
});