mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-05 02:06:24 +08:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e555fcbb0b | ||
|
|
83e8f6bccd | ||
|
|
5801f735bc | ||
|
|
53e1129a12 | ||
|
|
13c0a4d21b | ||
|
|
126fe8eb67 | ||
|
|
73c41eed22 | ||
|
|
40149b6f17 | ||
|
|
6532775a25 | ||
|
|
6469792546 |
+38
-11
@@ -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
@@ -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]*)`,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -700,6 +700,8 @@
|
||||
"已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间",
|
||||
"保存失败": "保存失败",
|
||||
"通知日志": "通知日志",
|
||||
"标题": "标题",
|
||||
"内容": "内容",
|
||||
"发送时间": "发送时间",
|
||||
"推送渠道": "推送渠道",
|
||||
"发送状态": "发送状态",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 () => {},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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')`,
|
||||
),
|
||||
),
|
||||
'',
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user