fix: improve shell compatibility and process cleanup

Preserve literal paths and arguments, use portable command options, validate startup configuration, and retain scripts when downloads fail.

Ensure task watchdogs and process groups are cleaned up across Bash versions, with compatibility and process lifecycle regression coverage.
This commit is contained in:
whyour
2026-10-15 23:20:15 +08:00
parent 13c0a4d21b
commit 5801f735bc
14 changed files with 900 additions and 139 deletions
+2 -2
View File
@@ -119,12 +119,12 @@ reload_pm2
if [[ $AutoStartBot == true ]]; then if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 3. 启动 bot..." 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 fi
if [[ $EnableExtraShell == true ]]; then if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 4. 执行自定义脚本..." log_with_style "INFO" "🛠️ 4. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 & nohup ql extra >"$dir_log/extra.log" 2>&1 &
fi fi
log_with_style "SUCCESS" "🎉 容器启动成功!" log_with_style "SUCCESS" "🎉 容器启动成功!"
+3 -3
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
create_token() { create_token() {
local token_command="ts-node-transpile-only ${dir_root}/back/token.ts"
local token_file="${dir_root}/static/build/token.js" local token_file="${dir_root}/static/build/token.js"
if [[ -f $token_file ]]; then if [[ -f $token_file ]]; then
token_command="node ${token_file}" __ql_token__=$(node "$token_file")
else
__ql_token__=$(ts-node-transpile-only "${dir_root}/back/token.ts")
fi fi
__ql_token__=$(eval "$token_command")
} }
get_token() { get_token() {
+30 -28
View File
@@ -39,16 +39,16 @@ random_delay() {
gen_array_scripts() { gen_array_scripts() {
local dir_current=$(pwd) local dir_current=$(pwd)
local i="-1" local i="-1"
cd $dir_scripts cd "$dir_scripts" || return $?
for file in $(ls); do for file in *; do
if [[ -f $file ]] && [[ $file == *.js && $file != sendNotify.js ]]; then if [[ -f $file ]] && [[ $file == *.js && $file != sendNotify.js ]]; then
let i++ let i++
array_scripts[i]=$(echo "$file" | perl -pe "s|$dir_scripts/||g") 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_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]="<未识别出活动名称>" [[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="<未识别出活动名称>"
fi fi
done done
cd $dir_current cd "$dir_current" || return $?
} }
## 使用说明 ## 使用说明
@@ -72,11 +72,11 @@ usage() {
## run nohup,$1:文件名,不含路径,带后缀 ## run nohup,$1:文件名,不含路径,带后缀
run_nohup() { run_nohup() {
local file_name=$1 local file_name=$1
nohup node $file_name &>$log_path & nohup node "$file_name" &>"$log_path" &
} }
env_str_to_array() { env_str_to_array() {
. $file_env . "$file_env"
local IFS="&" local IFS="&"
read -ra array <<<"${!env_param}" read -ra array <<<"${!env_param}"
array_length=${#array[@]} array_length=${#array[@]}
@@ -122,7 +122,7 @@ enter_script_workdir() {
_target_dir="${dir_scripts}/${work_dir}" _target_dir="${dir_scripts}/${work_dir}"
fi fi
if [[ -d "${_target_dir}" ]]; then if [[ -d "${_target_dir}" ]]; then
cd "${_target_dir}" cd "${_target_dir}" || return $?
if [[ ${file_param} =~ "/" ]]; then if [[ ${file_param} =~ "/" ]]; then
local script_name="${file_param##*/}" local script_name="${file_param##*/}"
if [[ "${use_dot_prefix}" == "true" ]]; then if [[ "${use_dot_prefix}" == "true" ]]; then
@@ -136,13 +136,13 @@ enter_script_workdir() {
t '警告:工作目录不存在 %s' "${_target_dir}" t '警告:工作目录不存在 %s' "${_target_dir}"
fi fi
cd $dir_scripts cd "$dir_scripts" || return $?
if [[ ${file_param} =~ "/" ]]; then if [[ ${file_param} =~ "/" ]]; then
local script_dir="${file_param%/*}" local script_dir="${file_param%/*}"
local script_name="${file_param##*/}" local script_name="${file_param##*/}"
if [[ -d ${script_dir} ]]; then if [[ -d ${script_dir} ]]; then
cd ${script_dir} cd "$script_dir" || return $?
if [[ "${use_dot_prefix}" == "true" ]]; then if [[ "${use_dot_prefix}" == "true" ]]; then
file_param="./${script_name}" file_param="./${script_name}"
else else
@@ -159,12 +159,12 @@ run_normal() {
random_delay "$file_param" random_delay "$file_param"
fi fi
enter_script_workdir enter_script_workdir || return $?
if [[ $isJsOrPythonFile == 'false' ]]; then if [[ $isJsOrPythonFile == 'false' ]]; then
clear_non_sh_env clear_non_sh_env
fi fi
run_task_command $which_program $file_param "${script_params[@]}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}"
} }
handle_env_split() { handle_env_split() {
@@ -175,7 +175,7 @@ handle_env_split() {
env_str_to_array 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 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)) local runArr=($(eval echo $tempArr))
array_run=($(awk -v RS=' ' '!a[$1]++' <<<${runArr[@]})) array_run=($(awk -v RS=' ' '!a[$1]++' <<<"${runArr[*]}"))
} }
## 并发执行时,设定的 RandomDelay 不会生效,即所有任务立即执行 ## 并发执行时,设定的 RandomDelay 不会生效,即所有任务立即执行
@@ -192,10 +192,10 @@ run_concurrent() {
time=$(date "+$mtime_format") time=$(date "+$mtime_format")
single_log_time=$(format_log_time "$mtime_format" "$time") single_log_time=$(format_log_time "$mtime_format" "$time")
enter_script_workdir enter_script_workdir || return $?
local j=0 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" single_log_path="$dir_log/$log_dir/${single_log_time}_$((j + 1)).log"
let j++ let j++
@@ -203,16 +203,16 @@ run_concurrent() {
export "${env_param}=${array[$i - 1]}" export "${env_param}=${array[$i - 1]}"
clear_non_sh_env clear_non_sh_env
fi fi
eval envParam="${env_param}" numParam="${i}" run_task_command $which_program $file_param "${script_params[@]}" &>$single_log_path & envParam="${env_param}" numParam="${i}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}" &>"$single_log_path" &
done done
wait wait
local k=0 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" single_log_path="$dir_log/$log_dir/${single_log_time}_$((k + 1)).log"
let k++ let k++
cat $single_log_path cat "$single_log_path"
[[ -f $single_log_path ]] && rm -f $single_log_path [[ -f $single_log_path ]] && rm -f "$single_log_path"
done done
} }
@@ -229,7 +229,7 @@ run_designated() {
if [[ $isJsOrPythonFile == 'false' ]]; then if [[ $isJsOrPythonFile == 'false' ]]; then
local n=0 local n=0
for i in ${array_run[@]}; do for i in "${array_run[@]}"; do
array_str[n]=${array[$i - 1]} array_str[n]=${array[$i - 1]}
let n++ let n++
done done
@@ -241,9 +241,9 @@ run_designated() {
clear_non_sh_env clear_non_sh_env
fi fi
enter_script_workdir enter_script_workdir || return $?
envParam="${env_param}" numParam="${num_param}" run_task_command $which_program $file_param "${script_params[@]}" envParam="${env_param}" numParam="${num_param}" run_task_command ${which_program:+"$which_program"} "$file_param" "${script_params[@]}"
} }
## 运行其他命令 ## 运行其他命令
@@ -273,7 +273,7 @@ run_else() {
if [[ -n "$script_for_dir" ]]; then if [[ -n "$script_for_dir" ]]; then
local saved_file_param="$file_param" local saved_file_param="$file_param"
file_param="$script_for_dir" file_param="$script_for_dir"
enter_script_workdir true enter_script_workdir true || return $?
local adjusted_script="$file_param" local adjusted_script="$file_param"
file_param="$saved_file_param" file_param="$saved_file_param"
@@ -289,17 +289,17 @@ run_else() {
set -- "${new_args[@]}" set -- "${new_args[@]}"
else else
# 没有找到脚本参数,只 cd 到 scripts 目录 # 没有找到脚本参数,只 cd 到 scripts 目录
enter_script_workdir true enter_script_workdir true || return $?
shift shift
fi fi
else else
# file_param 本身就是脚本,直接用 enter_script_workdir 处理 # file_param 本身就是脚本,直接用 enter_script_workdir 处理
enter_script_workdir true enter_script_workdir true || return $?
shift shift
fi fi
clear_non_sh_env clear_non_sh_env
run_task_command $which_program $file_param "$@" run_task_command ${which_program:+"$which_program"} "$file_param" "$@"
} }
check_file() { check_file() {
@@ -313,12 +313,14 @@ check_file() {
export PREV_NODE_OPTIONS="${NODE_OPTIONS:=}" export PREV_NODE_OPTIONS="${NODE_OPTIONS:=}"
export PREV_PYTHONPATH="${PYTHONPATH:=}" export PREV_PYTHONPATH="${PYTHONPATH:=}"
if [[ $1 == *.js ]] || [[ $1 == *.ts ]] || [[ $1 == *.mjs ]]; then if [[ $1 == *.js ]] || [[ $1 == *.ts ]] || [[ $1 == *.mjs ]]; then
export NODE_OPTIONS="-r ${file_preload_js} ${NODE_OPTIONS}" local preload_path=${file_preload_js//\\/\\\\}
preload_path=${preload_path//\"/\\\"}
export NODE_OPTIONS="-r \"${preload_path}\" ${NODE_OPTIONS}"
else else
export PYTHONPATH="${dir_preload}:${dir_config}:${PYTHONPATH}" export PYTHONPATH="${dir_preload}:${dir_config}:${PYTHONPATH}"
fi fi
else else
. $file_env . "$file_env"
fi fi
fi fi
} }
+8 -2
View File
@@ -1,4 +1,12 @@
#!/usr/bin/env bash #!/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 "开始发布"
echo -e "切换master分支" echo -e "切换master分支"
@@ -9,8 +17,6 @@ git push --set-upstream origin master -f
echo -e "更新cdn文件" echo -e "更新cdn文件"
ts-node-transpile-only sample/tool.ts 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 "当前版本$version"
echo -e "删除已经存在的本地tag" echo -e "删除已经存在的本地tag"
+21 -16
View File
@@ -3,42 +3,47 @@
days=$1 days=$1
remove_js_log() { remove_js_log() {
local log_full_path_list=$(find $dir_log -name "*.log") local log log_date log_timestamp diff_time log_path encoded_path result
local diff_time local now
for log in $log_full_path_list; do now=$(date +%s) || return $?
local log_date=$(echo $log | awk -F "/" '{print $NF}' | cut -c1-10) while IFS= read -r -d '' log; do
log_date=${log##*/}
log_date=${log_date:0:10}
if ! [[ $log_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then if ! [[ $log_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
if [[ $is_macos -eq 1 ]]; then if [[ $is_macos -eq 1 ]]; then
log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log") log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log") || continue
else else
log_date=$(stat -c %y "$log" | cut -d ' ' -f 1) log_date=$(stat -c %y "$log" | cut -d ' ' -f 1)
fi fi
fi fi
if [[ $is_macos -eq 1 ]]; then if [[ $is_macos -eq 1 ]]; then
diff_time=$(($(date +%s) - $(date -j -f "%Y-%m-%d" "$log_date" +%s))) # 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
else else
diff_time=$(($(date +%s) - $(date +%s -d "$log_date"))) log_timestamp=$(date +%s -d "$log_date") || continue
fi fi
diff_time=$((now - log_timestamp))
if [[ $diff_time -gt $((${days} * 86400)) ]]; then if [[ $diff_time -gt $((${days} * 86400)) ]]; then
local log_path=$(echo "$log" | sed "s,${dir_log}/,,g") log_path=${log#"$dir_log"/}
local result=$(find_cron_api "log_path=$log_path") encoded_path=$(printf '%s' "$log_path" | jq -sRr @uri) || continue
result=$(find_cron_api "log_path=$encoded_path")
t '查询文件 %s' "$log_path" t '查询文件 %s' "$log_path"
if [[ -z $result ]]; then if [[ -z $result ]]; then
t '删除中~' t '删除中~'
rm -vf $log rm -vf -- "$log"
else else
t '正在被 %s 使用,跳过~' "$result" t '正在被 %s 使用,跳过~' "$result"
fi fi
fi fi
done done < <(find "$dir_log" -type f -name "*.log" -print0)
} }
remove_empty_dir() { remove_empty_dir() {
cd $dir_log local dir
for dir in $(ls); do for dir in "$dir_log"/*/ "$dir_log"/.[!.]*/ "$dir_log"/..?*/; do
if [[ -d $dir ]] && [[ -z $(ls $dir) ]]; then [[ -d "$dir" ]] || continue
rm -rf $dir # rmdir also protects a directory populated between discovery and removal.
fi rmdir -- "$dir" 2>/dev/null || true
done done
} }
+44 -40
View File
@@ -77,17 +77,20 @@ load_ql_envs() {
} }
import_config() { import_config() {
[[ -f $file_config_user ]] && . $file_config_user [[ -f $file_config_user ]] && . "$file_config_user"
[[ -f $dir_preload/lang_env.sh ]] && . $dir_preload/lang_env.sh [[ -f $dir_preload/lang_env.sh ]] && . "$dir_preload/lang_env.sh"
# 加载语言包(bash 4+ 支持 declare -A,不兼容时回退输出中文 key) # 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.
local lang=${QL_LANG:-zh} local lang=${QL_LANG:-zh}
local lang_file="$dir_shell/lang/${lang}.sh" local lang_file="$dir_shell/lang/${lang}.sh"
if [[ ${BASH_VERSINFO[0]} -ge 4 ]] && [[ -f $lang_file ]]; then if (declare -gA __ql_lang_probe) 2>/dev/null; then
. $lang_file if [[ -f $lang_file ]]; then
elif [[ ${BASH_VERSINFO[0]} -ge 4 ]]; then . "$lang_file"
elif [[ -f "$dir_shell/lang/zh.sh" ]]; then
. "$dir_shell/lang/zh.sh" . "$dir_shell/lang/zh.sh"
fi fi
fi
load_ql_envs load_ql_envs
command_timeout_time=${CommandTimeoutTime:-""} command_timeout_time=${CommandTimeoutTime:-""}
@@ -132,7 +135,7 @@ unset_proxy() {
make_dir() { make_dir() {
local dir=$1 local dir=$1
if [[ ! -d $dir ]]; then if [[ ! -d $dir ]]; then
mkdir -p $dir mkdir -p -- "$dir"
fi fi
} }
@@ -176,57 +179,57 @@ define_cmd() {
} }
fix_config() { fix_config() {
make_dir $dir_tmp make_dir "$dir_tmp"
make_dir $dir_static make_dir "$dir_static"
make_dir $dir_data make_dir "$dir_data"
make_dir $dir_config make_dir "$dir_config"
make_dir $dir_log make_dir "$dir_log"
make_dir $dir_db make_dir "$dir_db"
make_dir $dir_scripts make_dir "$dir_scripts"
make_dir $dir_list_tmp make_dir "$dir_list_tmp"
make_dir $dir_repo make_dir "$dir_repo"
make_dir $dir_raw make_dir "$dir_raw"
make_dir $dir_update_log make_dir "$dir_update_log"
make_dir $dir_dep make_dir "$dir_dep"
if [[ ! -s $file_config_user ]]; then if [[ ! -s $file_config_user ]]; then
cp -f $file_config_sample $file_config_user cp -f "$file_config_sample" "$file_config_user"
fi fi
if [[ ! -f $file_task_before ]]; then if [[ ! -f $file_task_before ]]; then
cp -f $file_task_sample $file_task_before cp -f "$file_task_sample" "$file_task_before"
fi fi
if [[ ! -f $file_task_after ]]; then if [[ ! -f $file_task_after ]]; then
cp -f $file_task_sample $file_task_after cp -f "$file_task_sample" "$file_task_after"
fi fi
if [[ ! -f $file_extra_shell ]]; then if [[ ! -f $file_extra_shell ]]; then
cp -f $file_extra_sample $file_extra_shell cp -f "$file_extra_sample" "$file_extra_shell"
fi fi
if [[ ! -s $file_notify_py ]]; then 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 fi
if [[ ! -s $file_notify_js ]]; then 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 fi
if [[ ! -s $file_test_js ]]; then 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 fi
if [[ ! -s $file_test_py ]]; then 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 fi
if [[ ! -s $dep_notify_js ]]; then 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 fi
if [[ ! -s $dep_notify_py ]]; then 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 fi
} }
@@ -246,17 +249,17 @@ npm_install_2() {
local dir_current=$(pwd) local dir_current=$(pwd)
local dir_work=$1 local dir_work=$1
cd $dir_work cd "$dir_work" || return $?
t '安装 %s 依赖包...\n' "$dir_work" t '安装 %s 依赖包...\n' "$dir_work"
npm_install_sub npm_install_sub
cd $dir_current cd "$dir_current" || return $?
} }
diff_and_copy() { diff_and_copy() {
local copy_source=$1 local copy_source=$1
local copy_to=$2 local copy_to=$2
if [[ ! -s $copy_to ]] || [[ $(diff $copy_source $copy_to) ]]; then if [[ ! -s $copy_to ]] || [[ $(diff "$copy_source" "$copy_to") ]]; then
cp -f $copy_source $copy_to cp -f "$copy_source" "$copy_to"
fi fi
} }
@@ -265,12 +268,13 @@ git_clone_scripts() {
local dir="$2" local dir="$2"
local branch="$3" local branch="$3"
local proxy="$4" local proxy="$4"
[[ $branch ]] && local part_cmd="-b $branch " local branch_args=()
[[ $branch ]] && branch_args=(-b "$branch")
t '开始拉取仓库 %s 到 %s\n' "${uniq_path}" "$dir" t '开始拉取仓库 %s 到 %s\n' "${uniq_path}" "$dir"
set_proxy "$proxy" set_proxy "$proxy"
git clone -q --depth=1 $part_cmd $url $dir git clone -q --depth=1 "${branch_args[@]}" "$url" "$dir"
exit_status=$? exit_status=$?
unset_proxy unset_proxy
@@ -283,7 +287,7 @@ random_range() {
} }
delete_pm2() { delete_pm2() {
cd $dir_root cd "$dir_root" || return $?
# Try to delete PM2 processes, but don't fail if PM2 is not available # Try to delete PM2 processes, but don't fail if PM2 is not available
pm2 delete ecosystem.config.js 2>/dev/null || true pm2 delete ecosystem.config.js 2>/dev/null || true
# Also try to kill any directly spawned node processes # Also try to kill any directly spawned node processes
@@ -291,7 +295,7 @@ delete_pm2() {
} }
reload_pm2() { reload_pm2() {
cd $dir_root cd "$dir_root" || return $?
restore_env_vars restore_env_vars
# Try to start PM2, but handle failures gracefully # Try to start PM2, but handle failures gracefully
@@ -310,7 +314,7 @@ reload_pm2() {
if [[ "$QL_CONTAINER" == "true" ]]; then if [[ "$QL_CONTAINER" == "true" ]]; then
nohup node static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 & nohup node static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
else 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 fi
local node_pid=$! local node_pid=$!
@@ -398,7 +402,7 @@ handle_task_start() {
} }
run_task_before() { run_task_before() {
. $file_task_before "$@" . "$file_task_before" "$@"
if [[ ${task_before:=} ]]; then if [[ ${task_before:=} ]]; then
t '执行前置命令\n' t '执行前置命令\n'
@@ -408,7 +412,7 @@ run_task_before() {
} }
run_task_after() { run_task_after() {
. $file_task_after "$@" . "$file_task_after" "$@"
if [[ ${task_after:=} ]]; then if [[ ${task_after:=} ]]; then
t '\n执行后置命令\n' t '\n执行后置命令\n'
+2 -2
View File
@@ -63,12 +63,12 @@ reload_pm2
if [[ $command != "reload" ]]; then if [[ $command != "reload" ]]; then
if [[ $AutoStartBot == true ]]; then if [[ $AutoStartBot == true ]]; then
log_with_style "INFO" "🤖 4. 启动 bot..." 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 fi
if [[ $EnableExtraShell == true ]]; then if [[ $EnableExtraShell == true ]]; then
log_with_style "INFO" "🛠️ 5. 执行自定义脚本..." log_with_style "INFO" "🛠️ 5. 执行自定义脚本..."
nohup ql extra >$dir_log/extra.log 2>&1 & nohup ql extra >"$dir_log/extra.log" 2>&1 &
fi fi
pm2 startup pm2 startup
+29 -10
View File
@@ -4,31 +4,46 @@
set -e set -e
set -x 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 if [[ ! $QL_DIR ]]; then
npm_dir=$(npm root -g) npm_dir=$(npm root -g 2>/dev/null) || npm_dir=""
pnpm_dir=$(pnpm root -g) pnpm_dir=""
if [[ -d "$npm_dir/@whyour/qinglong" ]]; then 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
QL_DIR="$npm_dir/@whyour/qinglong" QL_DIR="$npm_dir/@whyour/qinglong"
elif [[ -d "$pnpm_dir/@whyour/qinglong" ]]; then elif [[ -n "$pnpm_dir" && -d "$pnpm_dir/@whyour/qinglong" ]]; then
QL_DIR="$pnpm_dir/@whyour/qinglong" QL_DIR="$pnpm_dir/@whyour/qinglong"
else else
t '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装' startup_message '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装'
fi fi
if [[ $QL_DIR ]]; then if [[ $QL_DIR ]]; then
t '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR" startup_message '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR"
fi fi
exit 1 exit 1
fi fi
if [[ $QL_DIR != /* ]]; then
startup_message 'QL_DIR 必须为绝对路径,例如 /ql'
exit 1
fi
if [[ ! $QL_DATA_DIR ]]; then if [[ ! $QL_DATA_DIR ]]; then
t '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量' startup_message '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量'
exit 1 exit 1
fi fi
if [[ $QL_DATA_DIR != */data ]]; then if [[ $QL_DATA_DIR != /*/data ]]; then
t 'QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中' startup_message 'QL_DATA_DIR 必须为以 /data 结尾的绝对路径,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中'
exit 1 exit 1
fi fi
@@ -38,7 +53,11 @@ if [[ $command != "reload" ]]; then
# 安装依赖 # 安装依赖
os_name="${QL_OS_TYPE:-}" os_name="${QL_OS_TYPE:-}"
if [ -z "$os_name" ]; then if [ -z "$os_name" ]; then
if [[ -r /etc/os-release ]]; then
os_name=$(source /etc/os-release && echo "$ID") os_name=$(source /etc/os-release && echo "$ID")
else
os_name=$(uname -s)
fi
fi fi
# 非 root 用户使用 sudo # 非 root 用户使用 sudo
@@ -69,7 +88,7 @@ if [[ $command != "reload" ]]; then
$SUDO apt-get install -y git curl wget tzdata perl openssl jq nginx procps netcat-openbsd openssh-client $SUDO apt-get install -y git curl wget tzdata perl openssl jq nginx procps netcat-openbsd openssh-client
;; ;;
*) *)
t '暂不支持此系统部署 %s' "$os_name" startup_message '暂不支持此系统部署 %s' "$os_name"
exit 1 exit 1
;; ;;
esac esac
+17 -9
View File
@@ -36,7 +36,10 @@ run_task_command() {
) & ) &
timeout_watcher=$! timeout_watcher=$!
set +m set +m
trap '{ finish_task_timeout() {
local code="$1"
trap - EXIT
{
# Bash can report a killed job between any two cleanup commands. # Bash can report a killed job between any two cleanup commands.
# Keep redirection active until the watchdog is fully reaped. # Keep redirection active until the watchdog is fully reaped.
kill -KILL -- "-$timeout_watcher" || : kill -KILL -- "-$timeout_watcher" || :
@@ -45,14 +48,19 @@ run_task_command() {
if kill -TERM -- "-$timeout_worker" 2>/dev/null; then sleep 0.1; fi if kill -TERM -- "-$timeout_worker" 2>/dev/null; then sleep 0.1; fi
kill -KILL -- "-$timeout_worker" 2>/dev/null || : kill -KILL -- "-$timeout_worker" 2>/dev/null || :
wait "$timeout_worker" 2>/dev/null || : wait "$timeout_worker" 2>/dev/null || :
rm -rf -- "$timeout_dir"' EXIT rm -rf -- "$timeout_dir"
trap 'exit 130' INT exit "$code"
trap 'exit 143' TERM }
trap 'exit 129' HUP # Older Bash can omit a subshell's EXIT trap in sourced pipelines. Call
trap 'exit 131' QUIT # 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
# Do not clean up a timer whose process group has not initialized yet. # Do not clean up a timer whose process group has not initialized yet.
while [[ ! -f "$timeout_dir/ready" ]]; do while [[ ! -f "$timeout_dir/ready" ]]; do
kill -0 "$timeout_watcher" 2>/dev/null || exit 125 kill -0 "$timeout_watcher" 2>/dev/null || finish_task_timeout 125
sleep 0.001 sleep 0.001
done done
wait "$timeout_worker" wait "$timeout_worker"
@@ -60,8 +68,8 @@ run_task_command() {
if [[ -f "$timeout_dir/expired" ]]; then if [[ -f "$timeout_dir/expired" ]]; then
# Let the timer finish escalation even if the task leader exited first. # Let the timer finish escalation even if the task leader exited first.
wait "$timeout_watcher" 2>/dev/null || : wait "$timeout_watcher" 2>/dev/null || :
exit 124 finish_task_timeout 124
fi fi
exit "$timeout_code" finish_task_timeout "$timeout_code"
) )
} }
+7 -7
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
dir_shell=$QL_DIR/shell dir_shell=$QL_DIR/shell
. $dir_shell/share.sh . "$dir_shell/share.sh"
. $dir_shell/api.sh . "$dir_shell/api.sh"
. $dir_shell/task-timeout.sh . "$dir_shell/task-timeout.sh"
trap 'single_hanle SIGINT' INT trap 'single_hanle SIGINT' INT
trap 'single_hanle SIGTERM' TERM trap 'single_hanle SIGTERM' TERM
@@ -48,7 +48,7 @@ handle_log_path() {
fi fi
if [[ -z ${ID:=} ]]; then 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 fi
local suffix="" local suffix=""
if [[ ! -z $ID ]]; then if [[ ! -z $ID ]]; then
@@ -89,10 +89,10 @@ handle_log_path() {
log_path="$real_log_path" log_path="$real_log_path"
fi fi
cmd="2>&1 | tee -a $dir_log/$log_path" printf -v cmd '2>&1 | tee -a %q' "$dir_log/$log_path"
make_dir "$dir_log/$log_dir" make_dir "$dir_log/$log_dir"
if [[ "${no_tee:=}" == "true" ]]; then if [[ "${no_tee:=}" == "true" ]]; then
cmd=">> $dir_log/$log_path 2>&1" printf -v cmd '>> %q 2>&1' "$dir_log/$log_path"
fi fi
if [[ "${real_time:=}" == "true" ]]; then if [[ "${real_time:=}" == "true" ]]; then
@@ -162,5 +162,5 @@ define_program "${task_shell_params[@]}"
handle_log_path "${task_shell_params[@]}" handle_log_path "${task_shell_params[@]}"
init_begin_time init_begin_time
eval . $dir_shell/otask.sh "$cmd" eval '. "$dir_shell/otask.sh"' "$cmd"
exit 0 exit 0
+22 -16
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
dir_shell=$QL_DIR/shell dir_shell=$QL_DIR/shell
. $dir_shell/share.sh . "$dir_shell/share.sh"
. $dir_shell/api.sh . "$dir_shell/api.sh"
load_ql_envs load_ql_envs
. $dir_shell/env.sh . "$dir_shell/env.sh"
send_mark=$dir_shell/send_mark send_mark=$dir_shell/send_mark
@@ -165,17 +165,20 @@ update_raw() {
t '开始下载:%s 保存路径:%s\n' "${raw_url}" "$dir_raw/${raw_file_name}" t '开始下载:%s 保存路径:%s\n' "${raw_url}" "$dir_raw/${raw_file_name}"
set_proxy "$proxy" set_proxy "$proxy"
wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" ${raw_url} if wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" "$raw_url"; then
exit_status=0
else
exit_status=$? exit_status=$?
fi
unset_proxy unset_proxy
if [[ $? -eq 0 ]]; then if [[ $exit_status -eq 0 ]]; then
mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}" mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}" || return $?
t '下载 %s 成功...\n' "${raw_file_name}" t '下载 %s 成功...\n' "${raw_file_name}"
cd $dir_raw cd "$dir_raw" || return $?
local filename="raw_${raw_file_name}" 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}') 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} cp -f "$raw_file_name" "$dir_scripts/${filename}" || return $?
if [[ -z $cron_id ]] && [[ ${autoAddCron} == true ]]; then if [[ -z $cron_id ]] && [[ ${autoAddCron} == true ]]; then
cron_line=$( cron_line=$(
perl -ne "{ perl -ne "{
@@ -202,6 +205,7 @@ update_raw() {
else else
t '下载 %s 失败,保留之前正常下载的版本...\n' "${raw_file_name}" t '下载 %s 失败,保留之前正常下载的版本...\n' "${raw_file_name}"
[[ -f "$dir_raw/${raw_file_name}.new" ]] && rm -f "$dir_raw/${raw_file_name}.new" [[ -f "$dir_raw/${raw_file_name}.new" ]] && rm -f "$dir_raw/${raw_file_name}.new"
return "$exit_status"
fi fi
} }
@@ -209,7 +213,7 @@ update_raw() {
## 调用用户自定义的extra.sh ## 调用用户自定义的extra.sh
run_extra_shell() { run_extra_shell() {
if [[ -f $file_extra_shell ]]; then if [[ -f $file_extra_shell ]]; then
. $file_extra_shell . "$file_extra_shell"
else else
t '%s文件不存在,跳过执行...\n' "$file_extra_shell" t '%s文件不存在,跳过执行...\n' "$file_extra_shell"
fi fi
@@ -266,7 +270,8 @@ show_service_logs() {
# Winston writes the active runtime log by date. Follow the filename so # Winston writes the active runtime log by date. Follow the filename so
# size-based rotations are handled without involving the PM2 log files. # size-based rotations are handled without involving the PM2 log files.
local current_system_log="$system_log_dir/$(date +%F).log" local current_system_log="$system_log_dir/$(date +%F).log"
tail -n 0 --retry -F "$current_system_log" # GNU and BSD tail both support -F; --retry is GNU-only and implied by -F.
tail -n 0 -F "$current_system_log"
} }
## 脚本用法 ## 脚本用法
@@ -484,9 +489,9 @@ main() {
local log_path="${log_dir}/${log_time}.log" local log_path="${log_dir}/${log_time}.log"
local file_path="$dir_log/$log_path" local file_path="$dir_log/$log_path"
cmd="2>&1 | tee -a $file_path" printf -v cmd '2>&1 | tee -a %q' "$file_path"
if [[ "$no_tee" == "true" ]]; then if [[ "$no_tee" == "true" ]]; then
cmd=">> $file_path 2>&1" printf -v cmd '>> %q 2>&1' "$file_path"
fi fi
if [[ "$real_time" == "true" ]]; then if [[ "$real_time" == "true" ]]; then
cmd="" cmd=""
@@ -532,19 +537,20 @@ main() {
get_uniq_path "$p2" get_uniq_path "$p2"
if [[ -n $p2 ]]; then if [[ -n $p2 ]]; then
update_raw "$p2" "$p3" "$p4" "$p5" update_raw "$p2" "$p3" "$p4" "$p5"
command_status=$?
else else
t '命令输入错误...\n' t '命令输入错误...\n'
eval usage $cmd eval usage $cmd
fi fi
;; ;;
rmlog) rmlog)
eval . $dir_shell/rmlog.sh "$p2" $cmd eval '. "$dir_shell/rmlog.sh" "$p2"' "$cmd"
;; ;;
bot) bot)
eval . $dir_shell/bot.sh $cmd eval '. "$dir_shell/bot.sh"' "$cmd"
;; ;;
check) check)
eval . $dir_shell/check.sh $cmd eval '. "$dir_shell/check.sh"' "$cmd"
;; ;;
resetlet) resetlet)
eval update_auth_config "\\\"retries\\\":0" "重置登录错误次数" $cmd eval update_auth_config "\\\"retries\\\":0" "重置登录错误次数" $cmd
+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.status, 0, result.stderr);
assert.equal(result.stdout, `tail <-n> <0> <--retry> <-F> <${currentLog}>\n`); assert.equal(result.stdout, `tail <-n> <0> <-F> <${currentLog}>\n`);
assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false); assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false);
}); });
+558
View File
@@ -0,0 +1,558 @@
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[@]}"',
{
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',
'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}`),
);
}
});
}
+153
View File
@@ -0,0 +1,153 @@
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',
);
},
);
}