Compare commits

..
Author SHA1 Message Date
whyour e555fcbb0b fix: honor Python requirement specifiers and portable script discovery 2026-10-15 23:36:21 +08:00
whyour 83e8f6bccd Merge tested dev regression fixes for integration review 2026-10-15 23:28:56 +08:00
whyour 5801f735bc 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.
2026-10-15 23:20:15 +08:00
whyour 53e1129a12 fix: preserve Python source state and repair dev regressions 2026-10-15 09:53:22 +08:00
whyour 13c0a4d21b fix: handle mobile INI editing and silence timeout cleanup
Map INI to the CodeMirror properties language and fall back to plain text for unsupported modes. Reap the timeout watchdog immediately under stderr redirection to prevent Bash job notifications during cleanup.

Refs #3095, #3096
2026-10-15 01:17:43 +08:00
whyour 126fe8eb67 feat(notify): audit system and JavaScript/Python script notifications 2026-10-14 10:28:43 +08:00
whyour 73c41eed22 fix(i18n): retain Python dependency and notification audit translations 2026-10-14 10:25:13 +08:00
whyour 40149b6f17 feat(deps): support Python Git URLs and requirements files 2026-10-14 10:21:19 +08:00
whyour 6532775a25 feat(python): support isolated venv environments for native deployment 2026-10-14 10:19:50 +08:00
whyour 6469792546 chore(deps): update moment and brace-expansion security patches 2026-10-14 10:18:16 +08:00
26 changed files with 1474 additions and 167 deletions
+38 -11
View File
@@ -716,17 +716,20 @@ 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 (
isGitHubUrl(trimmedName) ||
isRequirementsFile(trimmedName) ||
isPyprojectToml(trimmedName)
) {
if (isPythonSourceDependency(trimmedName)) {
// Return a command that will always indicate not installed
// This ensures GitHub URLs and requirements files are always installed
return 'echo ""';
@@ -736,10 +739,30 @@ 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\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 '')`,
)}`,
`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)}`,
[DependenceTypes.linux]:
getOsTypeSync() === 'Alpine'
? `apk info -es ${trimmedName}`
@@ -824,9 +847,13 @@ 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(name)}`;
} -y -- ${shellQuote(packageName)}`;
}
const baseCommands = {
[DependenceTypes.nodejs]: 'pnpm remove -g',
@@ -917,7 +944,7 @@ async function _updateLinuxMirror(
throw new Error('Unsupported OS type for updating mirrors.');
const target = mirror || defaults[osType];
normalizeMirror(target);
const S = isInContainer() ? 'sudo ' : '';
const S = osType !== 'Alpine' && isInContainer() ? 'sudo ' : '';
const update =
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
let file =
+35 -12
View File
@@ -20,6 +20,7 @@ import {
getInstallCommand,
getUninstallCommand,
getGetCommand,
isPythonSourceDependency,
} from '../config/util';
import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
@@ -76,21 +77,25 @@ 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,
(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)),
);
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: ids } },
{ where: { id: installedDeps.map((x) => x.id!) } },
);
this.installDependenceOneByOne(docs, false, force);
this.installDependenceOneByOne(installedDeps, false, force);
}
return docs;
}
@@ -156,9 +161,15 @@ export default class DependenceService {
},
});
const checks = await concurrentRun(
docs.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
docs
.filter(
(doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
)
.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
5,
);
const missingIds = (checks || []).filter(
@@ -177,7 +188,10 @@ export default class DependenceService {
let depName = dependency.name.trim();
const depVersionStr = versionDependenceCommandTypes[dependency.type];
let depVersion = '';
if (depName.includes(depVersionStr)) {
if (
dependency.type !== DependenceTypes.python3 &&
depName.includes(depVersionStr)
) {
const symbolRegx = new RegExp(
`(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`,
);
@@ -305,6 +319,9 @@ 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
@@ -387,7 +404,13 @@ export default class DependenceService {
this.updateLog(depIds, message);
// 判断是否已经安装过依赖
if (isInstall && !force) {
// 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('['))
) {
let getCommand = getGetCommand(dependency.type, depName);
const depVersionStr = versionDependenceCommandTypes[dependency.type];
if (isLinuxDependence) {
@@ -395,7 +418,7 @@ export default class DependenceService {
}
let depVersion = '';
if (depName.includes(depVersionStr)) {
if (!isPythonDependence && 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
token_command="node ${token_file}"
__ql_token__=$(node "$token_file")
else
__ql_token__=$(ts-node-transpile-only "${dir_root}/back/token.ts")
fi
__ql_token__=$(eval "$token_command")
}
get_token() {
+30 -28
View File
@@ -39,16 +39,16 @@ random_delay() {
gen_array_scripts() {
local dir_current=$(pwd)
local i="-1"
cd $dir_scripts
for file in $(ls); do
cd "$dir_scripts" || return $?
for file in *; do
if [[ -f $file ]] && [[ $file == *.js && $file != sendNotify.js ]]; then
let i++
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)
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)
[[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="<未识别出活动名称>"
fi
done
cd $dir_current
cd "$dir_current" || return $?
}
## 使用说明
@@ -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}"
cd "${_target_dir}" || return $?
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
cd "$dir_scripts" || return $?
if [[ ${file_param} =~ "/" ]]; then
local script_dir="${file_param%/*}"
local script_name="${file_param##*/}"
if [[ -d ${script_dir} ]]; then
cd ${script_dir}
cd "$script_dir" || return $?
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
enter_script_workdir || return $?
if [[ $isJsOrPythonFile == 'false' ]]; then
clear_non_sh_env
fi
run_task_command $which_program $file_param "${script_params[@]}"
run_task_command ${which_program:+"$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
enter_script_workdir || return $?
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
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
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
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
local saved_file_param="$file_param"
file_param="$script_for_dir"
enter_script_workdir true
enter_script_workdir true || return $?
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
enter_script_workdir true || return $?
shift
fi
else
# file_param 本身就是脚本,直接用 enter_script_workdir 处理
enter_script_workdir true
enter_script_workdir true || return $?
shift
fi
clear_non_sh_env
run_task_command $which_program $file_param "$@"
run_task_command ${which_program:+"$which_program"} "$file_param" "$@"
}
check_file() {
@@ -313,12 +313,14 @@ check_file() {
export PREV_NODE_OPTIONS="${NODE_OPTIONS:=}"
export PREV_PYTHONPATH="${PYTHONPATH:=}"
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
export PYTHONPATH="${dir_preload}:${dir_config}:${PYTHONPATH}"
fi
else
. $file_env
. "$file_env"
fi
fi
}
+8 -2
View File
@@ -1,4 +1,12 @@
#!/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分支"
@@ -9,8 +17,6 @@ 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"
+21 -16
View File
@@ -3,42 +3,47 @@
days=$1
remove_js_log() {
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)
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}
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")
log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log") || continue
else
log_date=$(stat -c %y "$log" | cut -d ' ' -f 1)
fi
fi
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
diff_time=$(($(date +%s) - $(date +%s -d "$log_date")))
log_timestamp=$(date +%s -d "$log_date") || continue
fi
diff_time=$((now - log_timestamp))
if [[ $diff_time -gt $((${days} * 86400)) ]]; then
local log_path=$(echo "$log" | sed "s,${dir_log}/,,g")
local result=$(find_cron_api "log_path=$log_path")
log_path=${log#"$dir_log"/}
encoded_path=$(printf '%s' "$log_path" | jq -sRr @uri) || continue
result=$(find_cron_api "log_path=$encoded_path")
t '查询文件 %s' "$log_path"
if [[ -z $result ]]; then
t '删除中~'
rm -vf $log
rm -vf -- "$log"
else
t '正在被 %s 使用,跳过~' "$result"
fi
fi
done
done < <(find "$dir_log" -type f -name "*.log" -print0)
}
remove_empty_dir() {
cd $dir_log
for dir in $(ls); do
if [[ -d $dir ]] && [[ -z $(ls $dir) ]]; then
rm -rf $dir
fi
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
done
}
+45 -41
View File
@@ -77,16 +77,19 @@ 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"
# 加载语言包(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_file="$dir_shell/lang/${lang}.sh"
if [[ ${BASH_VERSINFO[0]} -ge 4 ]] && [[ -f $lang_file ]]; then
. $lang_file
elif [[ ${BASH_VERSINFO[0]} -ge 4 ]]; then
. "$dir_shell/lang/zh.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
fi
load_ql_envs
@@ -132,7 +135,7 @@ unset_proxy() {
make_dir() {
local dir=$1
if [[ ! -d $dir ]]; then
mkdir -p $dir
mkdir -p -- "$dir"
fi
}
@@ -176,57 +179,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
}
@@ -246,17 +249,17 @@ npm_install_2() {
local dir_current=$(pwd)
local dir_work=$1
cd $dir_work
cd "$dir_work" || return $?
t '安装 %s 依赖包...\n' "$dir_work"
npm_install_sub
cd $dir_current
cd "$dir_current" || return $?
}
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
}
@@ -265,12 +268,13 @@ git_clone_scripts() {
local dir="$2"
local branch="$3"
local proxy="$4"
[[ $branch ]] && local part_cmd="-b $branch "
local branch_args=()
[[ $branch ]] && branch_args=(-b "$branch")
t '开始拉取仓库 %s 到 %s\n' "${uniq_path}" "$dir"
set_proxy "$proxy"
git clone -q --depth=1 $part_cmd $url $dir
git clone -q --depth=1 "${branch_args[@]}" "$url" "$dir"
exit_status=$?
unset_proxy
@@ -283,7 +287,7 @@ random_range() {
}
delete_pm2() {
cd $dir_root
cd "$dir_root" || return $?
# 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
@@ -291,7 +295,7 @@ delete_pm2() {
}
reload_pm2() {
cd $dir_root
cd "$dir_root" || return $?
restore_env_vars
# Try to start PM2, but handle failures gracefully
@@ -310,7 +314,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=$!
@@ -398,7 +402,7 @@ handle_task_start() {
}
run_task_before() {
. $file_task_before "$@"
. "$file_task_before" "$@"
if [[ ${task_before:=} ]]; then
t '执行前置命令\n'
@@ -408,7 +412,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
+30 -11
View File
@@ -4,31 +4,46 @@
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)
pnpm_dir=$(pnpm root -g)
if [[ -d "$npm_dir/@whyour/qinglong" ]]; 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
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"
else
t '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装'
startup_message '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装'
fi
if [[ $QL_DIR ]]; then
t '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务' "$QL_DIR"
startup_message '请先手动设置 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
t '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量'
startup_message '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量'
exit 1
fi
if [[ $QL_DATA_DIR != */data ]]; then
t 'QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中'
if [[ $QL_DATA_DIR != /*/data ]]; then
startup_message 'QL_DATA_DIR 必须为以 /data 结尾的绝对路径,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中'
exit 1
fi
@@ -38,7 +53,11 @@ if [[ $command != "reload" ]]; then
# 安装依赖
os_name="${QL_OS_TYPE:-}"
if [ -z "$os_name" ]; then
os_name=$(source /etc/os-release && echo "$ID")
if [[ -r /etc/os-release ]]; then
os_name=$(source /etc/os-release && echo "$ID")
else
os_name=$(uname -s)
fi
fi
# 非 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
;;
*)
t '暂不支持此系统部署 %s' "$os_name"
startup_message '暂不支持此系统部署 %s' "$os_name"
exit 1
;;
esac
+22 -10
View File
@@ -36,19 +36,31 @@ run_task_command() {
) &
timeout_watcher=$!
set +m
trap 'kill -KILL -- "-$timeout_watcher" 2>/dev/null || :
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
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 || :
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
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
# 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 || exit 125
kill -0 "$timeout_watcher" 2>/dev/null || finish_task_timeout 125
sleep 0.001
done
wait "$timeout_worker"
@@ -56,8 +68,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 || :
exit 124
finish_task_timeout 124
fi
exit "$timeout_code"
finish_task_timeout "$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
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"
if [[ "${no_tee:=}" == "true" ]]; then
cmd=">> $dir_log/$log_path 2>&1"
printf -v cmd '>> %q 2>&1' "$dir_log/$log_path"
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
+23 -17
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,17 +165,20 @@ update_raw() {
t '开始下载:%s 保存路径:%s\n' "${raw_url}" "$dir_raw/${raw_file_name}"
set_proxy "$proxy"
wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" ${raw_url}
exit_status=$?
if wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" "$raw_url"; then
exit_status=0
else
exit_status=$?
fi
unset_proxy
if [[ $? -eq 0 ]]; then
mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}"
if [[ $exit_status -eq 0 ]]; then
mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}" || return $?
t '下载 %s 成功...\n' "${raw_file_name}"
cd $dir_raw
cd "$dir_raw" || return $?
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}
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 $?
if [[ -z $cron_id ]] && [[ ${autoAddCron} == true ]]; then
cron_line=$(
perl -ne "{
@@ -202,6 +205,7 @@ 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
}
@@ -209,7 +213,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
@@ -266,7 +270,8 @@ 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"
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 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
cmd=">> $file_path 2>&1"
printf -v cmd '>> %q 2>&1' "$file_path"
fi
if [[ "$real_time" == "true" ]]; then
cmd=""
@@ -532,19 +537,20 @@ 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,6 +700,8 @@
"已保存,但旧历史清理失败,请检查存储权限和空间": "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,6 +700,8 @@
"已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间",
"保存失败": "保存失败",
"通知日志": "通知日志",
"标题": "标题",
"内容": "内容",
"发送时间": "发送时间",
"推送渠道": "推送渠道",
"发送状态": "发送状态",
+2 -4
View File
@@ -4,6 +4,7 @@ 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,
@@ -15,7 +16,6 @@ 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,9 +747,7 @@ const Script = () => {
{isPhone && (
<CodeMirror
value={value}
extensions={
mode ? [langs[mode as keyof typeof langs]()] : undefined
}
extensions={getCodeMirrorExtensions(mode)}
theme={theme.includes('dark') ? 'dark' : 'light'}
readOnly={!isEditing || saving}
onChange={(value) => {
+11
View File
@@ -0,0 +1,11 @@
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,6 +61,7 @@ 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
@@ -0,0 +1,238 @@
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
@@ -0,0 +1,127 @@
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> <--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);
});
+560
View File
@@ -0,0 +1,560 @@
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
@@ -0,0 +1,34 @@
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
@@ -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',
);
},
);
}
+57
View File
@@ -0,0 +1,57 @@
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
@@ -0,0 +1,20 @@
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}`);
}
});