Compare commits

...
Author SHA1 Message Date
whyour ac8567e537 fix(runtime): resolve develop merge and restore frozen lockfile compatibility 2026-10-13 02:20:59 +08:00
whyour be877cfddc fix: 修复完整镜像源地址及 CRLF 任务导入 (#3093)
* fix: address six verified legacy issue regressions

* fix: limit issue patch to mirror URLs and CRLF imports

* fix: preserve APT formats and third-party repositories
2026-10-13 01:11:35 +08:00
Copilotwhyourcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>whyour
fdeffebba8 Fix catch-all route serving HTML for API requests (#2885)
* Initial plan

* Fix env.js loading error by preventing catch-all from matching API routes

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
Co-authored-by: whyour <imwhyour@gmail.com>
2026-10-13 01:07:50 +08:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>whyour
3c61e933a1 chore(deps): bump the npm_and_yarn group across 1 directory with 8 updates (#3075)
* chore(deps): bump the npm_and_yarn group across 1 directory with 8 updates

Bumps the npm_and_yarn group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [body-parser](https://github.com/expressjs/body-parser) | `1.20.3` | `1.20.6` |
| [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) | `3.0.3` | `3.0.7` |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.1.0` | `4.3.2` |
| [multer](https://github.com/expressjs/multer) | `2.3.0` | `2.4.0` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.1` | `10.0.9` |
| [undici](https://github.com/nodejs/undici) | `7.9.0` | `7.29.1` |
| [axios](https://github.com/axios/axios) | `1.7.8` | `1.20.0` |
| [brace-expansion](https://github.com/juliangruber/brace-expansion) | `1.1.12` | `1.1.21` |



Updates `body-parser` from 1.20.3 to 1.20.6
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/1.20.3...1.20.6)

Updates `http-proxy-middleware` from 3.0.3 to 3.0.7
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v3.0.7/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v3.0.3...v3.0.7)

Updates `js-yaml` from 4.1.0 to 4.3.2
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.2/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.3.2)

Updates `multer` from 2.3.0 to 2.4.0
- [Release notes](https://github.com/expressjs/multer/releases)
- [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expressjs/multer/compare/v2.3.0...v2.4.0)

Updates `nodemailer` from 8.0.1 to 10.0.9
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.1...v10.0.9)

Updates `undici` from 7.9.0 to 7.29.1
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.9.0...v7.29.1)

Updates `axios` from 1.7.8 to 1.20.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.8...v1.20.0)

Updates `brace-expansion` from 1.1.12 to 1.1.21
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.21)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:development
- dependency-name: body-parser
  dependency-version: 1.20.6
  dependency-type: direct:production
- dependency-name: brace-expansion
  dependency-version: 1.1.21
  dependency-type: indirect
- dependency-name: http-proxy-middleware
  dependency-version: 3.0.7
  dependency-type: direct:production
- dependency-name: js-yaml
  dependency-version: 4.3.2
  dependency-type: direct:production
- dependency-name: multer
  dependency-version: 2.3.0
  dependency-type: direct:production
- dependency-name: nodemailer
  dependency-version: 9.1.1
  dependency-type: direct:production
- dependency-name: undici
  dependency-version: 7.29.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(ci): verify the updated multer runtime version

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: whyour <imwhyour@gmail.com>
2026-10-13 01:06:29 +08:00
whyour 59979ee061 fix(build): pin frontend compilation to Node 22 on newer hosts 2026-10-12 01:34:21 +08:00
whyour 449a8a471c chore(runtime): reconcile future migration with current develop 2026-10-12 01:13:53 +08:00
whyour 70636900b2 chore: finalize 2.23.0 publication metadata 2026-10-11 12:27:51 +08:00
whyour 331f4f9138 Prepare Qinglong 2.23.0 and CLI 0.1.2 release (#3090)
* fix: patch reachable release-blocking dependencies

* docs: keep release risk notes in PR description

* chore: prepare Qinglong 2.23.0 and CLI 0.1.2 release metadata
2026-10-11 12:24:50 +08:00
whyour b06cb72334 fix: apply minimal production dependency security patches 2026-10-11 02:09:20 +08:00
whyour 89c6355635 fix: renew system tokens and serialize authentication cache updates 2026-10-11 01:56:50 +08:00
whyour 4b4e485305 ci: probe legacy pnpm without unsupported config options 2026-10-10 06:10:58 +08:00
whyour 2f359c6bf0 fix: freeze release runtime tools and npm consumer artifacts 2026-10-10 06:02:39 +08:00
whyour a70ec66fe3 fix: prepare supported runtime images for 2.23.0 2026-10-10 03:17:52 +08:00
whyour 5038d2d9a0 fix: patch production dependencies and add release validation 2026-10-10 01:16:07 +08:00
whyour 68ffa106f8 fix: resolve Debian regression findings (#3087)
* fix: resolve Debian regression findings

* fix: reject failed APT index refreshes and simplify SSH home lookup
2026-10-09 01:41:29 +08:00
whyour 9d5f58ef99 ci: verify Gitee Pull mirrors with resilient GitHub notifications
Use native Gitee Pull mirrors for code and static artifacts, and verify branch and tag SHAs alongside image builds. Trigger stale mirrors through small GitHub REST ref updates, retry transient errors with backoff, and keep polling when a notification fails.
2026-10-08 01:16:43 +08:00
whyour ef1c54a620 fix: 补齐前端缺失的中英文翻译 2026-10-07 20:35:59 +08:00
whyour 0218404d9f fix: 修复页面升级、ql update 与静态资源版本同步 (#3086)
* fix: validate panel upgrades and synchronize versioned static builds

* fix: add static-only mirror backfill workflow

* fix: avoid lifecycle self-kill and stop cancelled upgrade groups

* fix: build images when the shared upgrade engine changes

* fix: dispatch upgrade lifecycle through a fixed script

* fix: reclaim upgrade locks safely and normalize health probes

* fix: match backend base URL normalization exactly
2026-10-07 17:43:03 +08:00
whyour 24f7d1a46f fix: resolve dev panel and CLI regressions
Synchronize scheduler concurrency across API and gRPC processes and restore persisted limits during recovery.

Serialize subscription mutations and preserve shared storage while cleaning Shell and CLI namespaces, historical references, and filesystem-specific naming candidates.

Handle CLI text responses correctly and include script history in backups. Add regression coverage for concurrency, cleanup ownership, long URLs, Unicode paths, and history exports.
2026-10-07 12:35:10 +08:00
whyour fa8910b3d6 feat: add script version history (#3085)
* feat: add script version history

* fix: validate canonical save-as paths before backup

* fix: stack script history footer on small screens
2026-10-06 22:00:11 +08:00
whyour 2cab112391 fix: 加固安全输入边界并修复订阅兼容性 (#3084)
* fix: harden scoped API inputs and isolate login throttling

* fix: close script path gaps and harden security boundaries

* fix: preserve subscription aliases and avoid extension eval

* fix: allow cancelling invalid historical subscriptions

* fix: make script parent validation explicit for CodeQL

* fix: limit command execution and clarify trusted path branches

* fix: separate diff tool script access from config APIs

* fix: clean up obsolete subscription SSH credentials
2026-10-06 18:21:54 +08:00
whyour 2ab9c702e6 ci: skip Docker builds for CLI-only changes 2026-10-01 12:25:16 +08:00
whyour 6dd98a8565 fix(ci): isolate CLI release concurrency from validation 2026-10-01 12:17:26 +08:00
whyour 06c8d8b112 fix: report resource conflicts and accurate CLI API errors 2026-10-01 11:26:40 +08:00
whyour a5406bedca ci: allow verified CLI prereleases from develop 2026-10-01 00:26:42 +08:00
whyour 3d2b98a442 fix: scope file access restrictions and honor custom data directories 2026-09-30 21:21:35 +08:00
whyour 93d7ec69dc fix: scope config file blacklist to config directory (#3082) 2026-09-30 20:31:20 +08:00
whyour a303e33b4e fix: invalidate queued cron revisions and enforce task process timeouts 2026-09-29 23:37:00 +08:00
whyour 44129ca088 fix: align CLI shell completion and cancellation semantics 2026-09-29 01:06:47 +08:00
whyour def4917447 fix: finalize exited shell tasks and preserve command environments 2026-09-29 00:52:33 +08:00
whyour bc0f35e3f7 fix: reduce task startup overhead and lazy-load notifications (#3080) 2026-09-28 23:41:31 +08:00
whyour f168efcdae refactor: migrate cron scheduling to node-cron (#3079)
* refactor: migrate cron scheduling to node-cron

* feat: support annually midnight and minutely cron macros

* fix: harden cron recovery and system scheduler compatibility
2026-09-27 22:18:39 +08:00
whyour bf63425805 fix: isolate invalid cron rules during scheduler recovery 2026-09-27 16:45:09 +08:00
whyour a8310e7d51 fix: restore legacy cron schedules and shell diagnostics (#3078) 2026-09-27 16:16:22 +08:00
whyour a67d44cde5 fix: pin Debian Python 3.10 base for full release matrix 2026-09-26 01:40:39 +08:00
whyour 6c487018c2 fix: drain completed task logs and prepare 2.22.0 release (#3076) 2026-09-26 01:17:28 +08:00
whyour 801a71d740 feat(cli): cover OpenAPI with a remote npm CLI and internal panel tools (#3074)
* feat(cli): add unified Commander CLI for QingLong 2.x

* fix(cli): publish via npm and address security review feedback

* ci(cli): package npm artifacts and remove evaluation collateral

* test(cli): use a fixed shell fixture for log retention

* refactor(cli): separate remote npm client from panel tools

* feat(cli): cover active panel OpenAPI resources

* docs(cli): unify authentication and skill guidance

* refactor(cli): isolate internal commands and generate Commander help

* refactor(cli): organize remote and internal modules by responsibility

* ci(cli): publish verified npm archives from master

* fix(cli): publish under the whyour npm scope

* ci: use npm trusted publishing for both packages

* docs: introduce the published CLI on the project homepage

* fix(cli): preserve server log truncation and correct login hints

* fix(cli): accept dashboard record request bodies

* fix(cli): preserve stdin for local task execution

* fix(cli): resolve task executables after changing directory

* fix(cli): preserve shell function tasks and sanitize test failures

* fix(cli): preserve shell hook state and resolve workdir after hooks

* fix(cli): preserve cleanup across shared shell task timeouts

* fix(cli): isolate shell control descriptors and reap timed-out descendants
2026-09-25 23:24:41 +08:00
whyour f051135fc4 fix: declare nullable subscription process id 2026-09-24 20:37:13 +08:00
whyour 2d6c2206b3 fix: verify task termination and preserve startup configuration 2026-09-22 22:23:18 +08:00
whyour 6acb665369 fix: prevent expired-token login refresh loop 2026-09-19 13:23:28 +08:00
whyour d9833c17d5 perf: reduce resident dependencies and duplicate WebSocket authentication (#3071)
* perf: load HTTP dependencies only in the HTTP worker

* docs: record 30-minute idle resource comparison

* perf: isolate database initialization from the cluster primary

* docs: record isolated database startup resource comparison

* perf: avoid an extra shell child in Docker health probes

* perf: load notification dependencies only when needed

* perf: retain queue cleanup after notification loading comparison

* fix: stop page polling after unmount and avoid duplicate fetches

* perf: share WebSocket session polling across connections

* docs: measure real WebSocket container CPU and memory

* perf: reuse JWT validation within each WebSocket session check

* perf: bound login IP lookup allocations with an address cache

* refactor: limit resource optimization PR to three core changes

* docs: benchmark the three core optimizations against develop

* chore: remove documentation and benchmark artifacts from PR
2026-09-18 20:22:18 +08:00
whyour a60ec5587a feat: show today's successful tasks from dashboard (#3072) 2026-09-18 02:16:54 +08:00
whyour 4eb27427f8 fix: 修复 develop 调度锁、运行恢复与 Alpine 缓存并发 (#3070)
* fix: repair develop scheduler locks and runtime recovery

* test: wait for child reaping after verified termination

* fix: abort cron mutations when scheduler deletion fails
2026-09-13 13:44:44 +08:00
whyour 041a437453 fix: support WPUSH notifications over gRPC 2026-09-13 01:08:14 +08:00
Alone88 841740f19a feat: add WPUSH notification provider (#3066)
Add WPUSH as a notification channel for Qinglong panel settings and
sample notify scripts (Python/JS), using POST https://api.wpush.cn/api/v1/send
with success code === 0. Supports optional channel and topic_code.
2026-09-13 00:50:56 +08:00
whyour a94e665054 fix: 修复任务生命周期与调度就绪,优化执行和构建开销 (#3069)
* fix: harden task lifecycle and scheduler readiness

* fix: confine log writes to the configured log directory

* fix: verify complete build artifacts and untracked inputs

* fix: reconcile scheduler state and make stop win startup races

* fix: isolate cron generations and serialize scheduler recovery
2026-09-13 00:32:41 +08:00
whyour d62d8f3025 fix: support Chinese cron log names (#3068) 2026-09-12 03:03:25 +08:00
442 changed files with 87431 additions and 10587 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.gitnexus
.claude
.codex
.env*
node_modules
data
audit
docs
test
src/.umi*
**/__pycache__
**/*.log
+275 -96
View File
@@ -2,20 +2,48 @@ name: Build And Push Docker Image
on:
push:
paths-ignore:
- "*.md"
paths:
- "**"
- "!*.md"
# CLI is verified separately; its shared Bash upgrade engine ships in images.
- "!cli/**"
- "cli/src/internal/maintenance/upgradeArtifacts.cjs"
- "!.github/workflows/cli-package.yml"
branches:
- "master"
- "develop"
tags:
- "v*"
workflow_dispatch:
inputs:
static_sync_only:
description: Synchronize an existing static snapshot to GitLab and verify Gitee
type: boolean
default: false
static_ref:
description: Static branch or version name, for example v2.22.0
type: string
default: ''
static_ref_type:
description: Synchronize a branch, or a version branch and Git tag
type: choice
options: [branch, tag]
default: branch
permissions:
contents: read
jobs:
validate:
if: ${{ !inputs.static_sync_only }}
uses: ./.github/workflows/validate.yml
runtime-tools:
if: ${{ !inputs.static_sync_only }}
uses: ./.github/workflows/runtime-tools.yml
code_gitlab:
if: ${{ !inputs.static_sync_only }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
@@ -32,46 +60,19 @@ jobs:
git push --force --all gitlab 2>&1 || echo "::warning::GitLab push failed"
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab tags push failed"
code_gitee:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Push to Gitee
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
run: |
set +e
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
git remote add gitee git@gitee.com:whyour/qinglong.git 2>/dev/null
if git push --force --all gitee 2>&1; then
echo "::notice::Gitee push --all succeeded"
else
echo "::warning::Push failed, trying to create repo via API..."
curl -sS --connect-timeout 30 --max-time 60 \
-X POST "https://gitee.com/api/v5/user/repos" \
-H "Content-Type: application/json" \
-d '{"name":"qinglong","private":"false"}' \
"?access_token=$GITEE_TOKEN" 2>/dev/null
git push --force --all gitee 2>&1 && echo "::notice::Gitee push succeeded after repo creation" || echo "::warning::Gitee push failed after retry"
fi
git push --force --tags gitee 2>&1 || echo "::warning::Gitee tags push failed"
build-static:
if: ${{ !inputs.static_sync_only }}
needs: [validate, runtime-tools]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -81,66 +82,133 @@ jobs:
pnpm build:front
pnpm build:back
- uses: actions/download-artifact@v7
with:
name: qinglong-runtime-tools-${{ github.sha }}
path: static/
- run: pnpm build:info
- uses: actions/upload-artifact@v6
with:
name: qinglong-static-${{ github.sha }}
path: static/
if-no-files-found: error
include-hidden-files: true
- name: copy to static repo
env:
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
GITHUB_BRANCH: ${{ github.ref_name }}
GITHUB_REF_TYPE: ${{ github.ref_type }}
run: |
mkdir -p tmp
cd ./tmp
cp -rf ../static/* ./
git init -b ${GITHUB_BRANCH} && git add .
git config --local user.name 'github-actions[bot]'
git config --local user.email 'github-actions[bot]@users.noreply.github.com'
git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')"
git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH}
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
npm-package:
if: ${{ !inputs.static_sync_only }}
needs: build-static
uses: ./.github/workflows/npm-package.yml
with:
static-artifact: qinglong-static-${{ github.sha }}
static_gitlab:
needs: build-static
runs-on: ubuntu-latest
concurrency:
group: static-gitlab-${{ github.ref_name }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- name: Push qinglong-static to GitLab
env:
STATIC_BRANCH: ${{ github.ref_name }}
STATIC_REF_TYPE: ${{ github.ref_type }}
MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }}
run: |
set +e
set -euo pipefail
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
cd static-mirror
git remote add gitlab git@gitlab.com:whyour/qinglong-static.git 2>/dev/null
git push --force --all gitlab 2>&1 || echo "::warning::GitLab static push failed"
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab static tags push failed"
bash scripts/sync-static.sh git@gitlab.com:whyour/qinglong-static.git "$STATIC_BRANCH" "$STATIC_REF_TYPE"
static_gitee:
verify-gitee-mirrors:
needs: build-static
runs-on: ubuntu-latest
timeout-minutes: 14
permissions:
contents: write
strategy:
fail-fast: false
matrix:
repository: [qinglong, qinglong-static]
steps:
- name: Push qinglong-static to Gitee
- uses: actions/checkout@v6
# Gitee Pull mirrors and GitHub push webhooks own synchronization.
# Verification runs alongside Docker builds and needs no Gitee credentials.
- name: Wait for Gitee Pull mirror
if: ${{ matrix.repository == 'qinglong' }}
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GH_TOKEN: ${{ github.token }}
QL_MIRROR_NOTIFY_REPO: whyour/qinglong
MIRROR_REPOSITORY: ${{ matrix.repository }}
MIRROR_REF: ${{ github.ref_name }}
MIRROR_REF_TYPE: ${{ github.ref_type }}
QL_MIRROR_INCLUDE_VERSION_BRANCH: ${{ matrix.repository == 'qinglong-static' }}
QL_MIRROR_NOTIFY_ON_START: 'false'
run: |
set +e
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror
cd static-mirror
git remote add gitee git@gitee.com:whyour/qinglong-static.git 2>/dev/null
bash scripts/wait-for-mirror.sh "https://github.com/whyour/$MIRROR_REPOSITORY.git" "https://gitee.com/whyour/$MIRROR_REPOSITORY.git" "$MIRROR_REF" "$MIRROR_REF_TYPE"
- name: Notify and wait for Gitee static mirror
if: ${{ matrix.repository == 'qinglong-static' }}
env:
GH_TOKEN: ${{ secrets.API_TOKEN }}
QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static
MIRROR_REF: ${{ github.ref_name }}
MIRROR_REF_TYPE: ${{ github.ref_type }}
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true'
run: |
bash scripts/wait-for-mirror.sh https://github.com/whyour/qinglong-static.git https://gitee.com/whyour/qinglong-static.git "$MIRROR_REF" "$MIRROR_REF_TYPE"
if git push --force --all gitee 2>&1; then
echo "::notice::Gitee static push succeeded"
else
echo "::warning::Push failed, trying to create repo via API..."
curl -sS --connect-timeout 30 --max-time 60 \
-X POST "https://gitee.com/api/v5/user/repos" \
-H "Content-Type: application/json" \
-d '{"name":"qinglong-static","private":"false"}' \
"?access_token=$GITEE_TOKEN" 2>/dev/null
git push --force --all gitee 2>&1 && echo "::notice::Gitee static push succeeded after repo creation" || echo "::warning::Gitee static push failed after retry"
fi
git push --force --tags gitee 2>&1 || echo "::warning::Gitee static tags push failed"
sync-existing-static:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
mirror: [gitlab]
concurrency:
group: static-${{ matrix.mirror }}-${{ inputs.static_ref }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- name: Synchronize existing static snapshot
env:
STATIC_REF: ${{ inputs.static_ref }}
STATIC_REF_TYPE: ${{ inputs.static_ref_type }}
MIRROR_HOST: ${{ matrix.mirror }}.com
MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -T 10 "$MIRROR_HOST" >> ~/.ssh/known_hosts 2>/dev/null
bash scripts/sync-static.sh "git@$MIRROR_HOST:whyour/qinglong-static.git" "$STATIC_REF" "$STATIC_REF_TYPE"
verify-existing-static-gitee:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }}
runs-on: ubuntu-latest
timeout-minutes: 14
steps:
- uses: actions/checkout@v6
- name: Wait for existing Gitee static snapshot
env:
GH_TOKEN: ${{ secrets.API_TOKEN }}
QL_MIRROR_NOTIFY_REPO: whyour/qinglong-static
MIRROR_REF: ${{ inputs.static_ref }}
MIRROR_REF_TYPE: ${{ inputs.static_ref_type }}
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true'
run: |
bash scripts/wait-for-mirror.sh https://github.com/whyour/qinglong-static.git https://gitee.com/whyour/qinglong-static.git "$MIRROR_REF" "$MIRROR_REF_TYPE"
build-alpine:
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
@@ -151,11 +219,16 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -208,7 +281,7 @@ jobs:
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
@@ -232,11 +305,16 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -288,7 +366,7 @@ jobs:
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
@@ -313,11 +391,16 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -355,7 +438,7 @@ jobs:
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
@@ -380,11 +463,16 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v7
with:
name: qinglong-static-${{ github.sha }}
path: static/
- uses: pnpm/action-setup@v6
with:
version: "8.3.1"
version: "10.34.6"
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
@@ -422,7 +510,7 @@ jobs:
uses: docker/build-push-action@v7
with:
build-args: |
MAINTAINER=${{ github.repository_owner }}
QL_MAINTAINER=${{ github.repository_owner }}
QL_BRANCH=${{ github.ref_name }}
SOURCE_COMMIT=${{ github.sha }}
network: host
@@ -440,26 +528,117 @@ jobs:
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
publish:
if: ${{ github.ref_name == 'master' }}
needs: [build-alpine, build-debian]
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
needs: [build-alpine, build-debian, build-alpine310, build-debian310, npm-package]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
id-token: write
env:
SOURCE_SHA: ${{ github.sha }}
VERIFIED_TARBALL_SHA256: ${{ needs.npm-package.outputs.tarball-sha256 }}
VERIFIED_PACKAGE_VERSION: ${{ needs.npm-package.outputs.package-version }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/checkout@v6
with:
version: "8.3.1"
- uses: actions/setup-node@v3
ref: ${{ github.sha }}
- uses: actions/setup-node@v6
with:
cache: "pnpm"
- name: build front and back
node-version: '22.23.3'
package-manager-cache: false
- uses: actions/download-artifact@v7
with:
name: ${{ needs.npm-package.outputs.artifact-name }}
path: ${{ runner.temp }}/main-npm-publish/
- uses: actions/download-artifact@v7
with:
name: qinglong-runtime-tools-${{ github.sha }}
path: ${{ runner.temp }}/npm-publisher-runtime-tools/
- name: Verify the gate-approved archive and its source receipts
shell: bash
run: |
pnpm install --frozen-lockfile
pnpm build:front
pnpm build:back
- name: publish npm package
set -euo pipefail
node <<'NODE'
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto'), assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const hash = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
const fileHash = (file) => hash(fs.readFileSync(file));
const directory = path.join(process.env.RUNNER_TEMP, 'main-npm-publish');
const summary = JSON.parse(fs.readFileSync(path.join(directory, 'package-build-summary.json')));
const pkg = JSON.parse(fs.readFileSync('package.json'));
assert.match(process.env.VERIFIED_TARBALL_SHA256 || '', /^[a-f0-9]{64}$/);
assert.equal(summary.sourceCommit, process.env.SOURCE_SHA); assert.equal(summary.previewMetadata, false);
assert.deepEqual(summary.sourceMetadataChanges, []); assert(summary.helperProvenance.length > 0);
assert(summary.helperProvenance.every((item) => item.matchesGitSource));
assert.equal(summary.package, pkg.name); assert.equal(summary.packageVersion, pkg.version);
assert.equal(summary.packageVersion, process.env.VERIFIED_PACKAGE_VERSION);
assert.equal(summary.productionManifestSHA256, fileHash('package.json')); assert.equal(summary.productionLockSHA256, fileHash('pnpm-lock.yaml'));
const source = spawnSync('git', ['archive', process.env.SOURCE_SHA], { maxBuffer: 128 * 1024 * 1024 });
assert.equal(source.status, 0); assert.equal(hash(source.stdout), summary.sourceArchiveSHA256);
assert.equal(summary.runtimeToolsIncluded, true); assert.equal(summary.runtimeTools.sourceCommit, process.env.SOURCE_SHA);
assert.equal(summary.runtimeTools.builder.platform, 'linux'); assert.equal(summary.runtimeTools.builder.releaseBuild, true);
assert.equal(path.basename(summary.tarball.filename), summary.tarball.filename); assert(summary.tarball.filename.endsWith('.tgz'));
assert.deepEqual(fs.readdirSync(directory).filter((name) => name.endsWith('.tgz')), [summary.tarball.filename]);
const tarball = path.join(directory, summary.tarball.filename);
assert.equal(summary.tarball.sha256, process.env.VERIFIED_TARBALL_SHA256);
assert.equal(fileHash(tarball), process.env.VERIFIED_TARBALL_SHA256); assert.equal(fs.statSync(tarball).size, summary.tarball.bytes);
const tar = (args) => {
const result = spawnSync('tar', args, { maxBuffer: 128 * 1024 * 1024 });
assert.equal(result.status, 0, 'Cannot read the approved archive'); return result.stdout;
};
const entries = tar(['-tzf', tarball]).toString().split('\n').filter(Boolean);
assert.equal(entries.length, new Set(entries).size);
for (const name of entries) assert(name.startsWith('package/') && !name.split('/').includes('..') && !name.endsWith('.node') && !name.split('/').some((part) => ['.npmrc', '.git', '.pnpm', '_cacache'].includes(part)), 'Forbidden archive item ' + name);
const read = (relative) => tar(['-xOf', tarball, 'package/' + relative]);
const receipt = JSON.parse(read('qinglong-npm-dependency-proof.json'));
const { tarball: ignoredTarball, ...producerReceipt } = summary; assert.deepEqual(receipt, producerReceipt);
assert.deepEqual(entries.map((name) => name.slice('package/'.length)).sort(), receipt.publishedFilePaths);
const published = JSON.parse(read('package.json')); assert.equal(published.name, pkg.name); assert.equal(published.version, pkg.version);
assert.equal(hash(read('package.json')), receipt.publishedRootManifestSHA256);
const buildInfo = read('static/build-info.json'), build = JSON.parse(buildInfo);
assert.equal(hash(buildInfo), summary.ciBuildInfoSHA256); assert.equal(build.sourceCommit, process.env.SOURCE_SHA); assert.equal(build.dirty, false);
assert.equal(build.lockfileSha256, summary.productionLockSHA256);
assert.deepEqual(entries.filter((name) => name.startsWith('package/static/')).map((name) => name.slice('package/static/'.length)).sort(), [...Object.keys(build.files), 'build-info.json'].sort());
const toolsRoot = path.join(process.env.RUNNER_TEMP, 'npm-publisher-runtime-tools');
for (const [name, expected] of [['runtime-tools.tgz', summary.runtimeTools.archiveSHA256], ['runtime-tools-proof.json', summary.runtimeTools.proofSHA256]]) {
assert.equal(fileHash(path.join(toolsRoot, name)), expected); assert.equal(hash(read('static/' + name)), expected); assert.equal(build.files[name], expected);
}
const tools = JSON.parse(fs.readFileSync(path.join(toolsRoot, 'runtime-tools-proof.json')));
assert.equal(tools.sourceCommit, process.env.SOURCE_SHA); assert.equal(tools.archive.sha256, summary.runtimeTools.archiveSHA256);
assert.equal(tools.builder.platform, 'linux'); assert.equal(tools.builder.releaseBuild, true);
assert.equal(tools.sourceAttestation.verified, true); assert.equal(tools.sourceAttestation.head, process.env.SOURCE_SHA); assert(tools.inputs.length > 0);
for (const input of tools.inputs) {
assert(input.path && !path.isAbsolute(input.path) && !input.path.split(/[\\/]/).includes('..'));
assert.equal(fileHash(input.path), input.sha256, 'Tool source input changed: ' + input.path);
}
fs.appendFileSync(process.env.GITHUB_ENV, 'QINGLONG_PUBLISH_TARBALL=' + tarball + '\n');
console.log(JSON.stringify({ sourceCommit: summary.sourceCommit, packageVersion: summary.packageVersion, tarballSHA256: summary.tarball.sha256, cleanSource: true, runtimeToolsIncluded: true }));
NODE
- name: Install the same fixed npm CLI for trusted publishing
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
npm publish
node scripts/install-runtime-tools.cjs \
--archive "$RUNNER_TEMP/npm-publisher-runtime-tools/runtime-tools.tgz" \
--proof "$RUNNER_TEMP/npm-publisher-runtime-tools/runtime-tools-proof.json" \
--prefix "$RUNNER_TEMP/npm-publisher-tools"
- name: Publish the verified npm archive with OIDC
shell: bash
run: |
set -euo pipefail
npm_cli="$RUNNER_TEMP/npm-publisher-tools/lib/node_modules/npm/bin/npm-cli.js"
export NPM_CONFIG_USERCONFIG="$RUNNER_TEMP/npm-publish-public.npmrc"
export NPM_CONFIG_GLOBALCONFIG=/dev/null
export NPM_CONFIG_CACHE="$RUNNER_TEMP/npm-publish-cache"
printf 'registry=https://registry.npmjs.org/\n' > "$NPM_CONFIG_USERCONFIG"
test "$(node "$npm_cli" --version)" = '12.2.0'
name=$(node -p 'require("./package.json").name')
version=$(node -p 'require("./package.json").version')
if node "$npm_cli" view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
node "$npm_cli" publish "$QINGLONG_PUBLISH_TARBALL" --ignore-scripts --access public --registry=https://registry.npmjs.org
else
echo '::error::Could not check the published npm version; refusing to publish.'
exit 1
fi
+161
View File
@@ -0,0 +1,161 @@
name: CLI package
on:
pull_request:
paths:
- 'cli/**'
- 'shell/**'
- 'back/api/**'
- 'back/loaders/deps.ts'
- 'package.json'
- '.github/workflows/cli-package.yml'
push:
branches: [develop, master]
paths:
- 'cli/**'
- 'shell/**'
- 'back/api/**'
- 'back/loaders/deps.ts'
- 'package.json'
- '.github/workflows/cli-package.yml'
workflow_dispatch:
inputs:
publish:
description: Publish the verified CLI package (master stable / develop prerelease)
type: boolean
default: false
prerelease_version:
description: Develop only, e.g. 0.1.2-beta.0 (alpha/beta/rc)
type: string
default: ''
permissions:
contents: read
concurrency:
# Keep validation runs separate from releases; never interrupt a running release.
# Master push releases share the same group as manually requested releases.
group: >-
cli-package-${{ github.workflow }}-${{ github.ref }}-${{
((github.event_name == 'workflow_dispatch' && inputs.publish) ||
(github.event_name == 'push' && github.ref == 'refs/heads/master')) && 'publish' || 'check' }}
cancel-in-progress: >-
${{ github.ref != 'refs/heads/master' &&
!(github.event_name == 'workflow_dispatch' && inputs.publish) }}
jobs:
package:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
node: ['22.12.0', '24']
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: cli/package-lock.json
- name: Install test interpreters and tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends bash ca-certificates curl git jq perl procps python3 unzip zip
npm install --global --ignore-scripts --no-audit --no-fund ts-node@10.9.2 typescript@5.2.2
- name: Set requested prerelease version before verification
if: github.event_name == 'workflow_dispatch' && inputs.prerelease_version != ''
working-directory: cli
env:
PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
shell: bash
run: |
set -euo pipefail
if [[ "$GITHUB_REF" != refs/heads/develop ||
! "$PRERELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then
echo '::error::Prereleases require develop and an explicit alpha/beta/rc version.'
exit 1
fi
npm version "$PRERELEASE_VERSION" --no-git-tag-version --ignore-scripts
- run: npm ci --prefix cli --no-audit --no-fund
- run: npm run check:cli
- run: npm run test:cli
- name: Verify offline package installation
env:
QL_CLI_PACKAGE_OUTPUT: ${{ runner.temp }}/cli-package
run: node cli/scripts/verify-package.cjs
- name: Upload verified npm archive
if: matrix.node == '24'
uses: actions/upload-artifact@v6
with:
name: qinglong-cli-${{ github.sha }}
path: ${{ runner.temp }}/cli-package/*.tgz
if-no-files-found: error
retention-days: 14
publish:
needs: package
if: >-
github.repository == 'whyour/qinglong' &&
((github.ref == 'refs/heads/master' &&
(github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && inputs.publish))) ||
(github.ref == 'refs/heads/develop' && github.event_name == 'workflow_dispatch' &&
inputs.publish && inputs.prerelease_version != ''))
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
id-token: write
steps:
- uses: actions/setup-node@v6
with:
node-version: '24'
package-manager-cache: false
registry-url: https://registry.npmjs.org
- uses: actions/download-artifact@v7
with:
name: qinglong-cli-${{ github.sha }}
path: cli-package
- name: Publish verified npm archive
env:
PRERELEASE_VERSION: ${{ inputs.prerelease_version }}
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
archives=(cli-package/*.tgz)
if [ "${#archives[@]}" -ne 1 ]; then
echo '::error::Expected exactly one verified CLI archive.'
exit 1
fi
archive="${archives[0]}"
metadata=$(tar -xOf "$archive" package/package.json)
name=$(jq -er '.name | select(. == "@whyour/qinglong-cli")' <<< "$metadata")
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
if [[ "$GITHUB_REF" == refs/heads/develop ]]; then
if [[ "$version" != "$PRERELEASE_VERSION" ||
! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+$ ]]; then
echo '::error::Archive version does not match the requested prerelease.'
exit 1
fi
tag="${BASH_REMATCH[1]}"
elif [[ "$GITHUB_REF" == refs/heads/master && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
tag=latest
else
echo '::error::Refusing to publish this branch/version combination.'
exit 1
fi
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > version.json 2> version-error.log; then
jq -e --arg version "$version" '. == $version' version.json > /dev/null
if [[ "$tag" != latest ]]; then
echo '::error::Prerelease version already exists; choose a new version to test this build.'
exit 1
fi
echo "::notice::$name@$version is already published; bump cli/package.json and its lockfile to release changes."
exit 0
elif ! jq -e '.error.code == "E404"' version.json > /dev/null; then
echo '::error::Could not check the published CLI version; refusing to publish.'
exit 1
fi
npm publish "./$archive" --access public --tag "$tag" --ignore-scripts --registry=https://registry.npmjs.org
+312
View File
@@ -0,0 +1,312 @@
name: Verify main npm archive
on:
workflow_call:
inputs:
static-artifact:
description: Complete static artifact including the same-source runtime tools
required: true
type: string
outputs:
artifact-name:
description: Main npm archive and producer summary, after every consumer passes
value: ${{ jobs.verified.outputs.artifact-name }}
tarball-sha256:
description: SHA256 of the exact archive consumed by all six cases
value: ${{ jobs.verified.outputs.tarball-sha256 }}
package-version:
description: Source package version verified against the archive
value: ${{ jobs.verified.outputs.package-version }}
permissions:
contents: read
env:
SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
jobs:
archive:
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
artifact-name: ${{ steps.archive.outputs.artifact-name }}
tarball-sha256: ${{ steps.archive.outputs.tarball-sha256 }}
package-version: ${{ steps.archive.outputs.package-version }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v6
with:
node-version: '22.23.3'
package-manager-cache: false
- uses: actions/download-artifact@v7
with:
name: ${{ inputs.static-artifact }}
path: static/
- name: Verify tool artifact source before executing the producer tools
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs'), crypto = require('node:crypto'), assert = require('node:assert/strict');
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const build = JSON.parse(fs.readFileSync('static/build-info.json'));
const tools = JSON.parse(fs.readFileSync('static/runtime-tools-proof.json'));
assert.equal(build.sourceCommit, process.env.SOURCE_SHA); assert.equal(build.dirty, false);
assert.equal(build.lockfileSha256, hash('pnpm-lock.yaml'));
assert.equal(tools.sourceCommit, process.env.SOURCE_SHA);
assert.equal(tools.builder.platform, 'linux'); assert.equal(tools.builder.releaseBuild, true);
assert.equal(tools.sourceAttestation.verified, true); assert.equal(tools.sourceAttestation.head, process.env.SOURCE_SHA);
for (const name of ['runtime-tools.tgz', 'runtime-tools-proof.json']) assert.equal(hash(`static/${name}`), build.files[name]);
assert.equal(hash('static/runtime-tools.tgz'), tools.archive.sha256);
for (const input of tools.inputs) {
assert(input.path && !input.path.startsWith('/') && !input.path.split(/[\\/]/).includes('..'));
assert.equal(hash(input.path), input.sha256, `Runtime source input changed: ${input.path}`);
}
NODE
- name: Install fixed producer tools into an isolated prefix
run: |
node scripts/install-runtime-tools.cjs \
--archive "$GITHUB_WORKSPACE/static/runtime-tools.tgz" \
--proof "$GITHUB_WORKSPACE/static/runtime-tools-proof.json" \
--prefix "$RUNNER_TEMP/npm-producer-tools"
- name: Export the single frozen production graph
run: |
node scripts/build-npm-package.cjs \
--repo "$GITHUB_WORKSPACE" \
--source-ref "$SOURCE_SHA" \
--artifact-dir "$GITHUB_WORKSPACE/static" \
--pnpm-cli "$RUNNER_TEMP/npm-producer-tools/lib/node_modules/qinglong-runtime-tools/node_modules/pnpm/bin/pnpm.cjs" \
--npm-cli "$RUNNER_TEMP/npm-producer-tools/lib/node_modules/npm/bin/npm-cli.js" \
--output "$RUNNER_TEMP/qinglong-npm-export"
- name: Verify and retain the exact consumer archive
id: archive
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto'), assert = require('node:assert/strict');
const root = path.join(process.env.RUNNER_TEMP, 'qinglong-npm-export');
const summary = JSON.parse(fs.readFileSync(path.join(root, 'package-build-summary.json')));
const pkg = JSON.parse(fs.readFileSync('package.json'));
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
assert.equal(summary.sourceCommit, process.env.SOURCE_SHA); assert.equal(summary.previewMetadata, false);
assert.deepEqual(summary.sourceMetadataChanges, []); assert(summary.helperProvenance.every((item) => item.matchesGitSource));
assert.equal(summary.package, pkg.name); assert.equal(summary.packageVersion, pkg.version);
assert.equal(summary.productionManifestSHA256, hash('package.json')); assert.equal(summary.productionLockSHA256, hash('pnpm-lock.yaml'));
assert.equal(summary.runtimeToolsIncluded, true); assert.equal(summary.runtimeTools.sourceCommit, process.env.SOURCE_SHA);
assert.equal(summary.runtimeTools.builder.releaseBuild, true);
assert.equal(path.basename(summary.tarball.filename), summary.tarball.filename);
const tarball = path.join(root, 'artifacts', summary.tarball.filename);
assert.equal(hash(tarball), summary.tarball.sha256); assert.equal(fs.statSync(tarball).size, summary.tarball.bytes);
const delivery = path.join(process.env.RUNNER_TEMP, 'main-npm-delivery'); fs.mkdirSync(delivery);
fs.copyFileSync(tarball, path.join(delivery, summary.tarball.filename));
fs.copyFileSync(path.join(root, 'package-build-summary.json'), path.join(delivery, 'package-build-summary.json'));
fs.appendFileSync(process.env.GITHUB_OUTPUT, `artifact-name=qinglong-main-npm-${process.env.SOURCE_SHA}\ntarball-sha256=${summary.tarball.sha256}\npackage-version=${pkg.version}\n`);
NODE
- uses: actions/upload-artifact@v6
with:
name: ${{ steps.archive.outputs.artifact-name }}
path: ${{ runner.temp }}/main-npm-delivery/
compression-level: 0
if-no-files-found: error
- name: Preserve producer diagnostics
if: always()
uses: actions/upload-artifact@v6
with:
name: qinglong-main-npm-producer-${{ github.event.pull_request.head.sha || github.sha }}
path: ${{ runner.temp }}/qinglong-npm-export/logs/
if-no-files-found: ignore
consumer:
needs: archive
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
client:
- {npm: '10', mode: local}
- {npm: '12', mode: local}
- {npm: '12', mode: global}
sqlite: [prebuilt, source]
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v6
with:
node-version: '22.23.3'
package-manager-cache: false
- uses: actions/download-artifact@v7
with:
name: ${{ needs.archive.outputs.artifact-name }}
path: ${{ runner.temp }}/main-npm-input/
- uses: actions/download-artifact@v7
with:
name: qinglong-runtime-tools-${{ github.event.pull_request.head.sha || github.sha }}
path: ${{ runner.temp }}/runtime-tools-input/
- name: Verify archive and client tools against the producer source
env:
VERIFIED_TARBALL_SHA256: ${{ needs.archive.outputs.tarball-sha256 }}
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto'), assert = require('node:assert/strict');
const directory = path.join(process.env.RUNNER_TEMP, 'main-npm-input');
const summary = JSON.parse(fs.readFileSync(path.join(directory, 'package-build-summary.json')));
const pkg = JSON.parse(fs.readFileSync('package.json'));
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
assert.equal(summary.sourceCommit, process.env.SOURCE_SHA); assert.equal(summary.previewMetadata, false);
assert.equal(summary.package, pkg.name); assert.equal(summary.packageVersion, pkg.version);
assert.equal(summary.productionManifestSHA256, hash('package.json')); assert.equal(summary.productionLockSHA256, hash('pnpm-lock.yaml'));
assert.equal(summary.runtimeToolsIncluded, true); assert.equal(summary.runtimeTools.sourceCommit, process.env.SOURCE_SHA);
assert.equal(summary.tarball.sha256, process.env.VERIFIED_TARBALL_SHA256);
assert.equal(path.basename(summary.tarball.filename), summary.tarball.filename);
assert.deepEqual(fs.readdirSync(directory).filter((name) => name.endsWith('.tgz')), [summary.tarball.filename]);
assert.equal(hash(path.join(directory, summary.tarball.filename)), process.env.VERIFIED_TARBALL_SHA256);
const tools = path.join(process.env.RUNNER_TEMP, 'runtime-tools-input');
assert.equal(hash(path.join(tools, 'runtime-tools.tgz')), summary.runtimeTools.archiveSHA256);
assert.equal(hash(path.join(tools, 'runtime-tools-proof.json')), summary.runtimeTools.proofSHA256);
NODE
- name: Install native compiler prerequisites
if: matrix.sqlite == 'source'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential python3
- name: Select the isolated npm client
env:
CLIENT_NPM: ${{ matrix.client.npm }}
shell: bash
run: |
set -euo pipefail
npm10_cli="$(node -p "require('node:path').join(require('node:path').dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js')")"
if [[ "$(node "$npm10_cli" --version)" != 10.* ]]; then
echo '::error::The pinned Node22 client must supply npm10.'
exit 1
fi
node scripts/install-runtime-tools.cjs \
--archive "$RUNNER_TEMP/runtime-tools-input/runtime-tools.tgz" \
--proof "$RUNNER_TEMP/runtime-tools-input/runtime-tools-proof.json" \
--prefix "$RUNNER_TEMP/npm-consumer-tools"
if [[ "$CLIENT_NPM" == 10 ]]; then
npm_cli="$npm10_cli"
else
npm_cli="$RUNNER_TEMP/npm-consumer-tools/lib/node_modules/npm/bin/npm-cli.js"
fi
echo "QINGLONG_CONSUMER_NPM_CLI=$npm_cli" >> "$GITHUB_ENV"
- name: Consume the same archive and verify the selected SQLite installation
env:
CONSUMER_MODE: ${{ matrix.client.mode }}
SQLITE_INSTALL: ${{ matrix.sqlite }}
shell: bash
run: |
set -euo pipefail
extra_args=()
if [[ "$CONSUMER_MODE" == global ]]; then extra_args+=(--global); fi
if [[ "$SQLITE_INSTALL" == source ]]; then extra_args+=(--source-build); else extra_args+=(--require-prebuilt); fi
tarball="$(node -e 'const fs=require("node:fs"),p=require("node:path"); const d=process.argv[1]; const s=JSON.parse(fs.readFileSync(p.join(d,"package-build-summary.json"))); process.stdout.write(p.join(d,s.tarball.filename))' "$RUNNER_TEMP/main-npm-input")"
node scripts/verify-npm-package.cjs \
--tarball "$tarball" \
--build-summary "$RUNNER_TEMP/main-npm-input/package-build-summary.json" \
--npm-cli "$QINGLONG_CONSUMER_NPM_CLI" \
--output "$RUNNER_TEMP/qinglong-npm-consumer" \
"${extra_args[@]}"
- name: Preserve actual consumer evidence
if: always()
uses: actions/upload-artifact@v6
with:
name: qinglong-main-npm-consumer-${{ matrix.client.npm }}-${{ matrix.client.mode }}-${{ matrix.sqlite }}-${{ github.event.pull_request.head.sha || github.sha }}
path: |
${{ runner.temp }}/qinglong-npm-consumer/*.log
${{ runner.temp }}/qinglong-npm-consumer/npm-consumer-validation.json
if-no-files-found: ignore
source-fallback:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v6
with:
node-version: '22.23.3'
package-manager-cache: false
- uses: actions/download-artifact@v7
with:
name: ${{ inputs.static-artifact }}
path: static/
- name: Install native compiler prerequisites
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential python3
- name: Prepare source using an older manager and the same verified static tools
env:
npm_config_build_from_source: 'true'
shell: bash
run: |
set -euo pipefail
mkdir "$RUNNER_TEMP/source-tool-fallback"
git archive "$SOURCE_SHA" | tar -x -C "$RUNNER_TEMP/source-tool-fallback"
npm10_cli="$(node -p "require('node:path').join(require('node:path').dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js')")"
printf 'registry=https://registry.npmjs.org/\n' > "$RUNNER_TEMP/legacy-pnpm-public.npmrc"
export NPM_CONFIG_USERCONFIG="$RUNNER_TEMP/legacy-pnpm-public.npmrc"
export npm_config_userconfig="$NPM_CONFIG_USERCONFIG"
export NPM_CONFIG_GLOBALCONFIG=/dev/null npm_config_globalconfig=/dev/null
export NPM_CONFIG_CACHE="$RUNNER_TEMP/legacy-pnpm-cache"
export PNPM_HOME="$RUNNER_TEMP/legacy-pnpm-home"
export XDG_CACHE_HOME="$RUNNER_TEMP/legacy-pnpm-xdg-cache" XDG_STATE_HOME="$RUNNER_TEMP/legacy-pnpm-xdg-state"
node "$npm10_cli" install --global pnpm@8.3.1 \
--prefix "$RUNNER_TEMP/legacy-pnpm" --ignore-scripts --no-audit --no-fund \
--userconfig "$RUNNER_TEMP/legacy-pnpm-public.npmrc" --globalconfig /dev/null \
--cache "$RUNNER_TEMP/legacy-pnpm-cache" --registry=https://registry.npmjs.org \
2>&1 | tee "$RUNNER_TEMP/legacy-pnpm-install.log"
legacy_cli="$RUNNER_TEMP/legacy-pnpm/lib/node_modules/pnpm/bin/pnpm.cjs"
node "$legacy_cli" --version | tee "$RUNNER_TEMP/legacy-pnpm-version.log"
test "$(cat "$RUNNER_TEMP/legacy-pnpm-version.log")" = '8.3.1'
node -e 'const fs=require("node:fs"),os=require("node:os"); fs.writeFileSync(process.argv[1],JSON.stringify(fs.readdirSync(os.tmpdir()).filter(n=>n.startsWith("qinglong-source-tools-")).sort()))' "$RUNNER_TEMP/source-tool-prefixes-before.json"
node scripts/install-panel-dependencies.cjs \
--root "$RUNNER_TEMP/source-tool-fallback" \
--pnpm-cli "$legacy_cli" \
--static-dir "$GITHUB_WORKSPACE/static" \
--source-commit "$SOURCE_SHA" \
2>&1 | tee "$RUNNER_TEMP/source-tool-fallback.log"
grep -q 'SOLINK_MODULE' "$RUNNER_TEMP/source-tool-fallback.log"
grep -q 'node-gyp@12.4.0' "$RUNNER_TEMP/source-tool-fallback.log"
grep -q '"prefixScope":"fresh temporary only"' "$RUNNER_TEMP/source-tool-fallback.log"
node <<'NODE'
const fs = require('node:fs'), path = require('node:path'), os = require('node:os'), crypto = require('node:crypto'), assert = require('node:assert/strict');
const { createRequire } = require('node:module');
const root = path.join(process.env.RUNNER_TEMP, 'source-tool-fallback');
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
assert.equal(hash(path.join(root, 'pnpm-lock.yaml')), hash('pnpm-lock.yaml'));
const before = JSON.parse(fs.readFileSync(path.join(process.env.RUNNER_TEMP, 'source-tool-prefixes-before.json')));
assert.deepEqual(fs.readdirSync(os.tmpdir()).filter((name) => name.startsWith('qinglong-source-tools-')).sort(), before);
const requireSource = createRequire(path.join(root, 'package.json')); requireSource('sqlite3');
const binding = Object.keys(require.cache).find((file) => file.endsWith('.node'));
assert(binding); assert.equal(fs.readFileSync(binding).subarray(0, 4).toString('hex'), '7f454c46');
fs.writeFileSync(path.join(process.env.RUNNER_TEMP, 'source-tool-native-proof.json'), JSON.stringify({ sourceCommit: process.env.SOURCE_SHA, productionLockSHA256: hash('pnpm-lock.yaml'), legacyManagerVersion: '8.3.1', legacyManagerCliSHA256: hash(path.join(process.env.RUNNER_TEMP, 'legacy-pnpm/lib/node_modules/pnpm/bin/pnpm.cjs')), binding: path.relative(root, binding), bindingSHA256: hash(binding), temporaryPrefixRemoved: true }, null, 2));
NODE
node "$RUNNER_TEMP/source-tool-fallback/scripts/verify-production-dependencies.cjs" \
2>&1 | tee "$RUNNER_TEMP/source-tool-fallback-protocols.log"
- name: Preserve source migration evidence
if: always()
uses: actions/upload-artifact@v6
with:
name: qinglong-source-tool-fallback-${{ github.event.pull_request.head.sha || github.sha }}
path: |
${{ runner.temp }}/legacy-pnpm-install.log
${{ runner.temp }}/legacy-pnpm-version.log
${{ runner.temp }}/source-tool-fallback.log
${{ runner.temp }}/source-tool-fallback-protocols.log
${{ runner.temp }}/source-tool-native-proof.json
if-no-files-found: ignore
verified:
needs: [archive, consumer, source-fallback]
runs-on: ubuntu-latest
outputs:
artifact-name: ${{ needs.archive.outputs.artifact-name }}
tarball-sha256: ${{ needs.archive.outputs.tarball-sha256 }}
package-version: ${{ needs.archive.outputs.package-version }}
steps:
- run: echo 'The exact main npm archive passed six Linux consumers and the older-manager source migration.'
+194
View File
@@ -0,0 +1,194 @@
name: Release readiness
on:
pull_request:
paths:
- '.github/workflows/release-readiness.yml'
- 'package.json'
- 'pnpm-lock.yaml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: release-readiness-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
regression:
uses: ./.github/workflows/validate.yml
runtime-tools:
uses: ./.github/workflows/runtime-tools.yml
static:
needs: runtime-tools
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: pnpm/action-setup@v6
with:
version: '10.34.6'
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm audit --prod --audit-level high
- run: pnpm build:front && pnpm build:back
- uses: actions/download-artifact@v7
with:
name: qinglong-runtime-tools-${{ github.event.pull_request.head.sha || github.sha }}
path: static/
- run: pnpm build:info
- uses: actions/upload-artifact@v6
with:
name: release-readiness-static
path: static/
include-hidden-files: true
if-no-files-found: error
sqlite-install:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: pnpm/action-setup@v6
with:
version: '10.34.6'
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: pnpm
- run: pnpm install --prod --frozen-lockfile
- name: Verify precompiled SQLite and production protocols
run: node scripts/verify-production-dependencies.cjs
- name: Rebuild SQLite from source
env:
npm_config_build_from_source: 'true'
PYTHON: /usr/bin/python3
run: |
export npm_config_node_gyp="$(node -p 'require("node:module").createRequire(require.resolve("sqlite3/package.json")).resolve("node-gyp/bin/node-gyp.js")')"
pnpm rebuild @whyour/sqlite3
- name: Verify source-built SQLite and production protocols
run: node scripts/verify-production-dependencies.cjs
npm-package:
needs: static
uses: ./.github/workflows/npm-package.yml
with:
static-artifact: release-readiness-static
images:
needs: [regression, static, sqlite-install]
runs-on: ubuntu-22.04
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- image: alpine
file: docker/Dockerfile
cache: alpine
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
- image: debian
file: docker/Dockerfile.debian
cache: debian
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
- image: alpine-python3.10
file: docker/Dockerfile.310
cache: alpine-python3.10
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x,linux/386
- image: debian-python3.10
file: docker/Dockerfile.debian310
cache: debian-python3.10
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Record source
id: source
env:
SOURCE_URL: ${{ github.event.pull_request.head.repo.clone_url || format('https://github.com/{0}.git', github.repository) }}
SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "url=$SOURCE_URL" >> "$GITHUB_OUTPUT"
echo "branch=$SOURCE_BRANCH" >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@v7
with:
name: release-readiness-static
path: static/
- uses: docker/setup-qemu-action@v4
with:
cache-image: false
- uses: docker/setup-buildx-action@v4
- name: Build every release architecture
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.file }}
platforms: ${{ matrix.platforms }}
build-args: |
QL_URL=${{ steps.source.outputs.url }}
QL_BRANCH=${{ steps.source.outputs.branch }}
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
push: false
provenance: false
sbom: false
cache-from: type=registry,ref=whyour/qinglong:cache-${{ matrix.cache }}
- name: Load native image for runtime verification
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.file }}
platforms: linux/amd64
build-args: |
QL_URL=${{ steps.source.outputs.url }}
QL_BRANCH=${{ steps.source.outputs.branch }}
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
tags: qinglong-readiness:${{ matrix.image }}
load: true
push: false
provenance: false
sbom: false
- name: Verify native production runtime without external networking
run: |
docker run --rm --network none --entrypoint node \
qinglong-readiness:${{ matrix.image }} \
--eval 'console.log(JSON.stringify({node: process.version, arch: process.arch, platform: process.platform}))'
docker run --rm --network none --entrypoint python3 \
qinglong-readiness:${{ matrix.image }} --version
docker run --rm --network none --entrypoint node \
qinglong-readiness:${{ matrix.image }} \
scripts/verify-system-runtime.cjs
docker run --rm --network none --entrypoint node \
qinglong-readiness:${{ matrix.image }} \
scripts/verify-production-dependencies.cjs
- name: Archive verified native Debian image for isolated dev validation
if: matrix.image == 'debian'
shell: bash
run: |
docker image inspect qinglong-readiness:debian \
--format '{"id":{{json .Id}},"revision":{{json (index .Config.Labels "org.opencontainers.image.revision")}},"architecture":{{json .Architecture}},"os":{{json .Os}}}' \
> release-readiness-debian-image.json
docker save qinglong-readiness:debian | gzip > release-readiness-debian-image.tar.gz
sha256sum release-readiness-debian-image.tar.gz > release-readiness-debian-image.sha256
- uses: actions/upload-artifact@v6
if: matrix.image == 'debian'
with:
name: release-readiness-debian-image
path: |
release-readiness-debian-image.tar.gz
release-readiness-debian-image.sha256
release-readiness-debian-image.json
compression-level: 0
retention-days: 3
if-no-files-found: error
+40
View File
@@ -0,0 +1,40 @@
name: Build verified runtime tools
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
tools:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-node@v6
with:
node-version: '22.23.3'
package-manager-cache: false
- name: Build the pinned universal Linux tool archive
env:
QL_TOOLS_SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
run: bash scripts/build-runtime-tools.sh "$RUNNER_TEMP/qinglong-runtime-tools"
- uses: actions/upload-artifact@v6
with:
name: qinglong-runtime-tools-${{ github.event.pull_request.head.sha || github.sha }}
path: |
${{ runner.temp }}/qinglong-runtime-tools/runtime-tools.tgz
${{ runner.temp }}/qinglong-runtime-tools/runtime-tools-proof.json
compression-level: 0
if-no-files-found: error
- name: Preserve build diagnostics
if: always()
uses: actions/upload-artifact@v6
with:
name: qinglong-runtime-tools-diagnostics-${{ github.event.pull_request.head.sha || github.sha }}
path: ${{ runner.temp }}/qinglong-runtime-tools/logs/
if-no-files-found: ignore
+36
View File
@@ -0,0 +1,36 @@
name: Validate
on:
pull_request:
workflow_dispatch:
workflow_call:
permissions:
contents: read
jobs:
regression:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['22', '24']
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: '10.34.6'
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: cp .env.example .env
- run: pnpm test
- run: pnpm build:back
- run: pnpm build:front
- run: pnpm benchmark:execution -- --runs=10 --output=execution-baseline.json
- uses: actions/upload-artifact@v6
with:
name: execution-baseline-node-${{ matrix.node }}
path: execution-baseline.json
+2 -1
View File
@@ -1 +1,2 @@
strict-peer-dependencies=false
strict-peer-dependencies=false
strict-dep-builds=true
+34 -4
View File
@@ -34,6 +34,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
- Support system level notification
- Support dark mode
- Support cell phone operation
- Manage panels through the [remote CLI and Agent Skill](#remote-cli-and-agent-skill)
## Version
@@ -61,10 +62,14 @@ docker run -d \
### npm
The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed.
The npm deployment supports `debian/ubuntu/alpine` with Node.js 22.x >=22.22.2 or 24.x >=24.15.0, npm, Python 3 and pip. The 2.23.0 archive includes frozen production dependencies and verified runtime tools; a separate pnpm installation is unnecessary. After this version is published, use the command below. With npm 12, approve this exact package lifecycle in your project's `package.json` first:
```json
{"allowScripts":{"@whyour/qinglong@2.23.0":true}}
```
```bash
npm i @whyour/qinglong
npm i @whyour/qinglong@2.23.0
```
## Deployment
@@ -79,15 +84,40 @@ npm i @whyour/qinglong
[View Documentation](https://qinglong.online/guide/user-guide/basic-explanation)
## Remote CLI and Agent Skill
Use the standalone npm package [@whyour/qinglong-cli](https://www.npmjs.com/package/@whyour/qinglong-cli) to manage a remote QingLong panel from your terminal or automation workflows. Create, update and run tasks and subscriptions, inspect logs, and manage applications, environment variables, scripts, configuration, dependencies and other OpenAPI resources. JSON output supports scripts and AI agents.
Requires Node.js >=22.12; Node.js 24 is recommended. Install the package to use `ql`:
```bash
npm install -g @whyour/qinglong-cli
ql login --url https://ql.example.com
ql auth status --json
ql task list --json
ql subscription list --json
ql --help
```
Create an application in the panel's System Settings → Application Settings and grant the required permissions. Enter its Client ID/Secret when prompted during login. Existing tokens are also supported through `QL_URL` and `QL_ACCESS_TOKEN`. Each command supports `--help`; set `QL_LANG=en` for English help.
The npm CLI manages remote panels. The panel's built-in executable is also named `ql`; use a separate installation directory on panel hosts to avoid replacing it. For local script execution and maintenance, see [panel-internal tools](cli/LOCAL.en.md).
[CLI guide](cli/README.en.md) · [简体中文](cli/README.md) · [Full command reference](cli/skills/qinglong-cli/references/openapi.md)
The package includes a [remote management skill](cli/skills/qinglong-cli/SKILL.md) covering authentication, command selection and result verification for AI agents. Local execution and maintenance have a separate [internal skill](cli/skills/qinglong-local/SKILL.md).
## Development
Use Node.js 22.x starting at 22.22.2, or Node.js 24.x starting at 24.15.0.
```bash
git clone https://github.com/whyour/qinglong.git
cd qinglong
cp .env.example .env
# Recommended use pnpm https://pnpm.io/zh/installation
npm install -g pnpm@8.3.1
pnpm install
npm install -g pnpm@10.34.6
pnpm install --frozen-lockfile
pnpm start
```
+34 -4
View File
@@ -36,6 +36,7 @@ Timed task management platform supporting Python3, JavaScript, Shell, Typescript
- 支持系统级通知
- 支持暗黑模式
- 支持手机端操作
- 支持通过 [远程 CLI 与 Agent Skill](#远程-cli-与-agent-skill) 管理面板
## 版本
@@ -63,10 +64,14 @@ docker run -d \
### npm
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm`
npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 Node.js 22.x >=22.22.2 或 24.x >=24.15.0,以及 npm、Python 3 和 pip。2.23.0 归档携带冻结生产依赖和受控工具,不需要单独安装 pnpm。该版本发布后使用以下命令;npm 12 的项目安装需要在当前项目 `package.json` 中精确批准安装脚本:
```json
{"allowScripts":{"@whyour/qinglong@2.23.0":true}}
```
```bash
npm i @whyour/qinglong
npm i @whyour/qinglong@2.23.0
```
## 部署
@@ -81,15 +86,40 @@ npm i @whyour/qinglong
[查看文档](https://qinglong.online/guide/user-guide/basic-explanation)
## 远程 CLI 与 Agent Skill
使用独立 npm 包 [@whyour/qinglong-cli](https://www.npmjs.com/package/@whyour/qinglong-cli),可以从本机终端或自动化流程管理远程青龙面板。支持任务与订阅的创建、修改、运行和日志查看,以及应用、环境变量、脚本、配置、依赖等 OpenAPI 资源;JSON 输出便于脚本和 AI Agent 使用。
需要 Node.js >=22.12,推荐 Node.js 24。安装后使用 `ql` 命令:
```bash
npm install -g @whyour/qinglong-cli
ql login --url https://ql.example.com
ql auth status --json
ql task list --json
ql subscription list --json
ql --help
```
在面板「系统设置 → 应用设置」创建应用并授予所需权限,登录时按提示输入 Client ID/Secret;也支持通过 `QL_URL` 和 `QL_ACCESS_TOKEN` 使用已有令牌。各命令支持 `--help`,`QL_LANG=en` 可切换英文帮助。
npm CLI 用于远程管理。面板内置 `ql` 同样使用这个命令名,已有面板的机器请使用独立安装目录,避免覆盖内置命令。本机脚本执行和运维见 [面板内部工具](cli/LOCAL.md)。
[CLI 中文说明](cli/README.md) · [English guide](cli/README.en.md) · [完整命令参考](cli/skills/qinglong-cli/references/openapi.md)
包内附带 [远程管理 Skill](cli/skills/qinglong-cli/SKILL.md),涵盖认证、命令选择和操作结果检查,可供 AI Agent 使用。本机执行与运维另见 [内部 Skill](cli/skills/qinglong-local/SKILL.md)。
## 开发
使用 Node.js 22.22.2 及以上的 22.x,或 Node.js 24.x >=24.15.0。
```bash
git clone https://github.com/whyour/qinglong.git
cd qinglong
cp .env.example .env
# 推荐使用 pnpm https://pnpm.io/zh/installation
npm install -g pnpm@8.3.1
pnpm install
npm install -g pnpm@10.34.6
pnpm install --frozen-lockfile
pnpm start
```
+3 -7
View File
@@ -4,7 +4,6 @@ import { Logger } from 'winston';
import config from '../config';
import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate';
import { join, basename } from 'path';
import { SAMPLE_FILES } from '../config/const';
import { t } from '../shared/i18n';
import ConfigService from '../services/config';
@@ -73,15 +72,12 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const { name, content } = req.body;
// Resolve path first to prevent traversal attacks
let basePath = config.configPath;
if (name.startsWith('data/scripts/')) {
basePath = join(config.rootPath, 'data/scripts');
return res.send({ code: 403, message: t('文件无法访问') });
}
const cleanName = name.replace(/^data\/scripts\//, '');
const normalized = resolveFileAccess(
basePath,
[cleanName],
config.configPath,
[name],
config.blackFileList,
);
if (!normalized) {
+14
View File
@@ -10,12 +10,26 @@ import {
InstanceStatus,
} from '../data/runningInstance';
import { t } from '../shared/i18n';
import cronClient from '../schedule/client';
const route = Router();
export default (app: Router) => {
app.use('/crons', route);
route.use(async (req, res, next) => {
// Keep stop/status callbacks available even when the scheduler is down.
if (['POST', 'PUT', 'DELETE'].includes(req.method) &&
['/', '/run', '/enable', '/disable', '/views/enable', '/views/disable'].includes(req.path)) {
try {
await cronClient.readiness.ensureReady();
} catch (error) {
return next(error);
}
}
return next();
});
route.get(
'/views',
async (req: Request, res: Response, next: NextFunction) => {
+39 -36
View File
@@ -107,42 +107,45 @@ export default (app: Router) => {
},
);
route.get(
'/failures',
async (req: Request, res: Response, next: NextFunction) => {
try {
const rows = (await CrontabStatModel.findAll({
attributes: ['ref_id', [fn('SUM', col('fail_count')), 'fail_count']],
where: { date: dayjs().format('YYYY-MM-DD'), fail_count: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col('fail_count')), 'DESC'], ['ref_id', 'ASC']],
raw: true,
})) as any[];
const crons = rows.length > 0 ? await CrontabModel.findAll({
attributes: ['id', 'name', 'command'],
where: { id: { [Op.in]: rows.map((row) => Number(row.ref_id)) } },
raw: true,
}) : [];
const cronMap = new Map(crons.map((cron) => [cron.id, cron]));
res.send({
code: 200,
data: rows.map((row) => {
const id = Number(row.ref_id);
const cron = cronMap.get(id);
return {
id,
name: cron?.name || cron?.command || tf('任务#%s', id),
command: cron?.command || '',
failCount: Number(row.fail_count),
deleted: !cron,
};
}),
});
} catch (e) {
next(e);
}
},
);
for (const isSuccess of [false, true]) {
route.get(
isSuccess ? '/successes' : '/failures',
async (req: Request, res: Response, next: NextFunction) => {
try {
const countColumn = isSuccess ? 'success_count' : 'fail_count';
const rows = (await CrontabStatModel.findAll({
attributes: ['ref_id', [fn('SUM', col(countColumn)), 'result_count']],
where: { date: dayjs().format('YYYY-MM-DD'), [countColumn]: { [Op.gt]: 0 } },
group: ['ref_id'],
order: [[fn('SUM', col(countColumn)), 'DESC'], ['ref_id', 'ASC']],
raw: true,
})) as any[];
const crons = rows.length > 0 ? await CrontabModel.findAll({
attributes: ['id', 'name', 'command'],
where: { id: { [Op.in]: rows.map((row) => Number(row.ref_id)) } },
raw: true,
}) : [];
const cronMap = new Map(crons.map((cron) => [cron.id, cron]));
res.send({
code: 200,
data: rows.map((row) => {
const id = Number(row.ref_id);
const cron = cronMap.get(id);
return {
id,
name: cron?.name || cron?.command || tf('任务#%s', id),
command: cron?.command || '',
[isSuccess ? 'successCount' : 'failCount']: Number(row.result_count),
deleted: !cron,
};
}),
});
} catch (e) {
next(e);
}
},
);
}
route.get(
'/trend',
+17 -14
View File
@@ -1,24 +1,16 @@
import { Joi, celebrate } from 'celebrate';
import { NextFunction, Request, Response, Router } from 'express';
import fs from 'fs';
import multer from 'multer';
import { Container } from 'typedi';
import { Logger } from 'winston';
import config from '../config';
import { safeJSONParse } from '../config/util';
import { t } from '../shared/i18n';
import EnvService from '../services/env';
const route = Router();
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, config.scriptPath);
},
filename: function (req, file, cb) {
cb(null, file.originalname);
},
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
});
const upload = multer({ storage: storage });
const labelSchema = Joi.array()
.items(Joi.string().trim().required())
.min(1)
@@ -282,12 +274,23 @@ export default (app: Router) => {
const logger: Logger = Container.get('logger');
try {
const envService = Container.get(EnvService);
const fileContent = await fs.promises.readFile(req!.file!.path, 'utf8');
const parseContent = safeJSONParse(fileContent);
let parseContent: unknown;
try {
parseContent = JSON.parse(req.file?.buffer.toString('utf8') || '');
} catch {
return res.status(400).send({ code: 400, message: t('参数错误') });
}
const data = Array.isArray(parseContent)
? parseContent
: [parseContent];
if (data.every((x) => x.name && x.value)) {
if (
data.length > 0 &&
data.every((x) =>
x && typeof x.name === 'string' &&
/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(x.name) &&
typeof x.value === 'string' && x.value.length > 0,
)
) {
const result = await envService.create(
data.map((x) => ({
name: x.name,
+2 -2
View File
@@ -11,8 +11,8 @@ export default (app: Router) => {
try {
const healthService = Container.get(HealthService);
const health = await healthService.check();
res.status(200).send({
code: 200,
res.status(health.status === 'ok' ? 200 : 503).send({
code: health.status === 'ok' ? 200 : 503,
data: health,
});
} catch (err: any) {
+2
View File
@@ -3,6 +3,7 @@ import { Container } from 'typedi';
import OpenService from '../services/open';
import { Logger } from 'winston';
import { celebrate, Joi } from 'celebrate';
import { rateLimit } from 'express-rate-limit';
const route = Router();
export default (app: Router) => {
@@ -100,6 +101,7 @@ export default (app: Router) => {
route.get(
'/auth/token',
rateLimit({ windowMs: 15 * 60 * 1000, limit: 100 }),
celebrate({
query: {
client_id: Joi.string().required(),
+199 -14
View File
@@ -7,20 +7,62 @@ import { Logger } from 'winston';
import config from '../config';
import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate';
import path, { join, parse } from 'path';
import path, { dirname, join, parse, resolve, sep } from 'path';
import ScriptService from '../services/script';
import ScriptHistoryService from '../services/scriptHistory';
import { t } from '../shared/i18n';
import multer from 'multer';
import { writeFileWithLock } from '../shared/utils';
import {
ScriptHistoryError,
HistoryUnavailableError,
} from '../shared/scriptHistory';
const route = Router();
function isPathAllowed(targetPath: string): boolean {
const resolved = path.resolve(targetPath);
return config.writePathList.some((x) =>
Boolean(resolveFileAccess(x, [resolved], config.blackFileList)),
Boolean(
resolveFileAccess(
x,
[resolved],
// Panel configuration secrets must not restrict user script filenames.
path.resolve(x) === path.resolve(config.configPath)
? config.blackFileList
: [],
),
),
);
}
async function existingWritableRealPath(targetPath: string): Promise<string> {
if (!isPathAllowed(targetPath)) {
throw new ScriptHistoryError(403, '暂无权限');
}
const resolved = resolve(targetPath);
for (const writableRoot of config.writePathList) {
const root = resolve(writableRoot);
const rootPrefix = root.endsWith(sep) ? root : root + sep;
// Keep an explicit normalized boundary check at the filesystem operation.
if (!resolved.startsWith(rootPrefix)) continue;
const realPath = await fs.realpath(resolved);
const realRoot = await fs.realpath(root);
const realRootPrefix = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
if (
!realPath.startsWith(realRootPrefix) ||
!resolveFileAccess(
realRoot,
[realPath],
root === resolve(config.configPath) ? config.blackFileList : [],
)
) {
throw new ScriptHistoryError(403, '暂无权限');
}
return realPath;
}
throw new ScriptHistoryError(403, '暂无权限');
}
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, config.tmpPath);
@@ -55,11 +97,7 @@ export default (app: Router) => {
];
if (req.query.path) {
if (
!resolveFileAccess(
config.scriptPath,
[req.query.path as string],
config.blackFileList,
)
!resolveFileAccess(config.scriptPath, [req.query.path as string])
) {
return res.send({ code: 403, message: t('暂无权限') });
}
@@ -115,6 +153,82 @@ export default (app: Router) => {
},
);
const historyError = (error: unknown, res: Response, next: NextFunction) => {
if (error instanceof ScriptHistoryError) {
return res.status(error.status).send({
code: error.status,
message: t(error.message),
...(error instanceof HistoryUnavailableError
? { historyUnavailable: true, currentHash: error.currentHash }
: {}),
});
}
return next(error);
};
const historyQuery = {
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
};
route.get(
'/history',
celebrate({ query: Joi.object(historyQuery).unknown(true) }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const service = Container.get(ScriptHistoryService);
const data = await service.list(
(req.query.path as string) || '',
req.query.filename as string,
);
res.send({ code: 200, data });
} catch (error) {
historyError(error, res, next);
}
},
);
route.get(
'/history/detail',
celebrate({
query: Joi.object({
...historyQuery,
id: Joi.string().uuid().required(),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const service = Container.get(ScriptHistoryService);
const data = await service.detail(
(req.query.path as string) || '',
req.query.filename as string,
req.query.id as string,
);
res.send({ code: 200, data });
} catch (error) {
historyError(error, res, next);
}
},
);
route.put(
'/history/restore',
celebrate({
body: Joi.object({
...historyQuery,
id: Joi.string().uuid().required(),
expectedHash: Joi.string().hex().length(64).required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { path = '', filename, id, expectedHash } = req.body;
const service = Container.get(ScriptHistoryService);
const data = await service.restore(path, filename, id, expectedHash);
res.send({ code: 200, data });
} catch (error) {
historyError(error, res, next);
}
},
);
route.get('/:file', (req: Request, res: Response) => {
return res.send({
code: 410,
@@ -136,6 +250,11 @@ export default (app: Router) => {
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
content: Joi.string().optional().allow(''),
skipHistory: Joi.boolean().optional(),
expectedHash: Joi.string()
.hex()
.length(64)
.when('skipHistory', { is: true, then: Joi.required() }),
originFilename: Joi.string().optional().allow(''),
directory: Joi.string().optional().allow(''),
file: Joi.string().optional().allow(''),
@@ -195,8 +314,56 @@ export default (app: Router) => {
if (!isPathAllowed(filePath) || !isPathAllowed(originFilePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
await fs.mkdir(path, { recursive: true });
// Check the canonical parent directly at the directory creation boundary.
const parentPath = resolve(dirname(filePath));
let parentCreated = false;
for (const writableRoot of config.writePathList) {
const root = resolve(writableRoot);
const rootPrefix = root.endsWith(sep) ? root : root + sep;
if (parentPath === root) {
await fs.mkdir(root, { recursive: true });
} else if (parentPath.startsWith(rootPrefix)) {
await fs.mkdir(parentPath, { recursive: true });
} else {
continue;
}
parentCreated = true;
break;
}
if (!parentCreated) {
return res.send({ code: 403, message: t('暂无权限') });
}
const fileExists = await fileExist(filePath);
if (fileExists && resolveFileAccess(config.scriptPath, [filePath])) {
let removeSource = filename !== originFilename;
let backupSource = '';
if (removeSource) {
const [originRealPath, targetRealPath] = await Promise.all([
existingWritableRealPath(originFilePath),
existingWritableRealPath(filePath),
]);
// Aliases of one script are an in-place save. Removing the source
// would also remove the destination behind a target symlink.
removeSource = originRealPath !== targetRealPath;
backupSource = originRealPath;
}
// Save-as removes the source after committing the destination. Keep
// its original content too: destination history only protects the
// file being overwritten, not the source being deleted.
if (removeSource) {
await fs.copyFile(
backupSource,
join(config.bakPath, originFilename.replace(/\//g, '')),
);
}
const service = Container.get(ScriptHistoryService);
const data = await service.save(path, filename, content, {
skipHistory: req.body.skipHistory,
expectedHash: req.body.expectedHash,
});
if (removeSource) await rmPath(originFilePath);
return res.send({ code: 200, data });
}
if (fileExists) {
await fs.copyFile(
originFilePath,
@@ -209,7 +376,7 @@ export default (app: Router) => {
await writeFileWithLock(filePath, content);
return res.send({ code: 200 });
} catch (e) {
return next(e);
return historyError(e, res, next);
}
},
);
@@ -221,6 +388,11 @@ export default (app: Router) => {
filename: Joi.string().required(),
path: Joi.string().optional().allow(''),
content: Joi.string().required().allow(''),
skipHistory: Joi.boolean().optional(),
expectedHash: Joi.string()
.hex()
.length(64)
.when('skipHistory', { is: true, then: Joi.required() }),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
@@ -238,10 +410,14 @@ export default (app: Router) => {
message: t('暂无权限'),
});
}
await writeFileWithLock(filePath, content);
return res.send({ code: 200 });
const service = Container.get(ScriptHistoryService);
const data = await service.save(path || '', filename, content, {
skipHistory: req.body.skipHistory,
expectedHash: req.body.expectedHash,
});
return res.send({ code: 200, data });
} catch (e) {
return next(e);
return historyError(e, res, next);
}
},
);
@@ -368,12 +544,21 @@ export default (app: Router) => {
if (!isPathAllowed(filePath)) {
return res.send({ code: 403, message: t('暂无权限') });
}
const logPath = join(config.logPath, path, `${name}.swap`);
const logPath = resolveFileAccess(config.logPath, [
path,
`${name}.swap`,
]);
if (!logPath) {
return res.send({ code: 403, message: t('暂无权限') });
}
const scriptService = Container.get(ScriptService);
const result = await scriptService.stopScript(filePath, pid);
setTimeout(() => {
rmPath(logPath);
const cleanupPath = resolveFileAccess(config.logPath, [logPath]);
if (cleanupPath) {
void rmPath(cleanupPath);
}
}, 3000);
res.send(result);
} catch (e) {
+3 -10
View File
@@ -3,7 +3,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston';
import SubscriptionService from '../services/subscription';
import { celebrate, Joi } from 'celebrate';
import CronExpressionParser from 'cron-parser';
import { isValidCronSchedule } from '../shared/cronSchedule';
const route = Router();
export default (app: Router) => {
@@ -58,10 +58,7 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
if (
!req.body.schedule ||
CronExpressionParser.parse(req.body.schedule).hasNext()
) {
if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.create(req.body);
return res.send({ code: 200, data });
@@ -204,11 +201,7 @@ export default (app: Router) => {
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
try {
if (
!req.body.schedule ||
typeof req.body.schedule === 'object' ||
CronExpressionParser.parse(req.body.schedule).hasNext()
) {
if (!req.body.schedule || isValidCronSchedule(req.body.schedule)) {
const subscriptionService = Container.get(SubscriptionService);
const data = await subscriptionService.update(req.body);
return res.send({ code: 200, data });
+18 -10
View File
@@ -8,15 +8,12 @@ import { celebrate, Joi } from 'celebrate';
import UserService from '../services/user';
import { t } from '../shared/i18n';
import { isDefaultAuthInfo } from '../shared/auth';
import {
getUniqPath,
handleLogPath,
parseVersion,
promiseExec,
} from '../config/util';
import { getUniqPath, handleLogPath, parseVersion } from '../config/util';
import { findCronId } from '../shared/cronCommand';
import dayjs from 'dayjs';
import multer from 'multer';
import { logStreamManager } from '../shared/logStreamManager';
import { rateLimit } from 'express-rate-limit';
const route = Router();
const storage = multer.diskStorage({
@@ -249,6 +246,7 @@ export default (app: Router) => {
route.put(
'/command-run',
rateLimit({ windowMs: 60 * 1000, limit: 60 }),
celebrate({
body: Joi.object({
command: Joi.string().required(),
@@ -258,8 +256,13 @@ export default (app: Router) => {
try {
const systemService = Container.get(SystemService);
const command = req.body.command;
const idStr = `cat ${config.crontabFile} | grep -E "${command}" | perl -pe "s|.*ID=(.*) ${command}.*|\\1|" | head -1 | awk -F " " '{print $1}' | xargs echo -n`;
let id = await promiseExec(idStr);
const crontab = await fs
.readFile(config.crontabFile, 'utf8')
.catch((e) => {
if (e.code === 'ENOENT') return '';
throw e;
});
const id = findCronId(crontab, command);
const uniqPath = await getUniqPath(command, id);
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logPath = `${uniqPath}/${logTime}.log`;
@@ -273,8 +276,13 @@ export default (app: Router) => {
},
onEnd: async (cp, endTime, diff) => {
// Close the stream after task completion
await logStreamManager.closeStream(await handleLogPath(logPath));
res.end();
try {
await logStreamManager.closeStream(
await handleLogPath(logPath),
);
} finally {
res.end();
}
},
onError: async (message: string) => {
res.write(message);
+49 -40
View File
@@ -1,14 +1,10 @@
import 'reflect-metadata';
import cluster, { type Worker } from 'cluster';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import type express from 'express';
import { Container } from 'typedi';
import config from './config';
import Logger from './loaders/logger';
import { monitoringMiddleware } from './middlewares/monitoring';
import { errStack } from './config/util';
import { errStack } from './shared/errors';
import { type GrpcServerService } from './services/grpc';
import { type HttpServerService } from './services/http';
@@ -20,24 +16,12 @@ interface WorkerMetadata {
}
class Application {
private app: express.Application;
private httpServerService?: HttpServerService;
private grpcServerService?: GrpcServerService;
private isShuttingDown = false;
private workerMetadataMap = new Map<number, WorkerMetadata>();
private httpWorker?: Worker;
constructor() {
this.app = express();
// 创建一个全局中间件,删除查询参数中的t
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.query.t) {
delete req.query.t;
}
next();
});
}
async start() {
try {
if (cluster.isPrimary) {
@@ -79,6 +63,11 @@ class Application {
);
// If gRPC worker died, restart it and wait for it to be ready
if (metadata.serviceType === 'grpc') {
try {
this.httpWorker?.send('scheduler-unavailable');
} catch (error) {
Logger.warn('Unable to notify HTTP worker of scheduler exit');
}
const newGrpcWorker = this.forkWorker('grpc');
this.waitForWorkerReady(newGrpcWorker, 30000)
.then(() => {
@@ -132,7 +121,14 @@ class Application {
}
private forkWorker(serviceType: string): Worker {
const worker = cluster.fork({ SERVICE_TYPE: serviceType });
const workerEnv: NodeJS.ProcessEnv = { SERVICE_TYPE: serviceType };
// PM2's fork launcher is inherited by our own cluster workers. Their APM
// messages go to this primary, not PM2, and duplicate its sampling work.
// Keep primary monitoring and allow restoring the inherited worker APM.
if (process.env.pm_id !== undefined && process.env.QL_WORKER_APM !== 'true') {
workerEnv.pmx = 'false';
}
const worker = cluster.fork(workerEnv);
this.workerMetadataMap.set(worker.id, {
id: worker.id,
@@ -145,17 +141,37 @@ class Application {
}
private async initializeDatabase() {
const dbLoader = await import('./loaders/db');
await dbLoader.default();
const { runStartupProcess } = await import('./shared/startupProcess');
await runStartupProcess(require.resolve('./bootstrap/database'));
}
private setupMiddlewares() {
this.app.use(helmet({
private async setupMiddlewares(): Promise<express.Application> {
// Only the HTTP worker needs an Express application and its middleware.
const [
{ default: createExpress },
{ default: helmet },
{ default: cors },
{ default: compression },
{ monitoringMiddleware },
] = await Promise.all([
import('express'),
import('helmet'),
import('cors'),
import('compression'),
import('./middlewares/monitoring'),
]);
const app = createExpress();
app.use((req, res, next) => {
if (req.query.t) delete req.query.t;
next();
});
app.use(helmet({
contentSecurityPolicy: false,
}));
this.app.use(cors(config.cors));
this.app.use(compression());
this.app.use(monitoringMiddleware);
app.use(cors(config.cors));
app.use(compression());
app.use(monitoringMiddleware);
return app;
}
private setupMasterShutdown() {
@@ -234,16 +250,16 @@ class Application {
const { initGrpcCerts } = await import('./config/grpcCerts');
await initGrpcCerts();
this.setupMiddlewares();
const app = await this.setupMiddlewares();
const { HttpServerService } = await import('./services/http');
this.httpServerService = Container.get(HttpServerService);
const appLoader = await import('./loaders/app');
await appLoader.default({ app: this.app });
await appLoader.default({ app });
const server = await this.httpServerService.initialize(
this.app,
app,
config.port,
);
@@ -264,17 +280,10 @@ class Application {
process.on('message', async (msg) => {
if (msg === 'shutdown') {
this.gracefulShutdown(serviceType);
} else if (msg === 'reregister-crons' && serviceType === 'http') {
// Re-register cron jobs when gRPC worker restarts
try {
Logger.info('[boot] Received reregister-crons message, re-registering cron jobs...');
const CronService = (await import('./services/cron')).default;
const cronService = Container.get(CronService);
await cronService.autosave_crontab();
Logger.info('[boot] Cron jobs re-registered successfully');
} catch (error) {
Logger.error(`[boot] Failed to re-register cron jobs:\n${errStack(error)}`);
}
} else if (serviceType === 'http' &&
(msg === 'reregister-crons' || msg === 'scheduler-unavailable')) {
const { default: cronClient } = await import('./schedule/client');
cronClient.readiness.invalidate();
}
});
+26
View File
@@ -0,0 +1,26 @@
import { errStack } from '../shared/errors';
// Also terminate if the primary disappears without forwarding a signal.
const onParentDisconnect = () => process.exit(1);
process.once('disconnect', onParentDisconnect);
async function initializeDatabase() {
const { sequelize } = await import('../data');
try {
const { default: loadDatabase } = await import('../loaders/db');
await loadDatabase();
} finally {
await sequelize.close();
}
}
initializeDatabase()
.catch((error) => {
console.error(`[boot] Database initialization failed:\n${errStack(error)}`);
process.exitCode = 1;
})
.finally(() => {
process.removeListener('disconnect', onParentDisconnect);
// Let pending log writes drain and exit naturally after closing SQLite.
if (process.connected) process.disconnect();
});
+1
View File
@@ -51,6 +51,7 @@ export const NotificationModeStringMap = {
19: 'ntfy',
20: 'wxPusherBot',
21: 'wxPusherSpt',
22: 'wpush',
} as const;
export const LINUX_DEPENDENCE_COMMAND: Record<
+93 -45
View File
@@ -1,6 +1,5 @@
import { execSync } from 'child_process';
import { execFileSync } from 'child_process';
import * as fs from 'fs/promises';
import * as os from 'os';
import path from 'path';
import config from './index';
import { fileExist } from './util';
@@ -24,71 +23,120 @@ const clientCertPath = path.join(certDir, 'client.crt');
let cachedConfig: GrpcTlsConfig | null = null;
function run(cmd: string, execOpts?: Record<string, unknown>): string {
const opts = { stdio: 'pipe', timeout: 30000, encoding: 'utf-8', ...execOpts } as any;
return (execSync(cmd, opts) as string).trim();
}
async function tmpFile(prefix: string): Promise<string> {
const dir = (await fileExist(certDir)) ? certDir : os.tmpdir();
await fs.mkdir(dir, { recursive: true });
return path.join(dir, `.${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}.pem`);
function run(args: string[]): string {
return execFileSync('openssl', args, {
stdio: 'pipe',
timeout: 30000,
encoding: 'utf-8',
}).trim();
}
async function generateAllCerts(): Promise<GrpcTlsConfig> {
Logger.info('[boot] Generating gRPC mTLS certificates...');
const caKeyTmp = await tmpFile('ca_key');
const caCertTmp = await tmpFile('ca_cert');
const serverKeyTmp = await tmpFile('server_key');
const serverCsrTmp = await tmpFile('server_csr');
const serverExtTmp = await tmpFile('server_ext');
const clientKeyTmp = await tmpFile('client_key');
const clientCsrTmp = await tmpFile('client_csr');
const clientExtTmp = await tmpFile('client_ext');
const srlTmp = path.join(path.dirname(caKeyTmp), '.grpc_ca.srl');
await fs.mkdir(certDir, { recursive: true, mode: 0o700 });
// Atomically reserve a private workspace for keys, CSRs and OpenSSL side files.
const workspace = await fs.mkdtemp(path.join(certDir, '.generate-'));
const caKeyTmp = path.join(workspace, 'ca.key');
const caCertTmp = path.join(workspace, 'ca.crt');
const serverKeyTmp = path.join(workspace, 'server.key');
const serverCsrTmp = path.join(workspace, 'server.csr');
const serverExtTmp = path.join(workspace, 'server.ext');
const clientKeyTmp = path.join(workspace, 'client.key');
const clientCsrTmp = path.join(workspace, 'client.csr');
const clientExtTmp = path.join(workspace, 'client.ext');
const cleanup = async () => {
for (const f of [caKeyTmp, caCertTmp, serverKeyTmp, serverCsrTmp, serverExtTmp,
clientKeyTmp, clientCsrTmp, clientExtTmp, srlTmp]) {
try { await fs.unlink(f); } catch {}
}
await fs.rm(workspace, { recursive: true, force: true });
};
try {
// 1. CA(私钥直接存盘,证书写入临时文件供签发使用)
run(`openssl genrsa -out '${caKeyTmp}' 2048 2>/dev/null`);
run(`openssl req -new -x509 -days 3650 -key '${caKeyTmp}' -out '${caCertTmp}' -subj '/CN=qinglong-ca/O=qinglong/C=CN' 2>/dev/null`);
run(['genrsa', '-out', caKeyTmp, '2048']);
run([
'req',
'-new',
'-x509',
'-days',
'3650',
'-key',
caKeyTmp,
'-out',
caCertTmp,
'-subj',
'/CN=qinglong-ca/O=qinglong/C=CN',
]);
const caKey = await fs.readFile(caKeyTmp, 'utf-8');
const caCert = await fs.readFile(caCertTmp, 'utf-8');
await fs.mkdir(certDir, { recursive: true });
await fs.writeFile(caKeyPath, caKey, { mode: 0o600 });
// 2. 服务端
run(`openssl genrsa -out '${serverKeyTmp}' 2048 2>/dev/null`);
run(`openssl req -new -key '${serverKeyTmp}' -out '${serverCsrTmp}' -subj '/CN=grpc-server' 2>/dev/null`);
await fs.writeFile(serverExtTmp, 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n');
const serverCert = run(
`openssl x509 -req -days 3650 -in '${serverCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${serverExtTmp}' 2>/dev/null`,
run(['genrsa', '-out', serverKeyTmp, '2048']);
run([
'req',
'-new',
'-key',
serverKeyTmp,
'-out',
serverCsrTmp,
'-subj',
'/CN=grpc-server',
]);
await fs.writeFile(
serverExtTmp,
'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1\n',
);
const serverCert = run([
'x509',
'-req',
'-days',
'3650',
'-in',
serverCsrTmp,
'-CA',
caCertTmp,
'-CAkey',
caKeyTmp,
'-CAserial',
path.join(workspace, 'ca.srl'),
'-CAcreateserial',
'-extfile',
serverExtTmp,
]);
const serverKey = await fs.readFile(serverKeyTmp, 'utf-8');
// 3. 客户端
run(`openssl genrsa -out '${clientKeyTmp}' 2048 2>/dev/null`);
run(`openssl req -new -key '${clientKeyTmp}' -out '${clientCsrTmp}' -subj '/CN=grpc-client' 2>/dev/null`);
run(['genrsa', '-out', clientKeyTmp, '2048']);
run([
'req',
'-new',
'-key',
clientKeyTmp,
'-out',
clientCsrTmp,
'-subj',
'/CN=grpc-client',
]);
await fs.writeFile(clientExtTmp, 'extendedKeyUsage=clientAuth\n');
const clientCert = run(
`openssl x509 -req -days 3650 -in '${clientCsrTmp}' -CA '${caCertTmp}' -CAkey '${caKeyTmp}' -CAcreateserial -extfile '${clientExtTmp}' 2>/dev/null`,
);
const clientCert = run([
'x509',
'-req',
'-days',
'3650',
'-in',
clientCsrTmp,
'-CA',
caCertTmp,
'-CAkey',
caKeyTmp,
'-CAserial',
path.join(workspace, 'ca.srl'),
'-CAcreateserial',
'-extfile',
clientExtTmp,
]);
const clientKey = await fs.readFile(clientKeyTmp, 'utf-8');
await cleanup();
Logger.info('[boot] gRPC mTLS certificates generated successfully');
return { caCert, serverCert, serverKey, clientCert, clientKey };
} catch (e) {
} finally {
await cleanup();
throw e;
}
}
+23 -26
View File
@@ -1,5 +1,7 @@
import { Subscription } from '../data/subscription';
import isNil from 'lodash/isNil';
import { shellQuote } from '../shared/shellQuote';
import { getSubscriptionSshAlias } from '../shared/subscriptionPath';
export function formatUrl(doc: Subscription) {
let url = doc.url;
@@ -7,7 +9,7 @@ export function formatUrl(doc: Subscription) {
if (doc.type === 'private-repo') {
if (doc.pull_type === 'ssh-key') {
host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1');
url = doc.url!.replace(host, doc.alias);
url = doc.url!.replace(host, getSubscriptionSshAlias(doc.alias));
} else {
host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1');
const { username, password } = doc.pull_option as any;
@@ -18,29 +20,24 @@ export function formatUrl(doc: Subscription) {
}
export function formatCommand(doc: Subscription, url?: string) {
let command = `SUB_ID=${doc.id} ql `;
let _url = url || formatUrl(doc).url;
const {
type,
whitelist,
blacklist,
dependences,
branch,
extensions,
proxy,
autoAddCron,
autoDelCron,
} = doc;
if (type === 'file') {
command += `raw "${_url}" "${proxy || ''}" "${
isNil(autoAddCron) ? true : Boolean(autoAddCron)
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
} else {
command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${
dependences || ''
}" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${
isNil(autoAddCron) ? true : Boolean(autoAddCron)
}" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`;
}
return command;
const args =
doc.type === 'file'
? ['raw', url || formatUrl(doc).url, doc.proxy || '']
: [
'repo',
url || formatUrl(doc).url,
doc.whitelist || '',
doc.blacklist || '',
doc.dependences || '',
doc.branch || '',
doc.extensions || '',
doc.proxy || '',
];
args.push(
String(isNil(doc.autoAddCron) ? true : Boolean(doc.autoAddCron)),
String(isNil(doc.autoDelCron) ? true : Boolean(doc.autoDelCron)),
);
return `SUB_ID=${shellQuote(String(doc.id))} ql ${args
.map((arg) => shellQuote(arg!))
.join(' ')}`;
}
+127 -79
View File
@@ -12,6 +12,8 @@ import { DependenceTypes } from '../data/dependence';
import { FormData } from 'undici';
import os from 'os';
import { maybeSudo, isInContainer } from './container';
import { resolveFileAccess } from '../shared/fileAccess';
import { mirrorWriteCommand, normalizeMirror, rewriteMirrorSources } from '../shared/linuxMirror';
export * from './share';
@@ -144,7 +146,8 @@ export async function handleLogPath(
logPath: string,
data: string = '',
): Promise<string> {
const absolutePath = path.resolve(config.logPath, logPath);
const absolutePath = resolveFileAccess(config.logPath, [logPath]);
if (!absolutePath) throw new Error('Log path is outside the log directory');
const logFileExist = await fileExist(absolutePath);
if (!logFileExist) {
await createFile(absolutePath, data);
@@ -487,18 +490,69 @@ export function psTree(pid: number): Promise<number[]> {
});
}
export async function killTask(pid: number) {
const pids = await psTree(pid);
if (pids.length) {
export async function killTask(pid: number, waitForExit = false) {
const descendants = await psTree(pid);
const signal = (target: number, sig: NodeJS.Signals) => {
try {
[pid, ...pids].reverse().forEach((x) => {
process.kill(x, 15);
});
} catch (error) { }
} else {
process.kill(pid, 2);
process.kill(target, sig);
} catch (error: any) {
if (error.code !== 'ESRCH') throw error;
}
};
if (!waitForExit) {
if (descendants.length) {
// A child may exit after psTree; keep signalling the remaining tree.
for (const target of [pid, ...descendants].reverse()) {
signal(target, 'SIGTERM');
}
} else signal(pid, 'SIGINT');
return;
}
const pids = [...descendants.reverse(), pid];
for (const target of pids) signal(target, 'SIGTERM');
const alive = async (target: number) => {
try {
process.kill(target, 0);
} catch (error: any) {
if (error.code === 'ESRCH') return false;
throw error;
}
if (process.platform === 'linux') {
try {
const stat = await fs.readFile(`/proc/${target}/stat`, 'utf8');
// The command field may contain spaces and parentheses. Zombies have
// exited even while their parent has not reaped the PID yet.
const state = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[0];
if (['Z', 'X', 'x'].includes(state)) return false;
} catch {
// /proc may be unavailable, or the process may have just exited.
// Retain the portable signal probe rather than assuming it is dead.
try {
process.kill(target, 0);
} catch (error: any) {
if (error.code === 'ESRCH') return false;
throw error;
}
}
}
return true;
};
const wait = async () => {
const deadline = Date.now() + 1000;
let remaining = pids;
while (true) {
const states = await Promise.all(remaining.map(alive));
remaining = remaining.filter((_, index) => states[index]);
if (!remaining.length || Date.now() >= deadline) return remaining;
await new Promise((resolve) => setTimeout(resolve, 20));
}
};
let remaining = await wait();
if (!remaining.length) return;
for (const target of remaining) signal(target, 'SIGKILL');
remaining = await wait();
if (remaining.length)
throw new Error(`Task processes did not exit: ${remaining.join(', ')}`);
}
export async function getPid(cmd: string) {
@@ -598,11 +652,7 @@ export function safeJSONParse(value?: string) {
}
}
export function errStack(error: unknown): string {
return error instanceof Error && error.stack
? error.stack
: String(error);
}
export { errStack } from '../shared/errors';
export async function rmPath(path: string) {
try {
@@ -757,75 +807,73 @@ export async function detectOS(): Promise<
return osType;
}
async function getCurrentMirrorDomain(
filePath: string,
): Promise<string | null> {
const fileContent = await fs.readFile(filePath, 'utf8');
const lines = fileContent.split('\n');
for (const line of lines) {
if (line.trim().startsWith('#')) {
continue;
}
const match = line.match(/https?:\/\/[^\/]+/);
if (match) {
return match[0];
}
}
return null;
}
async function replaceDomainInFile(
filePath: string,
oldDomainWithScheme: string,
newDomainWithScheme: string,
): Promise<void> {
let fileContent = await fs.readFile(filePath, 'utf8');
let updatedContent = fileContent.replace(
new RegExp(oldDomainWithScheme, 'g'),
newDomainWithScheme,
);
if (!newDomainWithScheme.endsWith('/')) {
newDomainWithScheme += '/';
}
await writeFileWithLock(filePath, updatedContent);
}
async function _updateLinuxMirror(
osType: string,
mirrorDomainWithScheme: string,
mirror: string,
): Promise<string> {
const defaults: Record<string, string> = {
Debian: 'http://deb.debian.org/debian',
Ubuntu: 'http://archive.ubuntu.com/ubuntu',
Alpine: 'http://dl-cdn.alpinelinux.org/alpine',
};
if (!defaults[osType])
throw new Error('Unsupported OS type for updating mirrors.');
const target = mirror || defaults[osType];
normalizeMirror(target);
const S = isInContainer() ? 'sudo ' : '';
let filePath: string, currentDomainWithScheme: string | null;
switch (osType) {
case 'Debian':
filePath = '/etc/apt/sources.list.d/debian.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
const update =
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
let file =
osType === 'Alpine'
? '/etc/apk/repositories'
: `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
let content = '';
try {
content = await fs.readFile(file, 'utf8');
} catch (error: any) {
if (error.code !== 'ENOENT') throw error;
if (osType !== 'Alpine') {
try {
file = '/etc/apt/sources.list';
content = await fs.readFile(file, 'utf8');
} catch (legacyError: any) {
if (legacyError.code !== 'ENOENT') throw legacyError;
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
}
case 'Ubuntu':
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${S}apt-get update`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${S}apt-get update`;
}
case 'Alpine':
filePath = '/etc/apk/repositories';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}|g' ${filePath} || (mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath}) && apk update`;
} else {
return `mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath} && apk update`;
}
default:
throw Error('Unsupported OS type for updating mirrors.');
}
}
if (!content.trim()) {
const release = await fs.readFile('/etc/os-release', 'utf8');
if (osType === 'Alpine') {
const version = release.match(/^VERSION_ID=["']?(\d+\.\d+)/m)?.[1];
if (!version)
throw new Error('Cannot determine Alpine repository version');
content = `${defaults.Alpine}/v${version}/main\n${defaults.Alpine}/v${version}/community\n`;
} else {
// The fallback may have read an empty legacy .list. Deb822 stanzas must
// be written to a .sources file, leaving the legacy file untouched.
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
const codename = release.match(/^VERSION_CODENAME=["']?([\w-]+)/m)?.[1];
if (!codename)
throw new Error('Cannot determine APT repository codename');
const ubuntu = osType === 'Ubuntu';
content = `Types: deb\nURIs: ${
defaults[osType]
}\nSuites: ${codename} ${codename}-updates${
ubuntu ? ` ${codename}-backports` : ''
}\nComponents: ${
ubuntu ? 'main restricted universe multiverse' : 'main'
}\nSigned-By: /usr/share/keyrings/${osType.toLowerCase()}-archive-keyring.gpg\n`;
if (!ubuntu)
content += `\nTypes: deb\nURIs: http://security.debian.org/debian-security\nSuites: ${codename}-security\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\n`;
}
}
return mirrorWriteCommand(
file,
rewriteMirrorSources(content, target),
S,
update,
);
}
export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
+2
View File
@@ -13,6 +13,7 @@ export class Crontab {
pid?: number;
isDisabled?: 1 | 0;
log_path?: string;
queued_token?: string | null;
isPinned?: 1 | 0;
labels?: string[];
last_running_time?: number;
@@ -83,6 +84,7 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
isDisabled: DataTypes.NUMBER,
isPinned: DataTypes.NUMBER,
log_path: DataTypes.STRING,
queued_token: DataTypes.STRING,
labels: DataTypes.JSON,
last_running_time: DataTypes.NUMBER,
last_execution_time: DataTypes.NUMBER,
+9 -1
View File
@@ -21,6 +21,7 @@ export enum NotificationMode {
'ntfy' = 'ntfy',
'wxPusherBot' = 'wxPusherBot',
'openiLink' = 'openiLink',
'wpush' = 'wpush',
}
abstract class NotificationBaseInfo {
@@ -172,6 +173,12 @@ export class OpeniLinkNotification extends NotificationBaseInfo {
public openiLinkContextToken = '';
}
export class WpushNotification extends NotificationBaseInfo {
public wpushApiKey = '';
public wpushChannel = '';
public wpushTopicCode = '';
}
export interface NotificationInfo
extends GoCqHttpBotNotification,
GotifyNotification,
@@ -195,4 +202,5 @@ export interface NotificationInfo
NtfyNotification,
WxPusherBotNotification,
WxPusherSptNotification,
OpeniLinkNotification {}
OpeniLinkNotification,
WpushNotification {}
+1 -1
View File
@@ -20,7 +20,7 @@ export class Subscription {
pull_option?:
| { private_key: string }
| { username: string; password: string };
pid?: number;
pid?: number | null;
is_disabled?: 1 | 0;
log_path?: string;
alias: string;
+3 -35
View File
@@ -9,6 +9,7 @@ import { CrontabViewModel } from '../data/cronView';
import { CrontabStatModel } from '../data/cronStats';
import { RunningInstanceModel } from '../data/runningInstance';
import { sequelize } from '../data';
import { migrateSchema } from '../shared/schemaMigrations';
export default async () => {
try {
@@ -22,44 +23,11 @@ export default async () => {
await CrontabStatModel.sync();
await RunningInstanceModel.sync();
// 初始化新增字段
const migrations = [
{
table: 'CrontabViews',
column: 'filterRelation',
type: 'VARCHAR(255)',
},
{ table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' },
{ table: 'CrontabViews', column: 'type', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' },
{ table: 'Crontabs', column: 'sub_id', type: 'NUMBER' },
{ table: 'Crontabs', column: 'extra_schedules', type: 'JSON' },
{ table: 'Crontabs', column: 'task_before', type: 'TEXT' },
{ table: 'Crontabs', column: 'task_after', type: 'TEXT' },
{ table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' },
{
table: 'Crontabs',
column: 'allow_multiple_instances',
type: 'NUMBER',
},
{ table: 'Crontabs', column: 'work_dir', type: 'VARCHAR(255)' },
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
{ table: 'Envs', column: 'labels', type: 'JSON' },
];
for (const migration of migrations) {
try {
await sequelize.query(
`alter table ${migration.table} add column ${migration.column} ${migration.type}`,
);
} catch (error) {
// Column already exists or other error, continue
}
}
await migrateSchema(sequelize);
Logger.info('[boot] DB loaded');
} catch (error) {
Logger.error('[boot] DB load failed', error);
throw error;
}
};
+45 -1
View File
@@ -17,6 +17,50 @@ async function linkCommand() {
}
async function linkCommandToDir(commandDir: string) {
const cliRoot = process.env.QL_CLI_ROOT;
if (cliRoot) {
if (!path.isAbsolute(cliRoot)) {
throw new Error('QL_CLI_ROOT must be an absolute CLI installation path');
}
const { installCliEntrypoints } = require(path.join(
cliRoot,
'dist/local/entrypoints.js',
));
await installCliEntrypoints(commandDir, cliRoot);
const { installCronEntrypoint } = require(path.join(
cliRoot,
'dist/local/cronEntrypoint.js',
));
await installCronEntrypoint(commandDir, cliRoot, config.crontabFile, {
...process.env,
QL_DIR: config.rootPath,
QL_DATA_DIR: config.dataPath,
});
// Container-wide legacy links may precede ~/bin. Ensure panel children use
// the explicitly selected entries without changing system-wide links.
process.env.PATH = [
commandDir,
...(process.env.PATH?.split(path.delimiter) ?? []).filter(
(entry) => entry !== commandDir,
),
].join(path.delimiter);
return;
}
const cronBridge = path.join(commandDir, 'crontab');
try {
if (
(await fs.lstat(cronBridge)).isFile() &&
(await fs.readFile(cronBridge, 'utf8')).includes(
'// QingLong CLI crontab bridge',
)
) {
await fs.unlink(cronBridge);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
const linkShell = [
{
src: 'update.sh',
@@ -39,7 +83,7 @@ async function linkCommandToDir(commandDir: string) {
if (stats) {
await fs.unlink(tmpTarget);
}
} catch (error) { }
} catch (error) {}
await fs.symlink(source, tmpTarget);
await fs.rename(tmpTarget, target);
+10 -1
View File
@@ -14,6 +14,7 @@ import { AuthInfo } from '../data/system';
import path from 'path';
import { t } from '../shared/i18n';
import { AppScope } from '../data/open';
import uniqueConstraintError from '../middlewares/uniqueConstraintError';
import protectedPathCase from '../middlewares/protectedPathCase';
function resolveTrustProxy(value = process.env.QL_TRUST_PROXY) {
@@ -178,7 +179,14 @@ export default ({ app }: { app: Application }) => {
app.use(rewrite('/open/*', '/api/$1'));
app.use(config.api.prefix, routes());
app.get('*', (_, res, next) => {
app.get('*', (req, res, next) => {
// Don't serve index.html for API routes
if (
req.path === config.api.prefix ||
req.path.startsWith(`${config.api.prefix}/`)
) {
return res.status(404).json({ code: 404, message: 'Not Found' });
}
const indexPath = path.join(frontendPath, 'index.html');
res.sendFile(indexPath, (err) => {
if (err) {
@@ -190,6 +198,7 @@ export default ({ app }: { app: Application }) => {
});
app.use(errors());
app.use(uniqueConstraintError);
app.use(
(
+14 -26
View File
@@ -13,11 +13,11 @@ import { AuthDataType, SystemModel } from '../data/system';
import SystemService from '../services/system';
import UserService from '../services/user';
import { writeFile, readFile } from 'fs/promises';
import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../config/util';
import { fileExist, isDemoEnv, safeJSONParse } from '../config/util';
import OpenService from '../services/open';
import { shareStore } from '../shared/store';
import Logger from './logger';
import { AppModel } from '../data/open';
import cronClient from '../schedule/client';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
import { setLang, systemLang } from '../shared/i18n';
@@ -30,24 +30,7 @@ export default async () => {
const openService = Container.get(OpenService);
// 初始化增加系统配置
let systemApp = (
await AppModel.findOne({
where: { name: 'system' },
})
)?.get({ plain: true });
if (!systemApp) {
systemApp = await AppModel.create({
name: 'system',
scopes: ['crons', 'system', 'dashboard'],
client_id: createRandomString(12, 12),
client_secret: createRandomString(24, 24),
});
} else if (!systemApp.scopes.includes('dashboard')) {
await AppModel.update(
{ scopes: [...systemApp.scopes, 'dashboard'] },
{ where: { name: 'system' } },
);
}
await openService.initializeSystemApp();
const [systemConfig] = await SystemModel.findOrCreate({
where: { type: AuthDataType.systemConfig },
});
@@ -143,7 +126,11 @@ export default async () => {
// 清空所有运行中的实例记录(服务重启后进程已不存在)
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped },
{
status: InstanceStatus.stopped,
finished_at: Math.floor(Date.now() / 1000),
// The previous process's exit code cannot be recovered after a restart.
},
{ where: { status: InstanceStatus.running } },
);
@@ -236,14 +223,15 @@ export default async () => {
} catch { }
// 初始化保存一次ck和定时任务数据
await cronService.autosave_crontab();
cronClient.readiness.configure(async () => {
await cronService.autosave_crontab(true);
await systemService.restoreCronConcurrency();
});
await cronClient.readiness.recover();
await envService.set_envs();
const authInfo = await userService.getAuthInfo();
const apps = await openService.findApps();
await shareStore.updateAuthInfo(authInfo);
if (apps?.length) {
await shareStore.updateApps(apps);
}
await openService.refreshApps();
};
+10 -1
View File
@@ -7,6 +7,8 @@ import config from '../config';
import { fileExist } from '../config/util';
import { join } from 'path';
import { t } from '../shared/i18n';
import Logger from './logger';
import { assertSubscriptionAlias } from '../shared/subscriptionPath';
export default async () => {
const systemService = Container.get(SystemService);
@@ -24,6 +26,7 @@ export default async () => {
if (await fileExist(tokenFile)) {
tokenCommand = `node ${tokenFile}`;
}
tokenCommand += ' --renew';
const cron = {
id: NaN,
name: t('生成token'),
@@ -70,6 +73,12 @@ export default async () => {
await subscriptionService.setSshConfig();
const subs = await subscriptionService.list();
for (const sub of subs) {
subscriptionService.handleTask(sub.get({ plain: true }), !sub.is_disabled);
try {
const doc = sub.get({ plain: true });
assertSubscriptionAlias(doc.alias);
await subscriptionService.handleTask(doc, !sub.is_disabled);
} catch (error) {
Logger.warn('跳过订阅 %s 的无效定时配置: %s', sub.id, error);
}
}
};
+60 -20
View File
@@ -1,4 +1,4 @@
import sockJs from 'sockjs';
import sockJs, { Connection } from 'sockjs';
import { Server } from 'http';
import { Container } from 'typedi';
import SockService from '../services/sock';
@@ -13,6 +13,41 @@ export default async ({ server }: { server: Server }) => {
log: () => {},
});
const sockService = Container.get(SockService);
const sessions = new Map<Connection, { token: string; platform: string }>();
let sessionTimer: ReturnType<typeof setInterval> | undefined;
let checking = false;
const checkSessions = async () => {
if (checking || sessions.size === 0) return;
checking = true;
// Connections accepted during this read must not use an older snapshot.
const batch = [...sessions];
try {
const current = await shareStore.getAuthInfo();
// Reuse validation only within this synchronous check of one auth snapshot.
const validated = new Map<string, Map<string, boolean>>();
for (const [conn, { token, platform }] of batch) {
if (!sessions.has(conn)) continue;
let platforms = validated.get(token);
if (!platforms) {
platforms = new Map();
validated.set(token, platforms);
}
let valid = platforms.get(platform);
if (valid === undefined) {
valid = isValidToken(current, token, platform, config.jwt.secret);
platforms.set(platform, valid);
}
if (!valid) conn.close('401');
}
} catch {
for (const [conn] of batch) {
if (sessions.has(conn)) conn.close('401');
}
} finally {
checking = false;
}
};
echo.on('connection', async (conn) => {
if (!conn.headers || !conn.url || !conn.pathname) {
@@ -20,35 +55,40 @@ export default async ({ server }: { server: Server }) => {
return;
}
const authInfo = await shareStore.getAuthInfo();
let closed = false;
conn.on('close', () => {
closed = true;
sessions.delete(conn);
sockService.removeClient(conn);
if (sessions.size === 0 && sessionTimer) {
clearInterval(sessionTimer);
sessionTimer = undefined;
}
});
let authInfo;
try {
authInfo = await shareStore.getAuthInfo();
} catch {
if (!closed) conn.close('401');
return;
}
if (closed) return;
const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop';
const headerToken = conn.url.replace(`${conn.pathname}?token=`, '');
if (isValidToken(authInfo, headerToken, platform, config.jwt.secret)) {
sockService.addClient(conn);
const checkSession = setInterval(async () => {
try {
const current = await shareStore.getAuthInfo();
if (
!isValidToken(current, headerToken, platform, config.jwt.secret)
) {
conn.close('401');
}
} catch {
conn.close('401');
}
}, 1000);
checkSession.unref();
sessions.set(conn, { token: headerToken, platform });
if (!sessionTimer) {
sessionTimer = setInterval(checkSessions, 1000);
sessionTimer.unref();
}
conn.on('data', (message) => {
conn.write(message);
});
conn.on('close', function () {
clearInterval(checkSession);
sockService.removeClient(conn);
});
return;
}
+14
View File
@@ -0,0 +1,14 @@
import { ErrorRequestHandler } from 'express';
import { UniqueConstraintError } from 'sequelize';
import { t } from '../shared/i18n';
// Handle the database constraint itself so concurrent writes are covered too.
const uniqueConstraintError: ErrorRequestHandler = (err, req, res, next) => {
if (!(err instanceof UniqueConstraintError)) return next(err);
// Constraint details can contain environment values or application credentials.
res
.status(409)
.json({ code: 409, message: t('资源已存在,请检查重复的名称或值') });
};
export default uniqueConstraintError;
+6 -1
View File
@@ -152,6 +152,7 @@ enum NotificationMode {
ntfy = 19;
wxPusherBot = 20;
wxPusherSpt = 21;
wpush = 22;
}
message NotificationInfo {
@@ -247,6 +248,10 @@ message NotificationInfo {
optional string wxPusherBotUids = 68;
optional string wxPusherSptList = 70;
optional string wpushApiKey = 71;
optional string wpushChannel = 72;
optional string wpushTopicCode = 73;
}
message SystemNotifyRequest {
@@ -275,4 +280,4 @@ service Api {
rpc EnableCrons(EnableCronsRequest) returns (Response) {}
rpc DisableCrons(DisableCronsRequest) returns (Response) {}
rpc RunCrons(RunCronsRequest) returns (Response) {}
}
}
+61 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.21.12
// protoc v3.17.3
// source: back/protos/api.proto
/* eslint-disable */
@@ -44,6 +44,7 @@ export enum NotificationMode {
ntfy = 19,
wxPusherBot = 20,
wxPusherSpt = 21,
wpush = 22,
UNRECOGNIZED = -1,
}
@@ -115,6 +116,9 @@ export function notificationModeFromJSON(object: any): NotificationMode {
case 21:
case "wxPusherSpt":
return NotificationMode.wxPusherSpt;
case 22:
case "wpush":
return NotificationMode.wpush;
case -1:
case "UNRECOGNIZED":
default:
@@ -168,6 +172,8 @@ export function notificationModeToJSON(object: NotificationMode): string {
return "wxPusherBot";
case NotificationMode.wxPusherSpt:
return "wxPusherSpt";
case NotificationMode.wpush:
return "wpush";
case NotificationMode.UNRECOGNIZED:
default:
return "UNRECOGNIZED";
@@ -400,6 +406,9 @@ export interface NotificationInfo {
wxPusherBotTopicIds?: string | undefined;
wxPusherBotUids?: string | undefined;
wxPusherSptList?: string | undefined;
wpushApiKey?: string | undefined;
wpushChannel?: string | undefined;
wpushTopicCode?: string | undefined;
}
export interface SystemNotifyRequest {
@@ -2967,6 +2976,9 @@ function createBaseNotificationInfo(): NotificationInfo {
wxPusherBotTopicIds: undefined,
wxPusherBotUids: undefined,
wxPusherSptList: undefined,
wpushApiKey: undefined,
wpushChannel: undefined,
wpushTopicCode: undefined,
};
}
@@ -3182,6 +3194,15 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.wxPusherSptList !== undefined) {
writer.uint32(562).string(message.wxPusherSptList);
}
if (message.wpushApiKey !== undefined) {
writer.uint32(570).string(message.wpushApiKey);
}
if (message.wpushChannel !== undefined) {
writer.uint32(578).string(message.wpushChannel);
}
if (message.wpushTopicCode !== undefined) {
writer.uint32(586).string(message.wpushTopicCode);
}
return writer;
},
@@ -3752,6 +3773,30 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.wxPusherSptList = reader.string();
continue;
}
case 71: {
if (tag !== 570) {
break;
}
message.wpushApiKey = reader.string();
continue;
}
case 72: {
if (tag !== 578) {
break;
}
message.wpushChannel = reader.string();
continue;
}
case 73: {
if (tag !== 586) {
break;
}
message.wpushTopicCode = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
@@ -3845,6 +3890,9 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
: undefined,
wxPusherBotUids: isSet(object.wxPusherBotUids) ? globalThis.String(object.wxPusherBotUids) : undefined,
wxPusherSptList: isSet(object.wxPusherSptList) ? globalThis.String(object.wxPusherSptList) : undefined,
wpushApiKey: isSet(object.wpushApiKey) ? globalThis.String(object.wpushApiKey) : undefined,
wpushChannel: isSet(object.wpushChannel) ? globalThis.String(object.wpushChannel) : undefined,
wpushTopicCode: isSet(object.wpushTopicCode) ? globalThis.String(object.wpushTopicCode) : undefined,
};
},
@@ -4060,6 +4108,15 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
if (message.wxPusherSptList !== undefined) {
obj.wxPusherSptList = message.wxPusherSptList;
}
if (message.wpushApiKey !== undefined) {
obj.wpushApiKey = message.wpushApiKey;
}
if (message.wpushChannel !== undefined) {
obj.wpushChannel = message.wpushChannel;
}
if (message.wpushTopicCode !== undefined) {
obj.wpushTopicCode = message.wpushTopicCode;
}
return obj;
},
@@ -4138,6 +4195,9 @@ export const NotificationInfo: MessageFns<NotificationInfo> = {
message.wxPusherBotTopicIds = object.wxPusherBotTopicIds ?? undefined;
message.wxPusherBotUids = object.wxPusherBotUids ?? undefined;
message.wxPusherSptList = object.wxPusherSptList ?? undefined;
message.wpushApiKey = object.wpushApiKey ?? undefined;
message.wpushChannel = object.wpushChannel ?? undefined;
message.wpushTopicCode = object.wpushTopicCode ?? undefined;
return message;
},
};
+8 -1
View File
@@ -5,6 +5,7 @@ package com.ql.cron;
service Cron {
rpc addCron(AddCronRequest) returns (AddCronResponse);
rpc delCron(DeleteCronRequest) returns (DeleteCronResponse);
rpc setConcurrency(SetConcurrencyRequest) returns (AddCronResponse);
}
message ISchedule { string schedule = 1; }
@@ -17,10 +18,16 @@ message ICron {
string name = 5;
}
message AddCronRequest { repeated ICron crons = 1; }
message AddCronRequest {
repeated ICron crons = 1;
bool replace = 2;
}
message AddCronResponse {}
message DeleteCronRequest { repeated string ids = 1; }
message DeleteCronResponse {}
// Zero restores the default limit.
message SetConcurrencyRequest { int32 concurrency = 1; }
+110 -3
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.21.12
// protoc v5.28.3
// source: back/protos/cron.proto
/* eslint-disable */
@@ -35,6 +35,7 @@ export interface ICron {
export interface AddCronRequest {
crons: ICron[];
replace: boolean;
}
export interface AddCronResponse {
@@ -47,6 +48,11 @@ export interface DeleteCronRequest {
export interface DeleteCronResponse {
}
/** Zero restores the default limit. */
export interface SetConcurrencyRequest {
concurrency: number;
}
function createBaseISchedule(): ISchedule {
return { schedule: "" };
}
@@ -232,7 +238,7 @@ export const ICron: MessageFns<ICron> = {
};
function createBaseAddCronRequest(): AddCronRequest {
return { crons: [] };
return { crons: [], replace: false };
}
export const AddCronRequest: MessageFns<AddCronRequest> = {
@@ -240,6 +246,9 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
for (const v of message.crons) {
ICron.encode(v!, writer.uint32(10).fork()).join();
}
if (message.replace !== false) {
writer.uint32(16).bool(message.replace);
}
return writer;
},
@@ -258,6 +267,14 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
message.crons.push(ICron.decode(reader, reader.uint32()));
continue;
}
case 2: {
if (tag !== 16) {
break;
}
message.replace = reader.bool();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
@@ -268,7 +285,10 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
},
fromJSON(object: any): AddCronRequest {
return { crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [] };
return {
crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [],
replace: isSet(object.replace) ? globalThis.Boolean(object.replace) : false,
};
},
toJSON(message: AddCronRequest): unknown {
@@ -276,6 +296,9 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
if (message.crons?.length) {
obj.crons = message.crons.map((e) => ICron.toJSON(e));
}
if (message.replace !== false) {
obj.replace = message.replace;
}
return obj;
},
@@ -285,6 +308,7 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
fromPartial<I extends Exact<DeepPartial<AddCronRequest>, I>>(object: I): AddCronRequest {
const message = createBaseAddCronRequest();
message.crons = object.crons?.map((e) => ICron.fromPartial(e)) || [];
message.replace = object.replace ?? false;
return message;
},
};
@@ -433,6 +457,64 @@ export const DeleteCronResponse: MessageFns<DeleteCronResponse> = {
},
};
function createBaseSetConcurrencyRequest(): SetConcurrencyRequest {
return { concurrency: 0 };
}
export const SetConcurrencyRequest: MessageFns<SetConcurrencyRequest> = {
encode(message: SetConcurrencyRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.concurrency !== 0) {
writer.uint32(8).int32(message.concurrency);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): SetConcurrencyRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseSetConcurrencyRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 8) {
break;
}
message.concurrency = reader.int32();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): SetConcurrencyRequest {
return { concurrency: isSet(object.concurrency) ? globalThis.Number(object.concurrency) : 0 };
},
toJSON(message: SetConcurrencyRequest): unknown {
const obj: any = {};
if (message.concurrency !== 0) {
obj.concurrency = Math.round(message.concurrency);
}
return obj;
},
create<I extends Exact<DeepPartial<SetConcurrencyRequest>, I>>(base?: I): SetConcurrencyRequest {
return SetConcurrencyRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<SetConcurrencyRequest>, I>>(object: I): SetConcurrencyRequest {
const message = createBaseSetConcurrencyRequest();
message.concurrency = object.concurrency ?? 0;
return message;
},
};
export type CronService = typeof CronService;
export const CronService = {
addCron: {
@@ -453,11 +535,21 @@ export const CronService = {
responseSerialize: (value: DeleteCronResponse) => Buffer.from(DeleteCronResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => DeleteCronResponse.decode(value),
},
setConcurrency: {
path: "/com.ql.cron.Cron/setConcurrency",
requestStream: false,
responseStream: false,
requestSerialize: (value: SetConcurrencyRequest) => Buffer.from(SetConcurrencyRequest.encode(value).finish()),
requestDeserialize: (value: Buffer) => SetConcurrencyRequest.decode(value),
responseSerialize: (value: AddCronResponse) => Buffer.from(AddCronResponse.encode(value).finish()),
responseDeserialize: (value: Buffer) => AddCronResponse.decode(value),
},
} as const;
export interface CronServer extends UntypedServiceImplementation {
addCron: handleUnaryCall<AddCronRequest, AddCronResponse>;
delCron: handleUnaryCall<DeleteCronRequest, DeleteCronResponse>;
setConcurrency: handleUnaryCall<SetConcurrencyRequest, AddCronResponse>;
}
export interface CronClient extends Client {
@@ -491,6 +583,21 @@ export interface CronClient extends Client {
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: DeleteCronResponse) => void,
): ClientUnaryCall;
setConcurrency(
request: SetConcurrencyRequest,
callback: (error: ServiceError | null, response: AddCronResponse) => void,
): ClientUnaryCall;
setConcurrency(
request: SetConcurrencyRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: AddCronResponse) => void,
): ClientUnaryCall;
setConcurrency(
request: SetConcurrencyRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: AddCronResponse) => void,
): ClientUnaryCall;
}
export const CronClient = makeGenericClientConstructor(CronService, "com.ql.cron.Cron") as unknown as {
+1 -1
View File
@@ -1,7 +1,7 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc v3.21.12
// protoc v3.17.3
// source: back/protos/health.proto
/* eslint-disable */
+59 -84
View File
@@ -1,28 +1,15 @@
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
import { AddCronRequest, AddCronResponse } from '../protos/cron';
import nodeSchedule from 'node-schedule';
import { createCronJob, CronJob } from '../shared/cronScheduler';
import { isValidCronSchedule } from '../shared/cronSchedule';
import { scheduleStacks } from './data';
import { runCron } from '../shared/runCron';
import Logger from '../loaders/logger';
import { tf } from '../shared/i18n';
/**
* 预校验 cron 表达式,检测 node-schedule 会拒绝但 cron-parser 会接受的 pattern。
* node-schedule 对 bare /N(字段以 / 开头,如前无星号/数字前缀的 /6)返回 null,
* 提前拦截避免走 scheduleJob 后才发现无效。
*/
// Validate the entire batch before replacing any existing jobs.
const isValidCronField = (cron: string): boolean => {
// 检测 bare /N 模式:字段以 / 开头如 "/6",或空格后紧跟 "/6"
// node-schedule 会对这种字段返回 null
if (/\s\/\d/.test(cron) || /^\/\d/.test(cron)) {
return false;
}
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数位置返回 null
// cron-parser 接受但 node-schedule 拒绝,提前拦截
if (/\?/.test(cron)) {
return false;
}
return true;
return isValidCronSchedule(cron);
};
const addCron = (
@@ -37,11 +24,7 @@ const addCron = (
if (!isValidCronField(schedule)) {
validationErrors.push(
tf(
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
String(id),
schedule,
),
tf('任务ID %s: 无效的 cron 表达式 "%s"', String(id), schedule),
);
}
@@ -50,7 +33,7 @@ const addCron = (
if (!isValidCronField(x.schedule)) {
validationErrors.push(
tf(
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"',
String(id),
x.schedule,
),
@@ -69,70 +52,62 @@ const addCron = (
return;
}
// ===== 第二遍:注册所有任务 =====
for (const item of call.request.crons) {
const { id, schedule, command, extra_schedules, name } = item;
// 取消该 id 已有的旧任务
if (scheduleStacks.has(id)) {
scheduleStacks.get(id)?.forEach((x) => x.cancel());
}
Logger.info(
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
name,
schedule,
command,
);
if (extra_schedules?.length) {
extra_schedules.forEach((x) => {
Logger.info(
'[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s',
id,
name,
x.schedule,
command,
// Prepare stopped jobs first: construction errors must preserve the old snapshot.
const prepared = new Map<string, CronJob[]>();
try {
for (const item of call.request.crons) {
const jobs: CronJob[] = [];
prepared.get(item.id)?.forEach((job) => job.cancel());
prepared.set(item.id, jobs);
for (const schedule of [
item.schedule,
...(item.extra_schedules || []).map((x) => x.schedule),
]) {
jobs.push(
createCronJob(
schedule,
async () => {
Logger.info('[schedule][准备运行任务] 命令: %s', item.command);
await runCron(
item.command,
item,
() => scheduleStacks.get(item.id) === jobs,
);
},
{
name: `${item.id}: ${item.name || ''}`,
logger: Logger,
start: false,
},
),
);
});
}
}
} catch (error) {
for (const jobs of prepared.values()) jobs.forEach((job) => job.cancel());
const err: any = new Error(
error instanceof Error ? error.message : String(error),
);
err.code = status.INVALID_ARGUMENT;
err.details = err.message;
callback(err, null);
return;
}
const mainJob = nodeSchedule.scheduleJob(id, schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
});
if (!mainJob) {
Logger.warn(
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
id,
schedule,
);
}
const extraJobs = extra_schedules?.length
? extra_schedules.map((x) => {
const job = nodeSchedule.scheduleJob(id, x.schedule, async () => {
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
runCron(command, item);
});
if (!job) {
Logger.warn(
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
id,
x.schedule,
);
}
return job;
})
: [];
// 过滤 null(兜底保护,正常情况下预校验已拦截)
const jobs = [mainJob, ...extraJobs].filter((x) => x != null);
if (jobs.length > 0) {
scheduleStacks.set(id, jobs);
}
if (call.request.replace) {
for (const jobs of scheduleStacks.values())
jobs.forEach((job) => job?.cancel());
scheduleStacks.clear();
}
for (const [id, jobs] of prepared) {
scheduleStacks.get(id)?.forEach((job) => job.cancel());
scheduleStacks.set(id, jobs);
jobs.forEach((job) => job.start());
Logger.info(
'[schedule][创建定时任务] 任务ID: %s, 规则数: %s',
id,
jobs.length,
);
}
callback(null, null);
+10 -6
View File
@@ -38,6 +38,7 @@ import {
RunCronsRequest,
} from '../protos/api';
import { NotificationInfo } from '../data/notify';
import { Model } from 'sequelize';
Container.set('logger', LoggerInstance);
@@ -247,13 +248,16 @@ export const systemNotify = async (
const normalizeCronData = (data: CronItem | null): CronItem | undefined => {
if (!data) return undefined;
// create() returns a Sequelize instance; spreading it omits attribute getters.
const cron = data instanceof Model ? (data.get({ plain: true }) as CronItem) : data;
return {
...data,
sub_id: data.sub_id ?? undefined,
extra_schedules: data.extra_schedules ?? [],
pid: data.pid ?? undefined,
task_before: data.task_before ?? undefined,
task_after: data.task_after ?? undefined,
...cron,
labels: cron.labels ?? [],
sub_id: cron.sub_id ?? undefined,
extra_schedules: cron.extra_schedules ?? [],
pid: cron.pid ?? undefined,
task_before: cron.task_before ?? undefined,
task_after: cron.task_after ?? undefined,
};
};
+90 -11
View File
@@ -1,4 +1,4 @@
import { credentials } from '@grpc/grpc-js';
import { credentials, status, Metadata } from '@grpc/grpc-js';
import {
AddCronRequest,
AddCronResponse,
@@ -9,7 +9,41 @@ import {
import config from '../config';
import { getGrpcCerts } from '../config/grpcCerts';
import { HealthService } from '../protos/health';
import { SchedulerReadiness } from '../shared/schedulerReadiness';
class Client {
readonly readiness = new SchedulerReadiness(() => this.probe());
private async waitForReady(timeoutMs: number) {
try {
await new Promise<void>((resolve, reject) => {
this.client.waitForReady(Date.now() + timeoutMs, (err) =>
err ? reject(err) : resolve()
);
});
} catch (error) {
this.readiness.invalidate();
throw Object.assign(
error instanceof Error ? error : new Error(String(error)),
{ status: 503 }
);
}
}
private async probe(): Promise<void> {
await this.waitForReady(1000);
await new Promise<void>((resolve, reject) => {
this.client.makeUnaryRequest(
HealthService.check.path,
HealthService.check.requestSerialize,
HealthService.check.responseDeserialize,
{ service: 'scheduler' },
{ deadline: Date.now() + 1000 },
(err, res) => err ? reject(err) : res?.status === 1 ? resolve() : reject(new Error('Scheduler unavailable')),
);
});
}
private _client: CronClient | null = null;
private get client(): CronClient {
@@ -28,22 +62,67 @@ class Client {
return this._client;
}
addCron(request: AddCronRequest['crons']): Promise<AddCronResponse> {
async setConcurrency(concurrency: number): Promise<void> {
await this.waitForReady(2000);
return new Promise((resolve, reject) => {
this.client.addCron({ crons: request }, (err, res) => {
if (err) {
reject(err);
}
resolve(res);
});
this.client.setConcurrency(
{ concurrency }, new Metadata(), { deadline: Date.now() + 5000 },
(err) => {
if (err) {
if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) {
this.readiness.invalidate();
Object.assign(err, { status: 503 });
}
return reject(err);
}
resolve();
},
);
});
}
delCron(request: DeleteCronRequest['ids']): Promise<DeleteCronResponse> {
async addCron(
request: AddCronRequest['crons'],
replace = false
): Promise<AddCronResponse> {
await this.waitForReady(2000);
// Include every rule in a recovery snapshot, but bound a stuck worker.
const ruleCount = request.reduce(
(count, item) => count + 1 + (item.extra_schedules?.length || 0),
0,
);
const registrationTimeoutMs = Math.min(120000, 5000 + ruleCount * 5);
return new Promise((resolve, reject) => {
this.client.delCron({ ids: request }, (err, res) => {
this.client.addCron(
{ crons: request, replace },
new Metadata(),
{ deadline: Date.now() + registrationTimeoutMs },
(err, res) => {
if (err) {
if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) {
// A timed-out write may already have reached the scheduler.
// Reconcile its state from the DB instead of replaying the RPC.
this.readiness.invalidate();
Object.assign(err, { status: 503 });
}
return reject(err);
}
resolve(res);
}
);
});
}
async delCron(request: DeleteCronRequest['ids']): Promise<DeleteCronResponse> {
await this.waitForReady(2000);
return new Promise((resolve, reject) => {
this.client.delCron({ ids: request }, new Metadata(), { deadline: Date.now() + 5000 }, (err, res) => {
if (err) {
reject(err);
if (err.code === status.UNAVAILABLE || err.code === status.DEADLINE_EXCEEDED) {
this.readiness.invalidate();
Object.assign(err, { status: 503 });
}
return reject(err);
}
resolve(res);
});
+2 -2
View File
@@ -1,6 +1,6 @@
import nodeSchedule from 'node-schedule';
import { CronJob } from '../shared/cronScheduler';
import { ToadScheduler } from 'toad-scheduler';
export const scheduleStacks = new Map<string, nodeSchedule.Job[]>();
export const scheduleStacks = new Map<string, CronJob[]>();
export const intervalSchedule = new ToadScheduler();
+1 -1
View File
@@ -13,7 +13,7 @@ const delCron = (
'[schedule][取消定时任务] 任务ID: %s',
id,
);
// 过滤掉 nodeSchedule.scheduleJob() 对无效表达式返回的 null,
// 防御性过滤历史调度栈中的空任务,
// 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误,
// 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。
scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => {
+3
View File
@@ -52,6 +52,9 @@ const check = async (
callback: sendUnaryData<HealthCheckResponse>,
) => {
switch (call.request.service) {
// Local scheduler liveness only: never call HTTP from this probe.
case 'scheduler':
return callback(null, { status: 1 });
case 'cron': {
const healthUrl = `http://localhost:${config.port}${
config.baseUrl || ''
+26
View File
@@ -0,0 +1,26 @@
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
import os from 'os';
import { AddCronResponse, SetConcurrencyRequest } from '../protos/cron';
import taskLimit from '../shared/pLimit';
export async function setConcurrency(
call: ServerUnaryCall<SetConcurrencyRequest, AddCronResponse>,
callback: sendUnaryData<AddCronResponse>,
) {
const limit = call.request.concurrency;
if (!Number.isInteger(limit) || limit < 0 || limit > 2147483647) {
callback(
Object.assign(new Error('Invalid concurrency'), {
code: status.INVALID_ARGUMENT,
}),
null,
);
return;
}
try {
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
callback(null, {});
} catch (error) {
callback(error as Error, null);
}
}
+3 -4
View File
@@ -12,13 +12,12 @@ export default class ConfigService {
public async getFile(filePath: string, res: Response) {
let content = '';
if (!filePath) {
if (!filePath || filePath.startsWith('data/scripts/')) {
return res.send({ code: 403, message: t('文件无法访问') });
}
const scriptFile = filePath.startsWith('data/scripts/');
const resolved = resolveFileAccess(
scriptFile ? config.scriptPath : config.configPath,
[scriptFile ? filePath.slice('data/scripts/'.length) : filePath],
config.configPath,
[filePath],
config.blackFileList,
);
if (!resolved) {
+412 -269
View File
@@ -1,6 +1,13 @@
import { randomUUID } from 'crypto';
import { getInvalidCronSchedules, isValidCronSchedule } from '../shared/cronSchedule';
import {
withSchedulerMutation,
schedulerRegistrationError,
} from '../shared/schedulerMutationLock';
import { Service, Inject } from 'typedi';
import winston from 'winston';
import config from '../config';
import { resolveLogPath } from '../shared/logPath';
import { Crontab, CrontabModel, CrontabStatus } from '../data/cron';
import {
RunningInstanceModel,
@@ -8,12 +15,10 @@ import {
} from '../data/runningInstance';
import { exec, execSync } from 'child_process';
import fs from 'fs/promises';
import CronExpressionParser from 'cron-parser';
import {
getFileContentByName,
fileExist,
killTask,
killAllTasks,
getUniqPath,
safeJSONParse,
isDemoEnv,
@@ -31,8 +36,10 @@ import { writeFileWithLock } from '../shared/utils';
import { t } from '../shared/i18n';
import { ScheduleType } from '../interface/schedule';
import { logStreamManager } from '../shared/logStreamManager';
import { observeChildProcess, asError } from '../shared/childProcess';
import { isEmpty } from 'lodash';
import { LogReadOptions, readLogChunk } from '../shared/logReader';
import { resolveFileAccess } from '../shared/fileAccess';
@Service()
export default class CronService {
@@ -40,10 +47,21 @@ export default class CronService {
private isNodeCron(cron: Crontab) {
const { schedule, extra_schedules } = cron;
if (Number(schedule?.split(/ +/).length) > 5 || extra_schedules?.length) {
return true;
}
return false;
const fields = schedule?.trim().split(/\s+/) || [];
// System crontab only receives portable numeric five-field expressions.
// Extended syntax, macros and legacy shorthand use the Node scheduler.
return (
fields.length !== 5 ||
/[^\d\s*,/\-]/.test(schedule || '') ||
Boolean(extra_schedules?.length) ||
// BusyBox treats N/step as a single value and doesn't support Sunday=7.
fields.some((field) => field.split(',').some((part) => /^\d+\//.test(part))) ||
fields[4].includes('7') ||
// BusyBox steps DOM from zero; its full-range day/week wildcard
// handling also differs from the legacy Node parser's OR semantics.
fields[2].includes('/') ||
(fields[2] !== '*' && fields[4] !== '*')
);
}
private get schedulerMode(): 'system' | 'node' {
@@ -97,42 +115,45 @@ export default class CronService {
}
public async create(payload: Crontab): Promise<Crontab> {
const tab = new Crontab(payload);
tab.saved = false;
tab.log_name = await this.getLogName(tab);
const doc = await this.insert(tab);
return withSchedulerMutation(async () => {
const tab = new Crontab(payload);
tab.saved = false;
tab.log_name = await this.getLogName(tab);
const doc = await this.insert(tab);
if (isDemoEnv()) {
return doc;
}
if (this.shouldUseCronClient(doc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
},
]);
} catch (error: any) {
// gRPC 注册失败时回滚 DB 记录,避免产生"僵尸任务"
// (DB 和 crontab.list 有记录但调度器永远不会执行)
await CrontabModel.destroy({ where: { id: doc.id } });
this.logger.error(
'[crontab] Failed to register cron job in scheduler, task creation rolled back:',
error?.message || error,
);
throw new Error(
`${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`,
);
if (isDemoEnv()) {
return doc;
}
}
await this.setCrontab();
return doc;
if (this.shouldUseCronClient(doc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
},
]);
} catch (error: any) {
// gRPC 注册失败时回滚 DB 记录,避免产生"僵尸任务"
// (DB 和 crontab.list 有记录但调度器永远不会执行)
await CrontabModel.destroy({ where: { id: doc.id } });
this.logger.error(
'[crontab] Failed to register cron job in scheduler, task creation rolled back:',
error?.message || error,
);
throw schedulerRegistrationError(
`${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`,
error,
);
}
}
await this.setCrontab();
return doc;
});
}
public async insert(payload: Crontab): Promise<Crontab> {
@@ -140,69 +161,68 @@ export default class CronService {
}
public async update(payload: Partial<Crontab>): Promise<Crontab> {
const doc = await this.getDb({ id: payload.id });
const tab = new Crontab({ ...doc, ...payload });
tab.saved = false;
tab.log_name = await this.getLogName(tab);
const newDoc = await this.updateDb(tab);
if (doc.isDisabled === 1 || isDemoEnv()) {
return newDoc;
}
try {
await cronClient.delCron([String(newDoc.id)]);
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to unregister cron job in scheduler:',
error?.message || error,
);
}
if (this.shouldUseCronClient(newDoc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(newDoc.id),
schedule: newDoc.schedule!,
command: this.makeCommand(newDoc),
extra_schedules: newDoc.extra_schedules || [],
},
]);
} catch (error: any) {
// gRPC 注册新任务失败 → 回滚 DB 到旧数据,并尝试恢复旧调度注册
await CrontabModel.update(doc, { where: { id: doc.id } });
if (this.shouldUseCronClient(doc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
},
]);
} catch (_recoveryError: any) {
this.logger.warn(
'[crontab] Failed to restore old cron job in scheduler after rollback:',
_recoveryError?.message || _recoveryError,
);
}
}
this.logger.error(
'[crontab] Failed to register updated cron job in scheduler, update rolled back:',
error?.message || error,
);
throw new Error(
`${t('调度器注册失败,任务更新已回滚')}: ${(error as any)?.details || error?.message}`,
);
return withSchedulerMutation(async () => {
const doc = await this.getDb({ id: payload.id });
const tab = new Crontab({ ...doc, ...payload });
tab.saved = false;
tab.log_name = await this.getLogName(tab);
if (doc.isDisabled === 1 || isDemoEnv()) {
return await this.updateDb(tab);
}
}
await this.setCrontab();
return newDoc;
// Keep the DB snapshot unchanged if deletion has an uncertain outcome.
// Recovery uses that snapshot after this mutation releases its lock.
await cronClient.delCron([String(doc.id)]);
const newDoc = await this.updateDb(tab);
if (this.shouldUseCronClient(newDoc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(newDoc.id),
schedule: newDoc.schedule!,
command: this.makeCommand(newDoc),
extra_schedules: newDoc.extra_schedules || [],
},
]);
} catch (error: any) {
// gRPC 注册新任务失败 → 回滚 DB 到旧数据,并尝试恢复旧调度注册
await CrontabModel.update(omit(doc, ['queued_token']), {
where: { id: doc.id },
});
if (this.shouldUseCronClient(doc)) {
try {
await cronClient.addCron([
{
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
},
]);
} catch (_recoveryError: any) {
this.logger.warn(
'[crontab] Failed to restore old cron job in scheduler after rollback:',
_recoveryError?.message || _recoveryError,
);
}
}
this.logger.error(
'[crontab] Failed to register updated cron job in scheduler, update rolled back:',
error?.message || error,
);
throw schedulerRegistrationError(
`${t('调度器注册失败,任务更新已回滚')}: ${(error as any)?.details || error?.message}`,
error,
);
}
}
await this.setCrontab();
return newDoc;
});
}
public async updateDb(payload: Crontab): Promise<Crontab> {
@@ -227,6 +247,9 @@ export default class CronService {
last_execution_time: number;
exit_code?: number;
}) {
if (log_path && log_path !== '/dev/null') {
resolveLogPath(config.logPath, log_path);
}
let options: any = {
status,
pid,
@@ -290,16 +313,11 @@ export default class CronService {
}
public async remove(ids: number[]) {
await CrontabModel.destroy({ where: { id: ids } });
try {
return withSchedulerMutation(async () => {
await cronClient.delCron(ids.map(String));
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to unregister cron job in scheduler:',
error?.message || error,
);
}
await this.setCrontab();
await CrontabModel.destroy({ where: { id: ids } });
await this.setCrontab();
});
}
public async pin(ids: number[]) {
@@ -570,46 +588,99 @@ export default class CronService {
}
public async run(ids: number[]) {
const queuedToken = randomUUID();
await CrontabModel.update(
{ status: CrontabStatus.queued },
{ status: CrontabStatus.queued, queued_token: queuedToken },
{ where: { id: ids } },
);
ids.forEach((id) => {
this.runSingle(id);
this.runSingle(id, queuedToken);
});
}
public async stop(ids: number[]) {
const docs = await CrontabModel.findAll({ where: { id: ids } });
// Cancel the queued snapshot first, so a late spawn cannot claim it.
for (const doc of docs) {
// Kill all running instances of this task
try {
if (doc.pid) {
await killTask(doc.pid);
}
const command = doc.command.replace(/\s+/g, ' ').trim();
await killAllTasks(command);
this.logger.info(
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
if (doc.status === CrontabStatus.queued) {
const [cancelled] = await CrontabModel.update(
{ status: CrontabStatus.idle, pid: null, queued_token: null } as any,
{
where: {
id: doc.id,
status: CrontabStatus.queued,
[Op.and]: [
where(colFn('log_path'), { [Op.eq]: doc.log_path ?? null }),
where(colFn('queued_token'), {
[Op.eq]: doc.queued_token ?? null,
}),
],
},
}
);
// A concurrent claim may have won; capture its PID before signalling.
if (!cancelled) await doc.reload();
}
}
const stoppingInstances = await RunningInstanceModel.findAll({
attributes: ['id', 'pid', 'cron_id'],
where: { cron_id: ids, status: InstanceStatus.running },
});
const targets = new Set<number>(
[
...stoppingInstances.map((instance) => instance.pid),
...docs
.filter((doc) => doc.status === CrontabStatus.running)
.map((doc) => doc.pid),
].filter((pid): pid is number => typeof pid === 'number' && pid > 0)
);
const stopped = new Set<number>();
for (const pid of targets) {
try {
await killTask(pid, true);
stopped.add(pid);
} catch (error) {
this.logger.error(
`[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`,
'[panel][停止任务失败] PID: %s, 错误: %s',
pid,
asError(error).message
);
}
}
// Mark all running instances as stopped
const finishedAt = dayjs().unix();
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: finishedAt },
{ where: { cron_id: ids, status: InstanceStatus.running } },
);
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: undefined },
{ where: { id: ids } },
);
const stoppedIds = stoppingInstances
.filter((instance) => instance.pid && stopped.has(instance.pid))
.map((instance) => instance.id!);
if (stoppedIds.length) {
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: dayjs().unix() },
{ where: { id: stoppedIds } }
);
}
for (const doc of docs) {
if (
doc.status !== CrontabStatus.running ||
(doc.pid && !stopped.has(doc.pid))
)
continue;
const remaining = await RunningInstanceModel.count({
where: { cron_id: doc.id, status: InstanceStatus.running },
});
if (remaining) continue;
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: null, queued_token: null } as any,
{
where: {
id: doc.id,
status: CrontabStatus.running,
[Op.and]: [
where(colFn('queued_token'), { [Op.eq]: doc.queued_token ?? null }),
where(colFn('pid'), { [Op.eq]: doc.pid ?? null }),
where(colFn('log_path'), { [Op.eq]: doc.log_path ?? null }),
],
},
}
);
}
}
public async stopInstance(instanceId: number) {
@@ -646,125 +717,184 @@ export default class CronService {
return { code: 200, message: t('实例已停止') };
}
private async runSingle(cronId: number): Promise<number | void> {
return taskLimit.manualRunWithCronLimit(() => {
return new Promise(async (resolve: any) => {
private async runSingle(
cronId: number,
expectedToken?: string,
): Promise<number | void> {
return taskLimit.manualRunWithCronLimit(async () => {
let absolutePath: string | undefined;
let logPath: string | undefined;
let queuedLogPath: string | null | undefined;
let queuedToken: string | null = null;
let claimed = false;
try {
const cron = await this.getDb({ id: cronId });
const params = {
name: cron.name,
command: cron.command,
schedule: cron.schedule,
extra_schedules: cron.extra_schedules,
};
if (cron.status !== CrontabStatus.queued) {
resolve(params);
return;
}
this.logger.info(
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
);
let { id, command, log_name } = cron;
if (
cron.status !== CrontabStatus.queued ||
(expectedToken !== undefined && cron.queued_token !== expectedToken)
) return;
queuedToken = cron.queued_token ?? null;
queuedLogPath = cron.log_path ?? null;
const { id, command, log_name } = cron;
const uniqPath =
log_name === '/dev/null' || !log_name
? await getUniqPath(command, `${id}`)
: log_name;
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
await fs.mkdir(logDirPath, { recursive: true });
const logPath = `${uniqPath}/${logTime}.log`;
const absolutePath = path.resolve(config.logPath, `${logPath}`);
logPath = `${uniqPath}/${logTime}.log`;
absolutePath = resolveFileAccess(config.logPath, [logPath]);
if (!absolutePath)
throw new Error('Log path is outside the log directory');
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
const outputPath = absolutePath;
const cp = spawn(
`real_log_path=${logPath} no_delay=true ${this.makeCommand(
cron,
true,
true
)}`,
{ shell: '/bin/bash' },
{ shell: '/bin/bash' }
);
await CrontabModel.update(
{ status: CrontabStatus.running, pid: cp.pid, log_path: logPath },
{ where: { id } },
);
cp.stdout.on('data', async (data) => {
await logStreamManager.write(absolutePath, data.toString());
// Install observers before the first await: very short children may already exit.
const { completed } = observeChildProcess(cp, {
onStart: async () => {
try {
const [count] = await CrontabModel.update(
{
status: CrontabStatus.running,
pid: cp.pid,
log_path: logPath,
},
{
where: {
id,
status: CrontabStatus.queued,
[Op.and]: [
where(colFn('queued_token'), { [Op.eq]: queuedToken }),
where(colFn('log_path'), { [Op.eq]: queuedLogPath }),
],
},
}
);
if (count !== 1)
throw new Error(
'Task was stopped or superseded before startup'
);
claimed = true;
} catch (error) {
if (cp.pid) await killTask(cp.pid, true);
throw error;
}
},
onStdout: (message) => logStreamManager.write(outputPath, message),
onStderr: (message) => logStreamManager.write(outputPath, message),
});
cp.stderr.on('data', async (data) => {
this.logger.info(
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
command,
data.toString(),
);
await logStreamManager.write(absolutePath, data.toString());
});
cp.on('error', async (err) => {
const result = await completed;
if (result.error) {
this.logger.error(
'[panel][创建任务失败] 命令: %s, 错误信息: %j',
command,
err,
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
id,
result.error.message
);
await logStreamManager.write(absolutePath, JSON.stringify(err));
});
cp.on('exit', async (code) => {
this.logger.info(
'[panel][执行任务结束] 参数: %s, 退出码: %j',
JSON.stringify(params),
code,
}
this.logger.info(
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
id,
result.code
);
return { ...cron, pid: cp.pid, ...result } as any;
} catch (error) {
this.logger.error(
'[panel][创建任务失败] 任务ID: %s, 错误: %s',
cronId,
asError(error).message
);
} finally {
try {
if (absolutePath) await logStreamManager.closeStream(absolutePath);
} catch (error) {
this.logger.error(
'[panel][关闭任务日志失败] %s',
asError(error).message
);
await logStreamManager.closeStream(absolutePath);
resolve({ ...params, pid: cp.pid, code });
});
});
}
try {
// Do not overwrite a newer run's state or its script-reported exit code.
await CrontabModel.update(
{ status: CrontabStatus.idle, pid: null, queued_token: null } as any,
{
where: {
id: cronId,
[Op.and]: where(colFn('queued_token'), { [Op.eq]: queuedToken }),
[Op.or]: [
...(queuedLogPath !== undefined && !claimed
? [
{
status: CrontabStatus.queued,
[Op.and]: where(colFn('log_path'), {
[Op.eq]: queuedLogPath,
}),
},
]
: []),
...(claimed && logPath
? [{ log_path: logPath, status: CrontabStatus.running }]
: []),
],
},
}
);
} catch (error) {
this.logger.error(
'[panel][清理任务状态失败] %s',
asError(error).message
);
}
}
});
}
public async disabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
try {
return withSchedulerMutation(async () => {
await cronClient.delCron(ids.map(String));
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to unregister cron job in scheduler:',
error?.message || error,
);
}
await this.setCrontab();
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
await this.setCrontab();
});
}
public async enabled(ids: number[]) {
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
const docs = await CrontabModel.findAll({ where: { id: ids } });
const crons = docs
.filter((x) => this.shouldUseCronClient(x))
.map((doc) => ({
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
}));
return withSchedulerMutation(async () => {
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
const docs = await CrontabModel.findAll({ where: { id: ids } });
const crons = docs
.filter((x) => this.shouldUseCronClient(x))
.map((doc) => ({
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
}));
if (isDemoEnv()) {
return;
}
if (isDemoEnv()) {
return;
}
try {
await cronClient.addCron(crons);
} catch (error: any) {
// gRPC 注册失败 → 回滚启用状态,避免 DB 显示已启用但调度器未注册
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
this.logger.error(
'[crontab] Failed to register cron job in scheduler, enable rolled back:',
error?.message || error,
);
throw new Error(
`${t('调度器注册失败,任务启用已回滚')}: ${(error as any)?.details || error?.message}`,
);
}
await this.setCrontab();
try {
await cronClient.addCron(crons);
} catch (error: any) {
// gRPC 注册失败 → 回滚启用状态,避免 DB 显示已启用但调度器未注册
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
this.logger.error(
'[crontab] Failed to register cron job in scheduler, enable rolled back:',
error?.message || error,
);
throw schedulerRegistrationError(
`${t('调度器注册失败,任务启用已回滚')}: ${(error as any)?.details || error?.message}`,
error,
);
}
await this.setCrontab();
});
}
public async log(
@@ -799,7 +929,9 @@ export default class CronService {
truncated: false,
};
}
const absolutePath = path.resolve(config.logPath, `${doc.log_path}`);
const absolutePath = doc.log_path
? resolveLogPath(config.logPath, doc.log_path)
: '';
const logFileExist = doc.log_path && (await fileExist(absolutePath));
if (logFileExist) {
const chunk = await readLogChunk(`${absolutePath}`, options);
@@ -829,12 +961,12 @@ export default class CronService {
public async logs(id: number) {
const doc = await this.getDb({ id });
if (!doc || !doc.log_path) {
if (!doc || !doc.log_path || doc.log_path === '/dev/null') {
return [];
}
const relativeDir = path.dirname(`${doc.log_path}`);
const dir = path.resolve(config.logPath, relativeDir);
const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path));
const dirExist = await fileExist(dir);
if (dirExist) {
let files = await fs.readdir(dir);
@@ -938,7 +1070,7 @@ export default class CronService {
if (
command &&
schedule &&
CronExpressionParser.parse(schedule).hasNext()
isValidCronSchedule(schedule)
) {
const name = namePrefix + '_' + index;
@@ -957,39 +1089,56 @@ export default class CronService {
});
}
public async autosave_crontab() {
const tabs = await this.crontabs();
const regularCrons = tabs.data
.filter(
(x) =>
x.isDisabled !== 1 &&
this.shouldUseCronClient(x),
)
.map((doc) => ({
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
}));
public async autosave_crontab(requireScheduler = false) {
return withSchedulerMutation(async () => {
const tabs = await this.crontabs();
// A bad persisted rule must not block startup or all other schedules.
// Keep the DB row editable; omit it from both runtime scheduler snapshots.
tabs.data = tabs.data.filter((doc) => {
if (doc.isDisabled === 1) return true;
const invalidSchedules = getInvalidCronSchedules(doc);
if (invalidSchedules.length === 0) return true;
this.logger.warn(
'[crontab] Skipping task with invalid schedule (id=%s, name=%s): %s',
doc.id,
doc.name || '',
JSON.stringify(invalidSchedules),
);
return false;
});
const regularCrons = tabs.data
.filter(
(x) =>
x.isDisabled !== 1 &&
this.shouldUseCronClient(x),
)
.map((doc) => ({
name: doc.name || '',
id: String(doc.id),
schedule: doc.schedule!,
command: this.makeCommand(doc),
extra_schedules: doc.extra_schedules || [],
}));
if (isDemoEnv()) {
await writeFileWithLock(config.crontabFile, '');
return;
}
if (isDemoEnv()) {
await writeFileWithLock(config.crontabFile, '');
return;
}
// 先同步 crontab.list 与系统 crontab,确保其始终反映数据库真实状态。
// gRPC 调度注册为尽力而为:失败时不阻断文件同步,调度器重启后会重新注册。
// 这避免了因调度器短暂不可用导致 crontab.list 与数据库脱节(订阅更新误判任务已存在)。
await this.setCrontab(tabs);
try {
await cronClient.addCron(regularCrons);
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to register cron job in scheduler:',
error?.message || error,
);
}
// 先同步 crontab.list 与系统 crontab,确保其始终反映数据库真实状态。
// gRPC 调度注册为尽力而为:失败时不阻断文件同步,调度器重启后会重新注册。
// 这避免了因调度器短暂不可用导致 crontab.list 与数据库脱节(订阅更新误判任务已存在)。
await this.setCrontab(tabs);
try {
await cronClient.addCron(regularCrons, requireScheduler);
} catch (error: any) {
this.logger.warn(
'[crontab] Failed to register cron job in scheduler: %s',
error?.message || error,
);
if (requireScheduler) throw error;
}
});
}
public async bootTask() {
@@ -998,13 +1147,7 @@ export default class CronService {
(x) => !x.isDisabled && this.isBootSchedule(x.schedule),
);
if (bootTasks.length > 0) {
await CrontabModel.update(
{ status: CrontabStatus.queued },
{ where: { id: bootTasks.map((t) => t.id!) } },
);
for (const task of bootTasks) {
this.runSingle(task.id!);
}
await this.run(bootTasks.map((task) => task.id!));
}
}
}
+3 -4
View File
@@ -262,10 +262,9 @@ export default class EnvService {
.map((x) => x.value)
.join('&')
.replace(/\\/g, '\\\\')}`;
js_env_string += `process.env.${key}=\`${_env_value.replace(
/\`/g,
'\\`',
)}\`;\n`;
js_env_string += `process.env[${JSON.stringify(key)}]=${JSON.stringify(
group.map((x) => x.value).join('&'),
)};\n`;
py_env_string += `os.environ['${key}']='''${_env_value.replace(
/\'/g,
"\\'",
+2 -1
View File
@@ -4,6 +4,7 @@ import { HealthService } from '../protos/health';
import { ApiService } from '../protos/api';
import { addCron } from '../schedule/addCron';
import { delCron } from '../schedule/delCron';
import { setConcurrency } from '../schedule/setConcurrency';
import { check } from '../schedule/health';
import * as Api from '../schedule/api';
import Logger from '../loaders/logger';
@@ -27,7 +28,7 @@ export class GrpcServerService {
async initialize() {
try {
this.server.addService(HealthService, { check });
this.server.addService(CronService, { addCron, delCron });
this.server.addService(CronService, { addCron, delCron, setConcurrency });
this.server.addService(ApiService, Api);
const tlsConfig = await initGrpcCerts();
+2 -4
View File
@@ -1,6 +1,6 @@
import { Service } from 'typedi';
import Logger from '../loaders/logger';
import { GrpcServerService } from './grpc';
import cronClient from '../schedule/client';
import { HttpServerService } from './http';
interface HealthStatus {
@@ -23,7 +23,6 @@ export class HealthService {
private startTime = Date.now();
constructor(
private grpcServerService: GrpcServerService,
private httpServerService: HttpServerService,
) {}
@@ -56,8 +55,7 @@ export class HealthService {
}
try {
const grpcServer = this.grpcServerService.getServer();
if (!grpcServer) {
if (!(await cronClient.readiness.check())) {
status.services.grpc = false;
status.status = 'error';
}
+7 -1
View File
@@ -39,7 +39,13 @@ export class HttpServerService {
private async tryListen(expressApp: express.Application, port: number, host: string): Promise<Server> {
return new Promise((resolve, reject) => {
const server = expressApp.listen(port, host, () => {
// There is one HTTP worker; accepting here avoids primary IPC handoff
// for every connection. Restore shared listening for custom clusters.
const server = expressApp.listen({
port,
host,
exclusive: process.env.QL_HTTP_SHARED_LISTEN !== 'true',
}, () => {
resolve(server);
});
+1 -6
View File
@@ -1,5 +1,4 @@
import { resolveFileAccess } from '../shared/fileAccess';
import path from 'path';
import { Inject, Service } from 'typedi';
import winston from 'winston';
import config from '../config';
@@ -9,10 +8,6 @@ export default class LogService {
constructor(@Inject('logger') private logger: winston.Logger) {}
public checkFilePath(filePath: string, fileName: string) {
return resolveFileAccess(
config.logPath,
[filePath || '', fileName],
config.blackFileList,
);
return resolveFileAccess(config.logPath, [filePath || '', fileName]);
}
}
+28
View File
@@ -37,6 +37,7 @@ export default class NotificationService {
['wxPusherBot', this.wxPusherBot],
['wxPusherSpt', this.wxPusherSpt],
['openiLink', this.openiLink],
['wpush', this.wpush],
]);
private title = '';
@@ -947,4 +948,31 @@ export default class NotificationService {
throw new Error(error.response ? error.response.body : error);
}
}
private async wpush() {
const { wpushApiKey, wpushChannel, wpushTopicCode } = this.params;
const url = 'https://api.wpush.cn/api/v1/send';
const json: Record<string, string> = {
apikey: `${wpushApiKey}`,
title: `${this.title}`,
content: `${this.content}`,
channel: `${wpushChannel || 'wechat'}`,
};
if (wpushTopicCode) {
json.topic_code = `${wpushTopicCode}`;
}
try {
const res = await httpClient.post(url, {
...this.gotOption,
json,
});
if (res.code === 0) {
return true;
} else {
throw new Error(JSON.stringify(res));
}
} catch (error: any) {
throw new Error(error.response ? error.response.body : error);
}
}
}
+116 -34
View File
@@ -6,27 +6,77 @@ import { v4 as uuidV4 } from 'uuid';
import sequelize, { Op } from 'sequelize';
import { shareStore } from '../shared/store';
import { t } from '../shared/i18n';
import { lock } from 'proper-lockfile';
import path from 'path';
import os from 'os';
import { writeFile, rename, rm, stat, chmod } from 'fs/promises';
import config from '../config';
@Service()
export default class OpenService {
constructor(@Inject('logger') private logger: winston.Logger) {}
// Every database mutation and full authentication-cache publication shares
// this cross-process lock. Lock a separate marker, not token.json itself.
private async withAppsLock<T>(operation: () => Promise<T>): Promise<T> {
const target = path.join(config.configPath, '.apps-state');
const release = await lock(target, {
realpath: false,
stale: 10_000,
retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 3000 },
});
try {
return await operation();
} finally {
await release();
}
}
public async initializeSystemApp(): Promise<void> {
await this.withAppsLock(async () => {
const doc = await AppModel.findOne({ where: { name: 'system' } });
const app = doc?.get({ plain: true });
if (!app) {
await this.insert({
name: 'system',
scopes: ['crons', 'system', 'dashboard'],
client_id: createRandomString(12, 12),
client_secret: createRandomString(24, 24),
});
} else if (!app.scopes.includes('dashboard')) {
await AppModel.update(
{ scopes: [...app.scopes, 'dashboard'] },
{ where: { id: app.id } },
);
}
await shareStore.updateApps(await this.find({}));
});
}
public async refreshApps(): Promise<void> {
await this.withAppsLock(async () => {
await shareStore.updateApps(await this.find({}));
});
}
public async findApps(): Promise<App[] | null> {
const docs = await this.find({});
return docs;
}
public async create(payload: App): Promise<App> {
const tab = { ...payload };
tab.client_id = createRandomString(12, 12);
tab.client_secret = createRandomString(24, 24);
const doc = await this.insert(tab);
const apps = await this.find({});
await shareStore.updateApps(apps);
return { ...doc, tokens: [] };
return this.withAppsLock(async () => {
const tab = { ...payload };
tab.client_id = createRandomString(12, 12);
tab.client_secret = createRandomString(24, 24);
const doc = await this.insert(tab);
const apps = await this.find({});
await shareStore.updateApps(apps);
return { ...doc, tokens: [] };
});
}
public async insert(payload: App): Promise<App> {
private async insert(payload: App): Promise<App> {
const doc = await AppModel.create(payload, { returning: true });
return doc.get({ plain: true });
}
@@ -41,10 +91,12 @@ export default class OpenService {
}
private async updateDb(payload: Partial<App>): Promise<App> {
await AppModel.update(payload, { where: { id: payload.id } });
const apps = await this.find({});
await shareStore.updateApps(apps);
return apps?.find((x) => x.id === payload.id) as App;
return this.withAppsLock(async () => {
await AppModel.update(payload, { where: { id: payload.id } });
const apps = await this.find({});
await shareStore.updateApps(apps);
return apps?.find((x) => x.id === payload.id) as App;
});
}
public async getDb(query: Record<string, any>): Promise<App> {
@@ -56,9 +108,11 @@ export default class OpenService {
}
public async remove(ids: number[]) {
await AppModel.destroy({ where: { id: ids } });
const apps = await this.find({});
await shareStore.updateApps(apps);
return this.withAppsLock(async () => {
await AppModel.destroy({ where: { id: ids } });
const apps = await this.find({});
await shareStore.updateApps(apps);
});
}
public async resetSecret(id: number): Promise<App> {
@@ -112,7 +166,15 @@ export default class OpenService {
return docs.map((x) => x.get({ plain: true }));
}
public async authToken({
public async authToken(credentials: {
client_id: string;
client_secret: string;
}): Promise<any> {
return this.withAppsLock(() => this.issueToken(credentials));
}
// Caller holds withAppsLock through database update and cache publication.
private async issueToken({
client_id,
client_secret,
}: {
@@ -156,27 +218,47 @@ export default class OpenService {
}
}
public async generateSystemToken(): Promise<{
public async generateSystemToken(renew = false): Promise<{
value: string;
expiration: number;
}> {
const apps = await shareStore.getApps();
const systemApp = apps?.find((x) => x.name === 'system');
if (!systemApp) {
throw new Error('system app not found');
}
const now = Math.round(Date.now() / 1000);
const currentToken = systemApp.tokens?.find((x) => x.expiration > now);
if (currentToken) {
return currentToken;
}
const { data } = await this.authToken({
client_id: systemApp.client_id,
client_secret: systemApp.client_secret,
return this.withAppsLock(async () => {
// Read after acquiring the lock; a concurrent issuer may have renewed.
const systemApp = await this.getDb({ name: 'system' });
const now = Math.round(Date.now() / 1000);
let appToken = systemApp.tokens
?.filter((token) => token.expiration > now)
.sort((left, right) => right.expiration - left.expiration)[0];
if (renew || !appToken) {
const { data } = await this.issueToken({
client_id: systemApp.client_id,
client_secret: systemApp.client_secret,
});
appToken = { value: data.token, expiration: data.expiration };
} else {
// Recover a prior database commit whose cache publication failed.
await shareStore.updateApps(await this.find({}));
}
const tokenFile = path.join(config.configPath, 'token.json');
const temporaryFile = `${tokenFile}.${process.pid}.tmp`;
const mode = await stat(tokenFile).then(
(file) => file.mode & 0o777,
(error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return 0o600;
throw error;
},
);
try {
await writeFile(temporaryFile, `${JSON.stringify(appToken)}${os.EOL}`, {
mode,
});
await chmod(temporaryFile, mode);
// Shell readers see a complete old or new document, never a partial one.
await rename(temporaryFile, tokenFile);
} finally {
await rm(temporaryFile, { force: true });
}
return appToken;
});
return {
...data,
value: data.token,
};
}
}
+112 -80
View File
@@ -1,6 +1,6 @@
import { Service, Inject } from 'typedi';
import winston from 'winston';
import nodeSchedule from 'node-schedule';
import { createCronJob, CronJob } from '../shared/cronScheduler';
import { ChildProcessWithoutNullStreams } from 'child_process';
import {
ToadScheduler,
@@ -11,6 +11,11 @@ import {
import dayjs from 'dayjs';
import taskLimit from '../shared/pLimit';
import { spawn } from 'cross-spawn';
import {
observeChildProcess,
asError,
ProcessResult,
} from '../shared/childProcess';
export interface ScheduleTaskType {
id?: number;
@@ -27,7 +32,7 @@ export interface TaskCallbacks {
startTime: dayjs.Dayjs,
) => Promise<void>;
onEnd?: (
cp: ChildProcessWithoutNullStreams,
cp: ChildProcessWithoutNullStreams | undefined,
endTime: dayjs.Dayjs,
diff: number,
) => Promise<void>;
@@ -37,7 +42,7 @@ export interface TaskCallbacks {
@Service()
export default class ScheduleService {
private scheduleStacks = new Map<string, nodeSchedule.Job>();
private scheduleStacks = new Map<string, CronJob>();
private intervalSchedule = new ToadScheduler();
@@ -63,73 +68,85 @@ export default class ScheduleService {
) {
const { runOrigin, ...others } = params;
return taskLimit[this.taskLimitMap[runOrigin]](others, () => {
return new Promise(async (resolve, reject) => {
this.logger.info(
`[panel][开始执行任务] 参数: ${JSON.stringify({
...others,
command,
})}`,
);
try {
const startTime = dayjs();
await callbacks.onBefore?.(startTime);
const cp = spawn(command, { shell: '/bin/bash' });
callbacks.onStart?.(cp, startTime);
completionTime === 'start' && resolve(cp.pid);
cp.stdout.on('data', async (data) => {
await callbacks.onLog?.(data.toString());
});
cp.stderr.on('data', async (data) => {
this.logger.info(
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
command,
data.toString(),
);
await callbacks.onError?.(data.toString());
});
cp.on('error', async (err) => {
this.logger.error(
'[panel][创建任务失败] 命令: %s, 错误信息: %j',
command,
err,
);
await callbacks.onError?.(JSON.stringify(err));
});
cp.on('exit', async (code) => {
this.logger.info(
'[panel][执行任务结束] 参数: %s, 退出码: %j',
JSON.stringify({
...others,
command,
}),
code,
);
const endTime = dayjs();
await callbacks.onEnd?.(
cp,
endTime,
endTime.diff(startTime, 'seconds'),
);
resolve({ ...others, pid: cp.pid, code });
});
} catch (error) {
this.logger.error(
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
command,
error,
);
await callbacks.onError?.(JSON.stringify(error));
}
});
let resolveStart!: (pid: number | undefined) => void;
let rejectStart!: (error: Error) => void;
const startResult = new Promise<number | undefined>((resolve, reject) => {
resolveStart = resolve;
rejectStart = reject;
});
// Most scheduled callers only observe completion (or intentionally detach).
void startResult.catch(() => {});
const completion = taskLimit[this.taskLimitMap[runOrigin]](
others,
async () => {
const startTime = dayjs();
let cp: ChildProcessWithoutNullStreams | undefined;
let result: ProcessResult = { code: null, signal: null };
try {
this.logger.info('[panel][开始执行任务] 任务ID: %s', others.id);
await callbacks.onBefore?.(startTime);
cp = spawn(command, { shell: '/bin/bash' });
const child = cp;
const observed = observeChildProcess(child, {
onStart: async () => {
await callbacks.onStart?.(child, startTime);
},
onStdout: callbacks.onLog,
onStderr: callbacks.onError,
});
observed.started.then(resolveStart, rejectStart);
result = await observed.completed;
} catch (error) {
result.error = asError(error);
rejectStart(result.error);
}
if (result.error) {
this.logger.error(
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
others.id,
result.error.message,
);
try {
await callbacks.onError?.(result.error.message);
} catch (error) {
this.logger.error(
'[panel][任务错误回调失败] %s',
asError(error).message,
);
}
}
// Cleanup also runs after setup/spawn failure, and only after both pipes drain.
const endTime = dayjs();
try {
await callbacks.onEnd?.(
cp,
endTime,
endTime.diff(startTime, 'seconds'),
);
} catch (error) {
result.error ??= asError(error);
this.logger.error(
'[panel][任务结束回调失败] %s',
asError(error).message,
);
}
this.logger.info(
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
others.id,
result.code,
);
return { ...others, pid: cp?.pid, ...result };
},
).catch((error) => {
// Queue/setup failures must not become unhandled rejections in detached callers.
rejectStart(asError(error));
this.logger.error('[panel][任务队列失败] %s', asError(error).message);
return { ...others, code: null, signal: null, error: asError(error) };
});
// Returning a PID must not release the execution slot while the process runs.
return completionTime === 'start' ? startResult : completion;
}
async createCronTask(
@@ -146,18 +163,33 @@ export default class ScheduleService {
command,
);
this.scheduleStacks.set(
_id,
nodeSchedule.scheduleJob(_id, schedule, async () => {
this.runTask(command, callbacks, {
name,
if (schedule) {
try {
const job = createCronJob(
schedule,
command,
id: _id,
runOrigin,
});
}),
);
() =>
this.runTask(command, callbacks, {
name,
schedule,
command,
id: _id,
runOrigin,
}),
{ name: `${_id}: ${name || ''}`, logger: this.logger, start: false },
);
this.scheduleStacks.get(_id)?.cancel();
this.scheduleStacks.set(_id, job);
job.start();
} catch (error) {
// A persisted invalid subscription must not become an unhandled startup rejection.
this.logger.warn(
'[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s',
_id,
schedule,
asError(error).message,
);
}
}
if (runImmediately) {
this.runTask(command, callbacks, {
+4 -8
View File
@@ -58,19 +58,15 @@ export default class ScriptService {
taskLimit.removeQueuedCron(relativePath.replace(/ /g, '-'));
pid = (await getPid(`${TASK_COMMAND} ${relativePath} now`)) as number;
}
try {
await killTask(pid);
} catch (error) {}
if (pid) {
await killTask(pid, true);
}
return { code: 200 };
}
public checkFilePath(filePath: string, fileName: string) {
return resolveFileAccess(
config.scriptPath,
[filePath || '', fileName],
config.blackFileList,
);
return resolveFileAccess(config.scriptPath, [filePath || '', fileName]);
}
public async getFile(filePath: string, fileName: string) {
+12
View File
@@ -0,0 +1,12 @@
import path from 'path';
import { Service } from 'typedi';
import config from '../config';
import { ScriptHistory } from '../shared/scriptHistory';
/** Panel configuration and lifecycle; the storage core stays independently testable. */
@Service()
export default class ScriptHistoryService extends ScriptHistory {
constructor() {
super(config.scriptPath, path.join(config.dataPath, 'script-history'));
}
}
+43 -23
View File
@@ -8,10 +8,22 @@ import { formatUrl } from '../config/subscription';
import config from '../config';
import { fileExist, rmPath } from '../config/util';
import { writeFileWithLock } from '../shared/utils';
import {
getSubscriptionSshAlias,
resolveSubscriptionPath,
} from '../shared/subscriptionPath';
@Service()
export default class SshKeyService {
private homedir = os.homedir();
// OpenSSH uses the effective user's passwd entry, not the HOME override.
private get homedir(): string {
try {
return os.userInfo().homedir;
} catch {
// Custom container UIDs may have no passwd entry. Keep startup working.
return os.homedir();
}
}
private sshPath = config.sshdPath;
private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config');
private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`;
@@ -21,6 +33,10 @@ export default class SshKeyService {
}
private async initSshConfigFile() {
await fs.mkdir(path.dirname(this.sshConfigFilePath), {
recursive: true,
mode: 0o700,
});
let config = '';
const _exist = await fileExist(this.sshConfigFilePath);
if (_exist) {
@@ -42,7 +58,7 @@ export default class SshKeyService {
key: string,
): Promise<void> {
try {
const filePath = path.join(this.sshPath, alias);
const filePath = resolveSubscriptionPath(this.sshPath, alias);
try {
await rmPath(filePath);
} catch { }
@@ -54,7 +70,7 @@ export default class SshKeyService {
private async removePrivateKeyFile(alias: string): Promise<void> {
try {
const filePath = path.join(this.sshPath, alias);
const filePath = resolveSubscriptionPath(this.sshPath, alias);
await rmPath(filePath);
} catch (error) {
this.logger.error('删除私钥文件失败', error);
@@ -66,18 +82,19 @@ export default class SshKeyService {
host: string,
proxy?: string,
) {
if (!/^[a-zA-Z0-9.-]+$/.test(host) ||
(proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy))) {
throw Object.assign(new Error('Invalid SSH host or proxy'), { status: 400 });
}
if (host === 'github.com') {
host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`;
}
const proxyStr = proxy
? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n`
: '';
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${path.join(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n${proxyStr}`;
const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n${proxyStr}`;
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
config,
{
encoding: 'utf8',
@@ -88,7 +105,7 @@ export default class SshKeyService {
private async removeSshConfig(alias: string) {
try {
const filePath = path.join(this.sshPath, `${alias}.config`);
const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config');
await rmPath(filePath);
} catch (error) {
this.logger.error(`删除ssh配置文件${alias}失败`, error);
@@ -101,6 +118,7 @@ export default class SshKeyService {
host: string,
proxy?: string,
): Promise<void> {
alias = getSubscriptionSshAlias(alias);
await this.generatePrivateKeyFile(alias, key);
await this.generateSingleSshConfig(alias, host, proxy);
}
@@ -110,6 +128,7 @@ export default class SshKeyService {
host: string,
proxy?: string,
): Promise<void> {
alias = getSubscriptionSshAlias(alias);
await this.removePrivateKeyFile(alias);
await this.removeSshConfig(alias);
}
@@ -117,15 +136,19 @@ export default class SshKeyService {
public async setSshConfig(docs: Subscription[]) {
for (const doc of docs) {
if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') {
const { alias, proxy } = doc;
const { host } = formatUrl(doc);
await this.removePrivateKeyFile(alias);
await this.removeSshConfig(alias);
await this.generatePrivateKeyFile(
alias,
(doc.pull_option as any).private_key,
);
await this.generateSingleSshConfig(alias, host, proxy);
try {
const alias = getSubscriptionSshAlias(doc.alias);
const { host } = formatUrl(doc);
await this.removePrivateKeyFile(alias);
await this.removeSshConfig(alias);
await this.generatePrivateKeyFile(
alias,
(doc.pull_option as any).private_key,
);
await this.generateSingleSshConfig(alias, host, doc.proxy);
} catch (error) {
this.logger.warn('跳过订阅 %s 的无效 SSH 配置: %s', doc.id, error);
}
}
}
}
@@ -144,12 +167,9 @@ export default class SshKeyService {
private async generateGlobalSshConfig(alias: string) {
// Create a config that matches all hosts, making this key globally available
const config = `Host *\n IdentityFile ${path.join(
this.sshPath,
alias,
)}\n StrictHostKeyChecking no\n`;
const config = `Host *\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n`;
await writeFileWithLock(
`${path.join(this.sshPath, `${alias}.config`)}`,
`${resolveSubscriptionPath(this.sshPath, alias, '.config')}`,
config,
{
encoding: 'utf8',
+164 -81
View File
@@ -20,7 +20,7 @@ import {
} from '../config/util';
import fs from 'fs/promises';
import { FindOptions, Op } from 'sequelize';
import path, { join } from 'path';
import path from 'path';
import ScheduleService, { TaskCallbacks } from './schedule';
import { SimpleIntervalSchedule } from 'toad-scheduler';
import SockService from './sock';
@@ -30,6 +30,14 @@ import dayjs from 'dayjs';
import { LOG_END_SYMBOL } from '../config/const';
import { formatCommand, formatUrl } from '../config/subscription';
import { CrontabModel } from '../data/cron';
import { assertSubscriptionAlias } from '../shared/subscriptionPath';
import { resolveLogPath } from '../shared/logPath';
import {
getSubscriptionStorageCandidates,
isSubscriptionStorageName,
resolveSubscriptionStoragePath,
} from '../shared/subscriptionStorage';
import { withSubscriptionMutation } from '../shared/subscriptionMutationLock';
import CrontabService from './cron';
import taskLimit from '../shared/pLimit';
import { logStreamManager } from '../shared/logStreamManager';
@@ -88,9 +96,11 @@ export default class SubscriptionService {
needCreate = true,
runImmediately = false,
) {
const { url } = formatUrl(doc);
doc.command = formatCommand(doc, url as string);
// Cancelling by ID must also work for invalid historical subscriptions.
if (needCreate) {
const { url } = formatUrl(doc);
doc.command = formatCommand(doc, url as string);
}
if (doc.schedule_type === 'crontab') {
this.scheduleService.cancelCronTask(doc as any);
@@ -159,48 +169,54 @@ export default class SubscriptionService {
);
},
onEnd: async (cp, endTime, diff) => {
const sub = await this.getDb({ id: doc.id });
const absolutePath = await handleLogPath(sub.log_path as string);
// 执行 sub_after
let afterStr = '';
let absolutePath: string | undefined;
try {
if (sub.sub_after) {
await logStreamManager.write(absolutePath, `\n\n## ${t('执行after命令...')}\n\n`);
afterStr = await promiseExec(sub.sub_after);
const sub = await this.getDb({ id: doc.id });
absolutePath = await handleLogPath(sub.log_path as string);
// 执行 sub_after
let afterStr = '';
try {
if (sub.sub_after) {
await logStreamManager.write(
absolutePath,
`\n\n## ${t('执行after命令...')}\n\n`,
);
afterStr = await promiseExec(sub.sub_after);
}
} catch (error: any) {
afterStr =
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
}
if (afterStr) {
await logStreamManager.write(absolutePath, `${afterStr}\n`);
}
await logStreamManager.write(
absolutePath,
'\n' +
tf(
'## 执行结束... %s 耗时 %s 秒',
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(diff),
) +
LOG_END_SYMBOL,
);
} finally {
try {
if (absolutePath) await logStreamManager.closeStream(absolutePath);
} finally {
await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: null } as any,
{ where: { id: doc.id } },
);
this.sockService.sendMessage({
type: 'runSubscriptionEnd',
message: t('订阅执行完成'),
references: [doc.id as number],
});
}
} catch (error: any) {
afterStr =
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
}
if (afterStr) {
await logStreamManager.write(absolutePath, `${afterStr}\n`);
}
await logStreamManager.write(
absolutePath,
'\n' +
tf(
'## 执行结束... %s 耗时 %s 秒',
endTime.format('YYYY-MM-DD HH:mm:ss'),
String(diff),
) +
LOG_END_SYMBOL,
);
// Close the stream after task completion
await logStreamManager.closeStream(absolutePath);
await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: undefined },
{ where: { id: sub.id } },
);
this.sockService.sendMessage({
type: 'runSubscriptionEnd',
message: t('订阅执行完成'),
references: [doc.id as number],
});
},
onError: async (message: string) => {
const sub = await this.getDb({ id: doc.id });
@@ -216,11 +232,14 @@ export default class SubscriptionService {
}
public async create(payload: Subscription): Promise<Subscription> {
const tab = new Subscription(payload);
const doc = await this.insert(tab);
await this.handleTask(doc.get({ plain: true }));
await this.setSshConfig();
return doc;
assertSubscriptionAlias(payload.alias);
return withSubscriptionMutation(async () => {
const tab = new Subscription(payload);
const doc = await this.insert(tab);
await this.handleTask(doc.get({ plain: true }));
await this.setSshConfig();
return doc;
});
}
public async insert(payload: Subscription): Promise<SubscriptionInstance> {
@@ -228,12 +247,27 @@ export default class SubscriptionService {
}
public async update(payload: Subscription): Promise<Subscription> {
const doc = await this.getDb({ id: payload.id });
const tab = new Subscription({ ...doc, ...payload });
const newDoc = await this.updateDb(tab);
await this.handleTask(newDoc, !newDoc.is_disabled);
await this.setSshConfig();
return newDoc;
assertSubscriptionAlias(payload.alias);
return withSubscriptionMutation(async () => {
const doc = await this.getDb({ id: payload.id });
const tab = new Subscription({ ...doc, ...payload });
const newDoc = await this.updateDb(tab);
await this.handleTask(newDoc, !newDoc.is_disabled);
await this.removeSshConfigForSubscription(doc);
await this.setSshConfig();
return newDoc;
});
}
private async removeSshConfigForSubscription(doc: Subscription) {
if (doc.type !== 'private-repo' || doc.pull_type !== 'ssh-key') return;
// Invalid historical rows must remain deletable without deriving unsafe paths.
try {
assertSubscriptionAlias(doc.alias);
} catch {
return;
}
await this.sshKeyService.removeSSHKey(doc.alias, '');
}
public async updateDb(payload: Subscription): Promise<Subscription> {
@@ -256,6 +290,7 @@ export default class SubscriptionService {
last_running_time: number;
last_execution_time: number;
}) {
if (log_path) resolveLogPath(config.logPath, log_path);
const options: any = {
status,
pid,
@@ -273,25 +308,71 @@ export default class SubscriptionService {
}
public async remove(ids: number[], query: { force?: boolean }) {
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
for (const doc of docs) {
await this.handleTask(doc.get({ plain: true }), false);
}
await SubscriptionModel.destroy({ where: { id: ids } });
await this.setSshConfig();
if (query?.force === true) {
const crons = await CrontabModel.findAll({ where: { sub_id: ids } });
if (crons?.length) {
await this.crontabService.remove(crons.map((x) => x.id!));
return withSubscriptionMutation(async () => {
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
const storagePaths: string[] = [];
const retainedPaths: string[] = [];
const collectPaths = (
doc: Subscription,
targets: string[],
deleting = false,
) => {
for (const names of getSubscriptionStorageCandidates(doc)) {
// A URL accepted by one engine can produce an invalid name in another.
// Validate each candidate independently, retaining the safe candidate.
if (!Object.values(names).every(isSubscriptionStorageName)) continue;
const resolve = deleting ? resolveSubscriptionStoragePath : path.resolve;
const scriptPath = resolve(config.scriptPath, names.script);
if (scriptPath) targets.push(scriptPath);
if (names.repo) {
const repoPath = resolve(config.repoPath, names.repo);
if (repoPath) targets.push(repoPath);
}
if (names.raw) {
const rawPath = resolve(path.join(config.dataPath, 'raw'), names.raw);
if (rawPath) targets.push(rawPath);
}
}
};
// Validate all deletion targets before changing rows or removing any files.
if (query?.force === true) {
for (const doc of docs) {
assertSubscriptionAlias(doc.alias);
resolveSubscriptionStoragePath(config.scriptPath, doc.alias);
resolveSubscriptionStoragePath(config.repoPath, doc.alias);
collectPaths(doc, storagePaths, true);
}
const remaining = await SubscriptionModel.findAll({ where: {} });
for (const doc of remaining) {
if (!ids.includes(doc.id!)) collectPaths(doc, retainedPaths);
}
}
for (const doc of docs) {
const filePath = join(config.scriptPath, doc.alias);
const repoPath = join(config.repoPath, doc.alias);
await rmPath(filePath);
await rmPath(repoPath);
await this.handleTask(doc.get({ plain: true }), false);
}
}
await SubscriptionModel.destroy({ where: { id: ids } });
for (const doc of docs) {
await this.removeSshConfigForSubscription(doc);
}
await this.setSshConfig();
if (query?.force === true) {
const crons = await CrontabModel.findAll({ where: { sub_id: ids } });
if (crons?.length) {
await this.crontabService.remove(crons.map((x) => x.id!));
}
for (const storagePath of new Set(storagePaths)) {
// rm is recursive: an ancestor must also be retained if a surviving
// subscription uses a nested branch, and vice versa.
const shared = retainedPaths.some((retained) =>
retained === storagePath ||
retained.startsWith(`${storagePath}${path.sep}`) ||
storagePath.startsWith(`${retained}${path.sep}`),
);
if (!shared) await rmPath(storagePath);
}
}
});
}
public async getDb(
@@ -316,20 +397,22 @@ export default class SubscriptionService {
public async stop(ids: number[]) {
const docs = await SubscriptionModel.findAll({ where: { id: ids } });
let failure: unknown;
for (const doc of docs) {
if (doc.pid) {
try {
await killTask(doc.pid);
} catch (error) {
this.logger.error(error);
try {
if (doc.pid) {
await killTask(doc.pid, true);
}
await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: null } as any,
{ where: { id: doc.id, pid: doc.pid ?? null } },
);
} catch (error) {
this.logger.error(error);
failure ??= error;
}
}
await SubscriptionModel.update(
{ status: SubscriptionStatus.idle, pid: undefined },
{ where: { id: ids } },
);
if (failure) throw failure;
}
private async runSingle(subscriptionId: number) {
@@ -391,7 +474,7 @@ export default class SubscriptionService {
if (doc.log_path) {
const relativeDir = path.dirname(`${doc.log_path}`);
const dir = path.resolve(config.logPath, relativeDir);
const dir = path.dirname(resolveLogPath(config.logPath, doc.log_path));
const _exist = await fileExist(dir);
if (_exist) {
let files = await fs.readdir(dir);
+52 -22
View File
@@ -1,4 +1,7 @@
import { spawn } from 'cross-spawn';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { shellQuote } from '../shared/shellQuote';
import { Response } from 'express';
import fs from 'fs';
import { Agent, request } from 'undici';
@@ -31,6 +34,8 @@ import {
SystemModelInfo,
} from '../data/system';
import taskLimit from '../shared/pLimit';
import cronClient from '../schedule/client';
import { withSchedulerMutation } from '../shared/schedulerMutationLock';
import NotificationService from './notify';
import ScheduleService, { TaskCallbacks } from './schedule';
import SockService from './sock';
@@ -119,15 +124,29 @@ export default class SystemService {
}
public async updateCronConcurrency(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
});
if (info.cronConcurrency) {
await taskLimit.setCustomLimit(info.cronConcurrency);
const limit = info.cronConcurrency ?? 0;
if (!Number.isInteger(limit) || limit < 0 || limit > 2147483647) {
throw Object.assign(new Error(t('参数错误')), { status: 400 });
}
return { code: 200, data: info };
return withSchedulerMutation(async () => {
const oDoc = await this.getSystemConfig();
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info, cronConcurrency: limit },
});
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
await cronClient.setConcurrency(limit);
return { code: 200, data: info };
});
}
public async restoreCronConcurrency() {
await withSchedulerMutation(async () => {
const doc = await this.getSystemConfig();
const limit = doc.info?.cronConcurrency ?? 0;
await taskLimit.setCustomLimit(limit || Math.max(os.cpus().length, 4));
await cronClient.setConcurrency(limit);
});
}
public async updateDependenceProxy(info: SystemModelInfo) {
@@ -139,7 +158,7 @@ export default class SystemService {
if (info.dependenceProxy) {
await fs.promises.writeFile(
config.dependenceProxyFile,
`export http_proxy="${info.dependenceProxy}"\nexport https_proxy="${info.dependenceProxy}"`,
`export http_proxy=${shellQuote(info.dependenceProxy)}\nexport https_proxy=${shellQuote(info.dependenceProxy)}`,
);
} else {
await fs.promises.rm(config.dependenceProxyFile);
@@ -155,7 +174,7 @@ export default class SystemService {
});
let cmd = 'pnpm config delete registry';
if (info.nodeMirror) {
cmd = `pnpm config set registry ${info.nodeMirror}`;
cmd = `pnpm config set registry ${shellQuote(info.nodeMirror)}`;
}
let command = `cd && ${cmd}`;
const docs = await DependenceModel.findAll({
@@ -204,7 +223,7 @@ export default class SystemService {
});
let cmd = 'pip config unset global.index-url';
if (info.pythonMirror) {
cmd = `pip3 config set global.index-url ${info.pythonMirror}`;
cmd = `pip3 config set global.index-url ${shellQuote(info.pythonMirror)}`;
}
await promiseExec(cmd);
return { code: 200, data: info };
@@ -220,7 +239,6 @@ export default class SystemService {
return;
}
const command = await updateLinuxMirrorFile(info.linuxMirror || '');
let hasError = false;
this.scheduleService.runTask(
command,
{
@@ -228,14 +246,15 @@ export default class SystemService {
res?.setHeader('QL-Task-Pid', `${cp.pid}`);
res?.end();
},
onEnd: async () => {
onEnd: async (cp) => {
this.sockService.sendMessage({
type: 'updateLinuxMirror',
message: 'update linux mirror end',
status: 'completed',
});
onEnd?.();
if (!hasError) {
// Tools such as sudo and apt may write warnings to stderr on success.
if (cp?.exitCode === 0 && cp.signalCode === null) {
await this.updateAuthDb({
...oDoc,
info: { ...oDoc.info, ...info },
@@ -243,7 +262,6 @@ export default class SystemService {
}
},
onError: async (message: string) => {
hasError = true;
this.sockService.sendMessage({ type: 'updateLinuxMirror', message });
},
onLog: async (message: string) => {
@@ -357,7 +375,10 @@ export default class SystemService {
}
public async reloadSystem(target?: 'system' | 'data') {
const cmd = `real_time=true ql reload ${target || ''}`;
if (target && !['system', 'data'].includes(target)) {
return { code: 400, message: t('参数错误') };
}
const cmd = `real_time=true ql reload ${shellQuote(target || '')}`;
const cp = spawn(cmd, {
shell: '/bin/bash',
detached: true,
@@ -438,11 +459,20 @@ export default class SystemService {
if (type && type.length) {
dataDirs = dataDirs.concat(type.filter((x) => x !== 'base'));
}
const dataPaths = dataDirs.map((dir) => `data/${dir}`);
await promiseExec(
`cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile
} ${dataPaths.join(' ')}`,
);
const allowed = new Set(['db', 'upload', 'config', 'scripts', 'log', 'deps',
'syslog', 'dep_cache', 'raw', 'repo', 'ssh.d', 'script-history']);
if (dataDirs.some((dir) => !allowed.has(dir))) {
return res.status(400).send({ code: 400, message: t('参数错误') });
}
if (dataDirs.includes('scripts')) dataDirs.push('script-history');
// History is created lazily; old/fresh installations must still export.
if (!fs.existsSync(path.join(config.dataPath, 'script-history'))) {
dataDirs = dataDirs.filter((dir) => dir !== 'script-history');
}
const dataPaths = [...new Set(dataDirs)].map((dir) => `data/${dir}`);
await promisify(execFile)('tar', ['-zcf', config.dataTgzFile, '--', ...dataPaths], {
cwd: path.dirname(config.dataPath),
});
res.download(config.dataTgzFile);
} catch (error: any) {
return res.send({ code: 400, message: error.message });
@@ -565,7 +595,7 @@ export default class SystemService {
try {
await fs.promises.writeFile(
config.langEnvFile,
`export QL_LANG='${lang}'\n`,
`export QL_LANG=${shellQuote(lang)}\n`,
);
} catch (error) {
this.logger.error(`Failed to write lang_env.sh: ${error}`);
+15 -12
View File
@@ -33,9 +33,11 @@ import {
verifyPassword,
} from '../shared/password';
import { serializeAuthMutation } from '../shared/authMutation';
import { LoginThrottle } from '../shared/loginThrottle';
@Service()
export default class UserService {
private loginThrottle = new LoginThrottle();
@Inject((type) => NotificationService)
private notificationService!: NotificationService;
@@ -106,11 +108,8 @@ export default class UserService {
return { code: 403, message: t('该 IP 已被列入黑名单') };
}
const retriesTime = Math.pow(3, retries) * 1000;
if (retries > 2 && timestamp - lastlogon < retriesTime) {
const waitTime = Math.ceil(
(retriesTime - (timestamp - lastlogon)) / 1000,
);
const waitTime = this.loginThrottle.retryAfter(ip, timestamp);
if (waitTime > 0) {
return {
code: 410,
message: tf('失败次数过多,请%s秒后重试', waitTime),
@@ -133,6 +132,7 @@ export default class UserService {
}
if (passwordMatches) {
this.loginThrottle.reset(ip);
const data = createRandomString(50, 100);
const expiration = twoFactorActivated ? '60d' : '20d';
let token = jwt.sign({ data }, config.jwt.secret, {
@@ -206,8 +206,9 @@ export default class UserService {
},
};
} else {
const failures = this.loginThrottle.fail(ip, timestamp);
await this.updateAuthInfo(content, {
retries: retries + 1,
retries: failures,
lastlogon: timestamp,
lastip: ip,
lastaddr: address,
@@ -239,8 +240,8 @@ export default class UserService {
},
});
this.getLoginLog();
if (retries > 2) {
const waitTime = Math.round(Math.pow(3, retries + 1));
if (failures > 3) {
const waitTime = this.loginThrottle.retryAfter(ip, timestamp);
return {
code: 410,
message: tf('失败次数过多,请%s秒后重试', waitTime),
@@ -439,8 +440,8 @@ export default class UserService {
const authInfo = await this.getAuthInfo();
const { isTwoFactorChecking, twoFactorSecret } = authInfo;
const now = Date.now();
const retries = authInfo.retries || 0;
if (retries > 2 && now - authInfo.lastlogon < Math.pow(3, retries) * 1000) {
const source = getClientIp(req);
if (this.loginThrottle.retryAfter(source, now) > 0) {
return { code: 410, message: t('失败次数过多,请稍后重试') };
}
if (
@@ -472,9 +473,8 @@ export default class UserService {
.join(' ');
}
await this.updateAuthInfo(authInfo, {
retries: retries + 1,
retries: this.loginThrottle.fail(source, now),
lastlogon: now,
isTwoFactorChecking: retries + 1 < 5,
lastip: ip,
lastaddr: address,
platform: req.platform,
@@ -690,5 +690,8 @@ export default class UserService {
});
}
await this.updateAuthInfo(authInfo, payload);
if (retries === 0 || password !== undefined || username !== undefined) {
this.loginThrottle.clear();
}
}
}
+78
View File
@@ -0,0 +1,78 @@
import { ChildProcessWithoutNullStreams } from 'child_process';
import { Readable } from 'stream';
export interface ProcessResult {
code: number | null;
signal: NodeJS.Signals | null;
error?: Error;
}
export function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
/** Attach immediately after spawn, before awaiting database or user callbacks. */
export function observeChildProcess(
child: ChildProcessWithoutNullStreams,
callbacks: {
onStart?: () => Promise<void>;
onStdout?: (message: string) => Promise<void>;
onStderr?: (message: string) => Promise<void>;
} = {},
) {
let failure: Error | undefined;
const recordError = (error: unknown) => {
failure ??= asError(error);
};
const spawned = new Promise<void>((resolve, reject) => {
child.once('spawn', resolve);
// Keep the listener through close: errors can occur after a successful spawn.
child.on('error', (error) => {
recordError(error);
reject(error);
});
});
const closed = new Promise<ProcessResult>((resolve) => {
child.once('close', (code, signal) => resolve({ code, signal }));
});
const started = spawned.then(async () => {
await callbacks.onStart?.();
return child.pid;
});
// The caller can ask only for completion, without an unhandled start rejection.
const ready = started.catch(recordError);
const consume = async (
stream: Readable,
callback?: (message: string) => Promise<void>,
) => {
// StringDecoder in Readable preserves UTF-8 characters split across chunks.
stream.setEncoding('utf8');
let callbackFailed = false;
try {
for await (const chunk of stream) {
await ready;
if (!callbackFailed && callback) {
try {
await callback(String(chunk));
} catch (error) {
recordError(error);
callbackFailed = true;
}
}
// Even if a log sink fails, drain the pipe so the child can finish.
}
} catch (error) {
recordError(error);
}
};
const output = Promise.all([
consume(child.stdout, callbacks.onStdout),
consume(child.stderr, callbacks.onStderr),
]);
const completed = Promise.all([closed, ready, output]).then(([result]) => ({
...result,
error: failure,
}));
return { started, completed };
}
+11
View File
@@ -0,0 +1,11 @@
// Match command text literally: looking up a task must never execute its input.
export function findCronId(crontab: string, command: string): string {
if (!command) return '';
for (const line of crontab.split('\n')) {
const id = /\bID=(\d+)\s/.exec(line);
if (id && line.includes(command, id.index + id[0].length)) {
return id[1];
}
}
return '';
}
+110
View File
@@ -0,0 +1,110 @@
import CronExpressionParser from 'cron-parser';
import { validate } from 'node-cron';
import { ScheduleType } from '../interface/schedule';
const aliases: Record<string, string> = {
'@yearly': '0 0 0 1 1 *',
'@annually': '0 0 0 1 1 *',
'@monthly': '0 0 0 1 * *',
'@weekly': '0 0 0 * * 0',
'@daily': '0 0 0 * * *',
'@midnight': '0 0 0 * * *',
'@hourly': '0 0 * * * *',
'@minutely': '0 * * * * *',
};
export interface CronSchedule {
patterns: string[];
dayCount: number;
nthDay: number;
}
// Validation and registration repeatedly see identical rules in large snapshots.
// Cache only immutable syntax, never dates or running tasks, and bound user input.
const scheduleCache = new Map<string, CronSchedule>();
// Canonicalize legacy shorthand, aliases, names and numeric-start steps before
// node-cron sees them. Extend legacy aliases explicitly; reject H and bare /N.
export function parseCronSchedule(schedule: unknown): CronSchedule {
if (typeof schedule !== 'string' || !schedule.trim()) {
throw new Error('Invalid cron schedule');
}
let source = schedule.trim();
const cacheKey = source;
const cached = scheduleCache.get(cacheKey);
if (cached) return cached;
if (source.startsWith('@')) {
if (!Object.hasOwn(aliases, source)) throw new Error('Invalid cron alias');
source = aliases[source];
}
let parts = source.split(/\s+/);
if (
parts.length > 6 ||
parts.some((part) => /(^|,)\//.test(part) || /H(?:\(|\/|$)/i.test(part))
) {
throw new Error('Unsupported cron syntax');
}
parts = [
...['0', '*', '*', '*', '*', '*'].slice(0, 6 - parts.length),
...parts,
];
if (
parts.some(
(part, index) => index !== 3 && index !== 5 && part.includes('?'),
)
) {
throw new Error('Question mark is only valid in day fields');
}
const expression = CronExpressionParser.parse(parts.join(' '));
if (!expression.hasNext())
throw new Error('Cron schedule has no next execution');
const normalized = expression.stringify(true).replace(/\?/g, '*').split(' ');
const dayCount = expression.fields.dayOfMonth.values.length;
const weekCount = expression.fields.dayOfWeek.values.length;
const patterns =
dayCount < 31 && weekCount < 8
? [
[...normalized.slice(0, 5), '*'].join(' '),
[...normalized.slice(0, 3), '*', ...normalized.slice(4)].join(' '),
]
: [normalized.join(' ')];
if (!patterns.every((pattern) => validate(pattern))) {
throw new Error('Unsupported cron schedule');
}
const parsed = {
patterns,
dayCount,
nthDay: expression.fields.dayOfWeek.nthDay,
};
Object.freeze(patterns);
Object.freeze(parsed);
if (scheduleCache.size >= 512)
scheduleCache.delete(scheduleCache.keys().next().value);
scheduleCache.set(cacheKey, parsed);
return parsed;
}
export function isValidCronSchedule(schedule: unknown): schedule is string {
try {
parseCronSchedule(schedule);
return true;
} catch {
return false;
}
}
export function getInvalidCronSchedules(cron: {
schedule?: string;
extra_schedules?: Array<{ schedule: string }>;
}): unknown[] {
if (
cron.schedule?.startsWith(ScheduleType.ONCE) ||
cron.schedule?.startsWith(ScheduleType.BOOT)
) {
return [];
}
return [
cron.schedule,
...(cron.extra_schedules || []).map((x) => x.schedule),
].filter((schedule) => !isValidCronSchedule(schedule));
}
+89
View File
@@ -0,0 +1,89 @@
import { createTask, ScheduledTask } from 'node-cron';
import { parseCronSchedule } from './cronSchedule';
interface SchedulerLogger {
warn(message: string, ...args: unknown[]): unknown;
error(message: string, ...args: unknown[]): unknown;
}
export interface CronJob {
start(): void;
cancel(): void;
}
// node-cron owns timers and calendar calculation; Qinglong owns execution
// concurrency and process lifetime. Missed in-process slots join that same queue.
export function createCronJob(
schedule: string,
callback: (date: Date) => unknown | Promise<unknown>,
options: { name: string; logger: SchedulerLogger; start?: boolean },
): CronJob {
const parsed = parseCronSchedule(schedule);
const tasks: ScheduledTask[] = [];
let cancelled = false;
let started = false;
const accepts = (index: number, date: Date) => {
if (tasks.length === 1) return true;
// Preserve cron-parser 4's day/weekday OR semantics, including its
// month-dependent full-day range and the global nth-week constraint.
if (parsed.nthDay && Math.ceil(date.getDate() / 7) !== parsed.nthDay)
return false;
if (index === 1) return !tasks[0].match(date);
const monthDays = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return parsed.dayCount < monthDays[date.getMonth()] || tasks[1].match(date);
};
const dispatch = async (index: number, date: Date, missed: boolean) => {
if (cancelled || !started || !accepts(index, date)) return;
if (missed) {
options.logger.warn(
'[schedule][补执行迟到任务] 任务: %s, 计划时间: %s',
options.name,
date.toISOString(),
);
}
try {
await callback(date);
} catch (error) {
options.logger.error(
'[schedule][定时回调失败] 任务: %s, 错误: %s',
options.name,
error instanceof Error ? error.message : String(error),
);
}
};
try {
for (const [index, pattern] of parsed.patterns.entries()) {
const task = createTask(
pattern,
(context) => dispatch(index, context.date, false),
{
noOverlap: false,
missedExecutionTolerance: 5000,
},
);
tasks.push(task);
task.on('execution:missed', (context) =>
dispatch(index, context.date, true),
);
// Fail before an existing schedule is cancelled, including impossible dates.
task.getNextRuns(1);
}
} catch (error) {
tasks.forEach((task) => task.destroy());
throw error;
}
const job: CronJob = {
start() {
if (started || cancelled) return;
started = true;
tasks.forEach((task) => task.start());
},
cancel() {
if (cancelled) return;
cancelled = true;
tasks.forEach((task) => task.destroy());
},
};
if (options.start !== false) job.start();
return job;
}
+4
View File
@@ -0,0 +1,4 @@
// Keep error formatting independent of database, HTTP and gRPC dependencies.
export function errStack(error: unknown): string {
return error instanceof Error && error.stack ? error.stack : String(error);
}
+7 -6
View File
@@ -3,6 +3,7 @@ import { shareStore } from './store';
const messages: Record<string, Record<string, string>> = {
zh: {},
en: {
'资源已存在,请检查重复的名称或值': 'Resource already exists; check for duplicate names or values',
'暂无权限': 'Access denied',
'参数错误': 'Invalid parameter',
'参数不正确': 'Invalid parameter',
@@ -94,8 +95,8 @@ const messages: Record<string, Record<string, string>> = {
'日志设置为忽略': 'Log set to ignore',
'定时规则不能为空': 'Schedule rule cannot be empty',
'无效的定时规则': 'Invalid schedule rule',
'日志名称只能包含字母、数字、下划线和连字符':
'Log name can only contain letters, numbers, underscores, and hyphens',
'日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符':
'Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators',
'日志名称不能超过100个字符': 'Log name cannot exceed 100 characters',
'错误的用户名密码,请重试': 'Incorrect username or password, please try again',
'该 IP 已被列入黑名单': 'This IP address has been blocked',
@@ -142,10 +143,10 @@ const messages: Record<string, Record<string, string>> = {
'Scheduler registration failed, task update rolled back',
'调度器注册失败,任务启用已回滚':
'Scheduler registration failed, task enable rolled back',
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
'Task ID %s: invalid cron expression "%s" (bare /N steps and ? character not supported)',
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
'Task ID %s (extra_schedule): invalid cron expression "%s" (bare /N steps and ? character not supported)',
'任务ID %s: 无效的 cron 表达式 "%s"':
'Task ID %s: invalid cron expression "%s"',
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"':
'Task ID %s (extra_schedule): invalid cron expression "%s"',
},
};
+1
View File
@@ -18,6 +18,7 @@ export interface IDependencyFn<T> {
export interface ICronFn<T> {
(): Promise<T>;
cron?: TCron;
isCurrent?: () => boolean;
}
export interface ISchedule {
+121
View File
@@ -0,0 +1,121 @@
import { shellQuote } from './shellQuote';
const repositoryNames =
/^(debian(?:-security)?|ubuntu(?:-ports)?|alpine|armbian)$/;
export function normalizeMirror(mirror: string): URL {
if (/\s/.test(mirror)) throw new Error('Invalid Linux mirror URL');
const url = new URL(mirror);
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
) {
throw new Error(
'Linux mirror must be an HTTP(S) URL without credentials, query or fragment',
);
}
return url;
}
export function replaceMirrorUrl(current: string, mirror: string): string {
const target = normalizeMirror(mirror);
const previous = new URL(current);
const oldSegments = previous.pathname.split('/').filter(Boolean);
const newSegments = target.pathname.split('/').filter(Boolean);
const repositoryIndex = oldSegments.findIndex((part) =>
repositoryNames.test(part),
);
const targetIndex = newSegments.findIndex((part) =>
repositoryNames.test(part),
);
const prefix =
targetIndex >= 0 ? newSegments.slice(0, targetIndex) : newSegments;
if (repositoryIndex >= 0) {
const repository = oldSegments[repositoryIndex];
let suffix = oldSegments.slice(repositoryIndex + 1);
// Repair paths already corrupted by repeated authority-only replacement.
while (suffix[0] === repository) suffix.shift();
return `${target.origin}/${[...prefix, repository, ...suffix].join('/')}`;
}
// Unknown repository paths: a full URL replaces the old root, a hostname
// preserves it. Never append the newly entered root to the existing root.
return `${target.origin}/${(newSegments.length
? newSegments
: oldSegments
).join('/')}`;
}
export function rewriteMirrorSources(content: string, mirror: string): string {
normalizeMirror(mirror);
return content
.split(/(\r?\n[ \t]*\r?\n)/)
.map((stanza) => {
const signedBy = stanza.match(/^Signed-By:\s*([^\r\n]*)/im)?.[1];
return stanza
.split('\n')
.map((line) => {
if (!/^\s*(?:URIs:|deb(?:-src)?\s|https?:\/\/)/.test(line))
return line;
const legacy = /^\s*deb(?:-src)?\s/.test(line);
const key = legacy
? line.match(/\bsigned-by=([^\]\s]+)/i)?.[1]
: signedBy;
// In .list files a # starts an inline comment; its URLs are metadata.
const comment = legacy ? line.indexOf('#') : -1;
const source = comment < 0 ? line : line.slice(0, comment);
const rewritten = source.replace(/https?:\/\/[^\s#]+/g, (url) =>
isSystemRepository(url, key) ? replaceMirrorUrl(url, mirror) : url,
);
return rewritten + (comment < 0 ? '' : line.slice(comment));
})
.join('\n');
})
.join('');
}
function isSystemRepository(url: string, signedBy?: string): boolean {
const keys =
signedBy
?.trim()
.split(/[\s,]+/)
.filter(Boolean) || [];
const archiveKeyring =
keys.length > 0 &&
keys.every((key) =>
/\/(?:debian|ubuntu|armbian)-archive-keyring\.(?:gpg|asc)$/.test(key),
);
// A separate signing key identifies a third-party archive, even if its
// repository happens to be called /debian or /ubuntu.
if (keys.length > 0 && !archiveKeyring) return false;
const segments = new URL(url).pathname.split('/').filter(Boolean);
const repositoryIndex = segments.findIndex((part) =>
repositoryNames.test(part),
);
if (repositoryIndex < 0) return false;
// Without archive metadata, accept only conventional archive roots. A
// vendor path such as Docker's /linux/debian is not the Debian archive.
return (
archiveKeyring ||
repositoryIndex === 0 ||
segments
.slice(0, repositoryIndex)
.every((part) => /^mirrors?$/.test(part)) ||
segments[repositoryIndex] === 'alpine'
);
}
export function mirrorWriteCommand(
file: string,
content: string,
prefix: string,
update: string,
): string {
return `${prefix}mkdir -p ${shellQuote(
file.slice(0, file.lastIndexOf('/')),
)} && printf '%s' ${shellQuote(content)} | ${prefix}tee ${shellQuote(
file,
)} >/dev/null && ${update}`;
}
+11
View File
@@ -0,0 +1,11 @@
import { resolveFileAccess } from './fileAccess';
/** Check stored paths too: old database rows are not trusted input. */
export function resolveLogPath(root: string, value: string): string {
const target = resolveFileAccess(root, [value]);
if (!target)
throw Object.assign(new Error('Log path is outside the log directory'), {
status: 400,
});
return target;
}
+87 -73
View File
@@ -1,5 +1,8 @@
import { createWriteStream, WriteStream } from 'fs';
import { EventEmitter } from 'events';
import path from 'path';
import config from '../config';
import { resolveFileAccess } from './fileAccess';
/**
* Manages write streams for log files to improve performance by avoiding repeated file opens
@@ -8,83 +11,90 @@ export class LogStreamManager extends EventEmitter {
private streams: Map<string, WriteStream> = new Map();
private pendingWrites: Map<string, Promise<void>> = new Map();
/**
* Write data to a log file using a managed stream
* @param filePath - Absolute path to the log file
* @param data - Data to write to the log file
*/
async write(filePath: string, data: string): Promise<void> {
// Wait for any pending writes to this file to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending;
}
private closingStreams = new Map<string, Promise<void>>();
private closedStreams = new WeakSet<WriteStream>();
private streamErrors = new Map<string, Error>();
// Create a new promise for this write operation
const writePromise = new Promise<void>((resolve, reject) => {
let stream = this.streams.get(filePath);
if (!stream) {
// Create a new write stream if one doesn't exist
stream = createWriteStream(filePath, { flags: 'a' });
this.streams.set(filePath, stream);
// Handle stream errors
stream.on('error', (error) => {
this.emit('error', { filePath, error });
// Remove the stream from the map on error
this.streams.delete(filePath);
reject(error);
});
}
// Write the data
const canContinue = stream.write(data, 'utf8', (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
// Handle backpressure
if (!canContinue) {
stream.once('drain', () => {
// Stream is ready for more data
});
}
});
this.pendingWrites.set(filePath, writePromise);
try {
await writePromise;
} finally {
this.pendingWrites.delete(filePath);
}
constructor(private readonly logRoot = config.logPath) {
super();
}
/**
* Close the stream for a specific file path
* @param filePath - Absolute path to the log file
*/
async closeStream(filePath: string): Promise<void> {
// Wait for any pending writes to complete
const pending = this.pendingWrites.get(filePath);
if (pending) {
await pending.catch(() => {
// Ignore errors on pending writes during close
});
/** Register each write synchronously, so concurrent callers cannot lose the tail. */
async write(filePath: string, data: string): Promise<void> {
if (this.closingStreams.has(filePath)) {
throw new Error(`Log stream is closing: ${filePath}`);
}
const previous = this.pendingWrites.get(filePath) || Promise.resolve();
const pending = previous.then(
() =>
new Promise<void>((resolve, reject) => {
const failure = this.streamErrors.get(filePath);
if (failure) return reject(failure);
let stream = this.streams.get(filePath);
if (!stream) {
// Validate only when opening: subsequent chunks reuse the same descriptor.
const root = path.resolve(this.logRoot);
const target = path.resolve(filePath);
if (
!target.startsWith(root + path.sep) ||
!resolveFileAccess(root, [target])
) {
return reject(new Error('Log path is outside the log directory'));
}
stream = createWriteStream(target, { flags: 'a' });
this.streams.set(filePath, stream);
const current = stream;
stream.once('close', () => this.closedStreams.add(current));
stream.on('error', (error) => {
this.streamErrors.set(filePath, error);
// EventEmitter's unobserved "error" event would crash the caller.
if (this.listenerCount('error') > 0)
this.emit('error', { filePath, error });
});
}
stream.write(data, 'utf8', (error) =>
error ? reject(error) : resolve(),
);
}),
);
this.pendingWrites.set(filePath, pending);
// Keep the tail until close, including failures; never reopen a failed log mid-run.
return pending;
}
const stream = this.streams.get(filePath);
if (stream) {
return new Promise<void>((resolve) => {
stream.end(() => {
this.streams.delete(filePath);
resolve();
});
});
async closeStream(filePath: string): Promise<void> {
const closing = this.closingStreams.get(filePath);
if (closing) return closing;
const pending = this.pendingWrites.get(filePath);
const result = (async () => {
let failure: unknown;
try {
await pending;
} catch (error) {
failure = error;
}
const stream = this.streams.get(filePath);
try {
if (stream && !this.closedStreams.has(stream)) {
await new Promise<void>((resolve) => {
stream.once('close', resolve);
if (failure || stream.destroyed) stream.destroy();
else stream.end();
});
}
failure ||= this.streamErrors.get(filePath);
if (failure) throw failure;
} finally {
this.streams.delete(filePath);
this.pendingWrites.delete(filePath);
this.streamErrors.delete(filePath);
}
})();
this.closingStreams.set(filePath, result);
try {
await result;
} finally {
this.closingStreams.delete(filePath);
}
}
@@ -92,7 +102,11 @@ export class LogStreamManager extends EventEmitter {
* Close all open streams
*/
async closeAll(): Promise<void> {
const closePromises = Array.from(this.streams.keys()).map((filePath) =>
const paths = new Set([
...this.streams.keys(),
...this.pendingWrites.keys(),
]);
const closePromises = Array.from(paths).map((filePath) =>
this.closeStream(filePath),
);
await Promise.all(closePromises);
+47
View File
@@ -0,0 +1,47 @@
const RETRY_TTL = 15 * 60 * 1000;
const MAX_SOURCES = 10000;
const MAX_RETRY_SECONDS = 5 * 60;
/** Source-specific, bounded backoff; persisted account history is not a lock. */
export class LoginThrottle {
private sources = new Map<
string,
{ failures: number; lastFailure: number }
>();
private get(source: string, now: number) {
const state = this.sources.get(source);
if (state && now - state.lastFailure >= RETRY_TTL) {
this.sources.delete(source);
return undefined;
}
return state;
}
retryAfter(source: string, now: number): number {
const state = this.get(source, now);
if (!state || state.failures < 3) return 0;
const seconds = Math.min(3 ** state.failures, MAX_RETRY_SECONDS);
return Math.max(
0,
Math.ceil((seconds * 1000 - (now - state.lastFailure)) / 1000),
);
}
fail(source: string, now: number): number {
const failures = Math.min((this.get(source, now)?.failures || 0) + 1, 6);
this.sources.delete(source);
if (this.sources.size >= MAX_SOURCES) {
this.sources.delete(this.sources.keys().next().value!);
}
this.sources.set(source, { failures, lastFailure: now });
return failures;
}
reset(source: string) {
this.sources.delete(source);
}
clear() {
this.sources.clear();
}
}
+16 -9
View File
@@ -50,11 +50,9 @@ class TaskLimit {
Buffer.from(tlsConfig.clientCert),
)
: credentials.createInsecure();
this._client = new ApiClient(
`localhost:${config.grpcPort}`,
creds,
{ 'grpc.enable_http_proxy': 0 },
);
this._client = new ApiClient(`localhost:${config.grpcPort}`, creds, {
'grpc.enable_http_proxy': 0,
});
}
return this._client;
}
@@ -113,12 +111,15 @@ class TaskLimit {
}
}
public removeQueuedCron(id: string) {
public removeQueuedCron(id: string, completed?: ICronFn<any>) {
if (this.queuedCrons.has(id)) {
const runs = this.queuedCrons.get(id);
if (runs && runs.length > 0) {
runs.pop();
this.queuedCrons.set(id, runs);
const remaining = completed
? runs.filter((run) => run !== completed)
: runs.slice(0, -1);
if (remaining.length) this.queuedCrons.set(id, remaining);
else this.queuedCrons.delete(id);
}
}
}
@@ -143,9 +144,15 @@ class TaskLimit {
cron: TCron,
fn: ICronFn<T>,
options?: Partial<QueueAddOptions>,
isCurrent: () => boolean = () => true,
): Promise<T | void> {
if (!isCurrent()) return;
fn.cron = cron;
let runs = this.queuedCrons.get(cron.id);
fn.isCurrent = isCurrent;
// Invalidated snapshots must not consume the new revision's repeat budget.
const runs = this.queuedCrons
.get(cron.id)
?.filter((run) => run.isCurrent?.() !== false);
const result = runs?.length ? [...runs, fn] : [fn];
const repeatTimes = this.repeatCronNotifyMap.get(cron.id) || 0;
if (result?.length > 5) {
+53 -34
View File
@@ -4,21 +4,27 @@ import Logger from '../loaders/logger';
import { ICron } from '../protos/cron';
import { CrontabModel, CrontabStatus } from '../data/cron';
import { killTask } from '../config/util';
import {
RunningInstanceModel,
InstanceStatus,
} from '../data/runningInstance';
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
import dayjs from 'dayjs';
import { observeChildProcess, asError } from './childProcess';
export function runCron(cmd: string, cron: ICron): Promise<number | void> {
return taskLimit.runWithCronLimit(cron, () => {
return new Promise(async (resolve: any) => {
export function runCron(
cmd: string,
cron: ICron,
isCurrent: () => boolean = () => true,
): Promise<number | void> {
const execute = async () => {
try {
if (!isCurrent()) return;
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
try {
const existingCron = await CrontabModel.findOne({
where: { id: Number(cron.id) },
});
// A mutation may commit while this queued run is reading the database.
if (!isCurrent() || !existingCron || existingCron.isDisabled) return;
// Default to single instance mode (0) for backward compatibility
const allowSingleInstances =
existingCron?.allow_multiple_instances === 0;
@@ -33,12 +39,17 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
Logger.info(
`[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`,
);
await killTask(existingCron.pid);
await killTask(existingCron.pid, true);
// Mark old running instances as stopped
const stoppedAt = dayjs().unix();
await RunningInstanceModel.update(
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
{ where: { cron_id: Number(cron.id), status: InstanceStatus.running } },
{
where: {
cron_id: Number(cron.id),
status: InstanceStatus.running,
},
},
);
// Update the status to idle after killing
await CrontabModel.update(
@@ -50,8 +61,11 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
Logger.error(
`[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`,
);
throw error;
}
// In particular, invalidation can happen while stopping a previous run.
if (!isCurrent()) return;
Logger.info(
`[schedule][开始执行任务] 参数 ${JSON.stringify({
...cron,
@@ -60,33 +74,38 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
);
const cp = spawn(cmd, { shell: '/bin/bash' });
cp.stderr.on('data', (data) => {
Logger.info(
'[schedule][执行任务失败] 命令: %s, 错误信息: %j',
cmd,
data.toString(),
);
const { completed } = observeChildProcess(cp, {
onStderr: async (message) => {
Logger.info(
'[schedule][任务标准错误] 命令: %s, 信息: %s',
cmd,
message,
);
},
});
cp.on('error', (err) => {
const result = await completed;
if (result.error) {
Logger.error(
'[schedule][创建任务失败] 命令: %s, 错误信息: %j',
'[schedule][执行任务失败] 命令: %s, 错误: %s',
cmd,
err,
result.error.message,
);
});
cp.on('exit', async (code) => {
taskLimit.removeQueuedCron(cron.id);
Logger.info(
'[schedule][执行任务结束] 参数: %s, 退出码: %j',
JSON.stringify({
...cron,
command: cmd,
}),
code,
);
resolve({ ...cron, command: cmd, pid: cp.pid, code });
});
});
});
}
Logger.info(
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j',
cron.id,
result.code,
);
return { ...cron, command: cmd, pid: cp.pid, ...result } as any;
} catch (error) {
Logger.error(
'[schedule][创建任务失败] 命令: %s, 错误: %s',
cmd,
asError(error).message,
);
} finally {
taskLimit.removeQueuedCron(cron.id, execute);
}
};
return taskLimit.runWithCronLimit(cron, execute, undefined, isCurrent);
}
+40
View File
@@ -0,0 +1,40 @@
import lockfile from 'proper-lockfile';
import config from '../config';
// HTTP and gRPC both mutate cron definitions. Hold one shared lock from the
// initial DB read/write through scheduler registration (including rollback).
// Recovery takes the same lock before reading its replacement snapshot.
export async function withSchedulerMutation<T>(
operation: () => Promise<T>,
): Promise<T> {
let release: () => Promise<void>;
try {
// proper-lockfile indexes held locks by target, not lockfilePath. Keep
// this identity separate from nested writeFileWithLock(crontabFile).
release = await lockfile.lock(`${config.crontabFile}.scheduler`, {
realpath: false,
lockfilePath: `${config.crontabFile}.scheduler.lock`,
stale: 30000,
update: 10000,
retries: { retries: 50, factor: 1, minTimeout: 100, maxTimeout: 100 },
});
} catch (cause) {
throw Object.assign(
new Error('Scheduler configuration is busy', { cause }),
{
status: 503,
},
);
}
try {
return await operation();
} finally {
await release();
}
}
export function schedulerRegistrationError(message: string, cause: any): Error {
return Object.assign(new Error(message, { cause }), {
status: cause?.status === 503 ? 503 : 500,
});
}
+77
View File
@@ -0,0 +1,77 @@
// Recovery is single-flight and retried only while unavailable, never at idle.
export class SchedulerReadiness {
private ready = false;
private generation = 0;
private restore?: () => Promise<void>;
private pending?: Promise<boolean>;
private retry?: NodeJS.Timeout;
constructor(private probe: () => Promise<void>, private retryMs = 1000) {}
configure(restore: () => Promise<void>) {
this.restore = restore;
}
invalidate() {
this.ready = false;
this.generation++;
void this.recover();
}
recover(): Promise<boolean> {
if (this.pending) return this.pending;
if (!this.restore) return Promise.resolve(false);
clearTimeout(this.retry);
const generation = this.generation;
this.ready = false;
this.pending = (async () => {
try {
await this.probe();
await this.restore!();
await this.probe();
this.ready = generation === this.generation;
} catch {
this.ready = false;
}
return this.ready;
})().finally(() => {
this.pending = undefined;
if (!this.ready) {
this.retry = setTimeout(() => void this.recover(), this.retryMs);
this.retry.unref();
}
});
return this.pending;
}
async check(): Promise<boolean> {
if (!this.ready) return false;
const generation = this.generation;
try {
await this.probe();
return this.ready && generation === this.generation;
} catch {
this.invalidate();
return false;
}
}
async ensureReady(timeoutMs = 2000): Promise<void> {
let timer: NodeJS.Timeout | undefined;
try {
const available = await Promise.race([
(async () => (await this.check()) || (await this.recover()))(),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs);
}),
]);
if (!available) {
throw Object.assign(new Error('Scheduler is recovering; try again later'), {
status: 503,
});
}
} finally {
clearTimeout(timer);
}
}
}
+70
View File
@@ -0,0 +1,70 @@
import { QueryTypes, Sequelize } from 'sequelize';
// Append new entries; IDs are persisted and must not be renumbered or reused.
const columns = [
{
table: 'CrontabViews',
column: 'filterRelation',
type: 'VARCHAR(255)',
},
{ table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' },
{ table: 'CrontabViews', column: 'type', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' },
{ table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' },
{ table: 'Crontabs', column: 'sub_id', type: 'NUMBER' },
{ table: 'Crontabs', column: 'extra_schedules', type: 'JSON' },
{ table: 'Crontabs', column: 'task_before', type: 'TEXT' },
{ table: 'Crontabs', column: 'task_after', type: 'TEXT' },
{ table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' },
{
table: 'Crontabs',
column: 'allow_multiple_instances',
type: 'NUMBER',
},
{ table: 'Crontabs', column: 'work_dir', type: 'VARCHAR(255)' },
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
{ table: 'Envs', column: 'labels', type: 'JSON' },
{ table: 'Crontabs', column: 'queued_token', type: 'VARCHAR(255)' },
];
export async function migrateSchema(database: Sequelize): Promise<void> {
await database.transaction(async (transaction) => {
await database.query(
'CREATE TABLE IF NOT EXISTS "SchemaMigrations" ("id" TEXT PRIMARY KEY, "applied_at" TEXT NOT NULL)',
{ transaction },
);
const applied = await database.query<{ id: string }>(
'SELECT "id" FROM "SchemaMigrations"',
{ type: QueryTypes.SELECT, transaction },
);
const appliedIds = new Set(applied.map(({ id }) => id));
for (const { table, column, type } of columns) {
const id = `add-${table}-${column}`;
const fields = await database.query<{ name: string }>(
`PRAGMA table_info("${table}")`,
{
type: QueryTypes.SELECT,
transaction,
},
);
if (fields.length === 0)
throw new Error(`Migration table is missing: ${table}`);
if (!fields.some((field) => field.name === column)) {
// table/column/type come only from the static migration manifest above.
await database.query(
`ALTER TABLE "${table}" ADD COLUMN "${column}" ${type}`,
{ transaction },
);
}
if (!appliedIds.has(id)) {
await database.query(
'INSERT INTO "SchemaMigrations" ("id", "applied_at") VALUES (:id, :appliedAt)',
{
replacements: { id, appliedAt: new Date().toISOString() },
transaction,
},
);
}
}
});
}
+467
View File
@@ -0,0 +1,467 @@
import * as fs from 'fs/promises';
import { createReadStream } from 'fs';
import path from 'path';
import { createHash, randomUUID } from 'crypto';
import { promisify } from 'util';
import { brotliCompress, brotliDecompress, constants } from 'zlib';
import { lock } from 'proper-lockfile';
import { resolveFileAccess } from './fileAccess';
import { getUniqueLockPath } from './utils';
const compress = promisify(brotliCompress);
const decompress = promisify(brotliDecompress);
const hash = (content: string) =>
createHash('sha256').update(content).digest('hex');
export const SCRIPT_HISTORY_LIMITS = {
versions: 20,
fileBytes: 1024 * 1024,
unpackedBytes: 8 * 1024 * 1024,
archiveBytes: 2 * 1024 * 1024,
totalBytes: 32 * 1024 * 1024,
files: 1000,
};
type Source = 'baseline' | 'save' | 'restore' | 'external';
interface Version {
id: string;
createdAt: string;
source: Source;
hash: string;
content: string;
}
interface Archive {
format: 1;
path: string;
versions: Version[];
head?: { hash: string; createdAt: string; source: Source };
}
export class ScriptHistoryError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
export class HistoryUnavailableError extends ScriptHistoryError {
constructor(public currentHash: string) {
super(413, '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本');
}
}
interface SaveOptions {
skipHistory?: boolean;
expectedHash?: string;
}
/** Lazy, bounded storage: no watcher, database, daemon, or in-memory cache.
* Full snapshots share a Brotli window, so similar revisions compress together
* without a patch chain. All mutations use the existing script lock and a
* cross-process archive lock. History is durable before overwriting the script. */
export class ScriptHistory {
constructor(
private scriptRoot: string,
private historyRoot: string,
private blacklist: string[] = [],
private limits = SCRIPT_HISTORY_LIMITS,
) {}
private async target(directory: string, filename: string) {
const candidate = resolveFileAccess(
this.scriptRoot,
[directory, filename],
this.blacklist,
);
if (!candidate) throw new ScriptHistoryError(403, '暂无权限');
try {
const file = await fs.realpath(candidate);
const root = await fs.realpath(this.scriptRoot);
const relative = path.relative(root, file);
const key = hash(relative);
return {
file,
relative,
archive: path.join(this.historyRoot, key + '.br'),
};
} catch (error: any) {
if (error.code === 'ENOENT')
throw new ScriptHistoryError(404, '脚本不存在');
throw error;
}
}
private async readText(file: string) {
const handle = await fs.open(file, 'r');
try {
const stat = await handle.stat();
if (!stat.isFile()) throw new ScriptHistoryError(400, '请选择脚本文件');
if (stat.size > this.limits.fileBytes)
throw new ScriptHistoryError(
413,
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
);
// Bounded read even if an external process grows the file after stat.
const bytes = Buffer.alloc(this.limits.fileBytes + 1);
let size = 0;
while (size < bytes.length) {
const { bytesRead } = await handle.read(
bytes,
size,
bytes.length - size,
null,
);
if (!bytesRead) break;
size += bytesRead;
}
const buffer = bytes.subarray(0, size);
const content = buffer.toString('utf8');
if (
size > this.limits.fileBytes ||
content.includes('\0') ||
!Buffer.from(content).equals(buffer)
) {
throw new ScriptHistoryError(
413,
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
);
}
return content;
} finally {
await handle.close();
}
}
private async readArchive(file: string, relative: string): Promise<Archive> {
try {
const stat = await fs.lstat(file);
if (!stat.isFile() || stat.size > this.limits.archiveBytes)
throw new Error('Invalid archive');
const bytes = await decompress(await fs.readFile(file), {
maxOutputLength: this.limits.unpackedBytes,
});
const archive: Archive = JSON.parse(bytes.toString('utf8'));
if (
archive.format !== 1 ||
archive.path !== relative ||
!Array.isArray(archive.versions) ||
archive.versions.length > this.limits.versions ||
archive.versions.some(
(v) =>
typeof v.content !== 'string' ||
typeof v.id !== 'string' ||
typeof v.createdAt !== 'string' ||
!['baseline', 'save', 'restore', 'external'].includes(v.source) ||
Buffer.byteLength(v.content) > this.limits.fileBytes ||
hash(v.content) !== v.hash,
)
) {
throw new Error('Invalid archive');
}
return archive;
} catch (error: any) {
if (error.code === 'ENOENT')
return { format: 1, path: relative, versions: [] };
throw new ScriptHistoryError(500, '历史版本读取失败,请检查历史文件');
}
}
async list(directory: string, filename: string) {
const target = await this.target(directory, filename);
const [current, archive] = await Promise.all([
this.readText(target.file),
this.readArchive(target.archive, target.relative),
]);
const currentHash = hash(current);
const versions = archive.versions
.slice()
.reverse()
.map(({ content, ...v }) => ({
...v,
size: Buffer.byteLength(content),
identical: v.hash === currentHash,
}));
return { versions, currentHash, limit: this.limits.versions };
}
async detail(directory: string, filename: string, id: string) {
const target = await this.target(directory, filename);
const [current, archive] = await Promise.all([
this.readText(target.file),
this.readArchive(target.archive, target.relative),
]);
const version = archive.versions.find((v) => v.id === id);
if (!version) throw new ScriptHistoryError(404, '历史版本不存在或已清理');
return { version, current, currentHash: hash(current) };
}
async save(
directory: string,
filename: string,
content: string,
options: SaveOptions = {},
) {
return this.mutate(directory, filename, { content, ...options });
}
async restore(
directory: string,
filename: string,
id: string,
expectedHash: string,
) {
return this.mutate(directory, filename, { id, expectedHash });
}
private async mutate(
directory: string,
filename: string,
change:
| ({ content: string } & SaveOptions)
| { id: string; expectedHash: string },
) {
const target = await this.target(directory, filename);
const releaseFile = await lock(target.file, {
lockfilePath: getUniqueLockPath(target.file),
retries: { retries: 20, minTimeout: 50, maxTimeout: 250 },
});
try {
let current: string;
try {
current = await this.readText(target.file);
if (
'content' in change &&
(Buffer.byteLength(change.content) > this.limits.fileBytes ||
change.content.includes('\0'))
) {
throw new ScriptHistoryError(
413,
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
);
}
} catch (error) {
if (
!('content' in change) ||
!(error instanceof ScriptHistoryError) ||
error.status !== 413
)
throw error;
// Hash oversized or non-UTF-8 files without reading them all into memory.
const digest = createHash('sha256');
for await (const chunk of createReadStream(target.file))
digest.update(chunk);
const currentHash = digest.digest('hex');
if (!change.skipHistory) throw new HistoryUnavailableError(currentHash);
if (change.expectedHash !== currentHash)
throw new ScriptHistoryError(409, '文件已变化,请刷新历史版本后重试');
await this.preparedWrite(target.file, change.content, (commit) =>
commit(),
);
return {
content: change.content,
changed: true,
historyRecorded: false,
};
}
if (
('id' in change || change.skipHistory) &&
hash(current) !== change.expectedHash
) {
throw new ScriptHistoryError(409, '文件已变化,请刷新历史版本后重试');
}
if ('content' in change && change.content === current)
return { content: current, changed: false };
await fs.mkdir(this.historyRoot, { recursive: true, mode: 0o700 });
const releaseHistory = await lock(this.historyRoot, {
retries: { retries: 20, minTimeout: 50, maxTimeout: 250 },
});
try {
const archive = await this.readArchive(target.archive, target.relative);
const restored =
'id' in change
? archive.versions.find((v) => v.id === change.id)
: undefined;
if ('id' in change && !restored)
throw new ScriptHistoryError(404, '历史版本不存在或已清理');
const content =
'content' in change ? change.content : restored!.content;
if (content === current) return { content, changed: false };
if (
Buffer.byteLength(content) > this.limits.fileBytes ||
content.includes('\0')
) {
throw new ScriptHistoryError(
413,
'历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本',
);
}
const currentHash = hash(current);
if (
archive.versions[archive.versions.length - 1]?.hash !== currentHash
) {
const head =
archive.head?.hash === currentHash ? archive.head : undefined;
archive.versions.push({
id: randomUUID(),
createdAt: head?.createdAt || new Date().toISOString(),
source:
head?.source ||
(archive.versions.length ? 'external' : 'baseline'),
hash: currentHash,
content: current,
});
}
// Only prior contents are stored. Head metadata labels the next snapshot,
// but is used only if its hash matches the file. A failed file write can
// never introduce a phantom content version or lose the old snapshot.
archive.head = {
hash: hash(content),
createdAt: new Date().toISOString(),
source: 'id' in change ? 'restore' : 'save',
};
const packed = await this.pack(archive);
const cleanupPending = await this.preparedWrite(
target.file,
content,
(commit) => this.store(target.archive, packed, commit),
);
return {
content,
changed: true,
historyRecorded: true,
cleanupPending,
};
} finally {
await releaseHistory();
}
} finally {
await releaseFile();
}
}
private async pack(archive: Archive) {
archive.versions = archive.versions.slice(-this.limits.versions);
while (true) {
const serialized = Buffer.from(JSON.stringify(archive));
if (serialized.length <= this.limits.unpackedBytes) {
const compressed = await compress(serialized, {
params: {
[constants.BROTLI_PARAM_QUALITY]: 4,
[constants.BROTLI_PARAM_LGWIN]: 22,
},
});
if (compressed.length <= this.limits.archiveBytes) return compressed;
}
if (archive.versions.length <= 1)
throw new ScriptHistoryError(413, '历史版本容量不足,无法安全保存');
archive.versions.shift();
}
}
/** Stage the script before publishing history. A failed write/fsync never
* truncates the original. Preserve the existing file's ownership and mode. */
private async preparedWrite<T>(
file: string,
content: string,
run: (commit: () => Promise<void>) => Promise<T>,
): Promise<T> {
const stat = await fs.stat(file);
const temporary = path.join(
path.dirname(file),
`.ql-save-${randomUUID()}.tmp`,
);
try {
await fs.writeFile(temporary, content, {
encoding: 'utf8',
mode: 0o600,
flag: 'wx',
});
if (
typeof process.getuid === 'function' &&
typeof process.getgid === 'function' &&
(stat.uid !== process.getuid() || stat.gid !== process.getgid())
) {
await fs.chown(temporary, stat.uid, stat.gid);
}
await fs.chmod(temporary, stat.mode & 0o7777);
const handle = await fs.open(temporary, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
return await run(() => fs.rename(temporary, file));
} finally {
await fs.unlink(temporary).catch(() => undefined);
}
}
private async store(
file: string,
content: Buffer,
commit: () => Promise<void>,
) {
const entries = [];
for (const name of await fs.readdir(this.historyRoot)) {
if (!/^[a-f0-9]{64}\.br$/.test(name)) continue;
const fullPath = path.join(this.historyRoot, name);
if (fullPath === file) continue;
const stat = await fs.lstat(fullPath);
entries.push({ file: fullPath, size: stat.size, time: stat.mtimeMs });
}
entries.sort((a, b) => a.time - b.time);
let total = entries.reduce((sum, v) => sum + v.size, content.length);
const victims: string[] = [];
while (
entries.length &&
(total > this.limits.totalBytes || entries.length >= this.limits.files)
) {
const oldest = entries.shift()!;
victims.push(oldest.file);
total -= oldest.size;
}
if (total > this.limits.totalBytes)
throw new ScriptHistoryError(413, '历史版本容量不足,无法安全保存');
const previous = await fs.readFile(file).catch((error) => {
if (error.code === 'ENOENT') return undefined;
throw error;
});
const temporary = path.join(this.historyRoot, '.pending.tmp');
const publish = async (bytes: Buffer) => {
await fs.unlink(temporary).catch((error) => {
if (error.code !== 'ENOENT') throw error;
});
await fs.writeFile(temporary, bytes, { mode: 0o600, flag: 'wx' });
const handle = await fs.open(temporary, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
await fs.rename(temporary, file);
};
try {
await publish(content);
try {
await commit();
} catch (error) {
// The original script is still intact. Restore its exact history before
// returning an error; no unrelated archive has been removed yet.
if (previous) await publish(previous);
else await fs.unlink(file);
throw error;
}
// Quota eviction is post-commit maintenance. Report cleanup failures as a
// warning, not a failed save (which would encourage a misleading retry).
let cleanupPending = false;
for (const victim of victims) {
try {
await fs.unlink(victim);
} catch (error: any) {
if (error.code !== 'ENOENT') cleanupPending = true;
}
}
return cleanupPending;
} finally {
await fs.unlink(temporary).catch(() => undefined);
}
}
}
+5
View File
@@ -0,0 +1,5 @@
/** Quote one literal POSIX shell argument, including quotes and substitutions. */
export function shellQuote(value: string): string {
if (value.includes('\0')) throw new Error('Invalid shell argument');
return "'" + value.replace(/'/g, "'\\''") + "'";
}
+47
View File
@@ -0,0 +1,47 @@
import { fork } from 'child_process';
// Keep one-off startup dependencies out of the long-lived cluster primary.
export function runStartupProcess(entrypoint: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = fork(entrypoint, [], { stdio: 'inherit' });
let interrupted: NodeJS.Signals | undefined;
let killTimer: NodeJS.Timeout | undefined;
const terminate = (signal: NodeJS.Signals) => {
if (interrupted) return;
interrupted = signal;
child.kill(signal);
killTimer = setTimeout(() => child.kill('SIGKILL'), 8000);
killTimer.unref();
};
const onSigterm = () => terminate('SIGTERM');
const onSigint = () => terminate('SIGINT');
const onExit = () => child.kill('SIGKILL');
const cleanup = () => {
if (killTimer) clearTimeout(killTimer);
process.removeListener('SIGTERM', onSigterm);
process.removeListener('SIGINT', onSigint);
process.removeListener('exit', onExit);
};
process.once('SIGTERM', onSigterm);
process.once('SIGINT', onSigint);
process.once('exit', onExit);
child.once('error', (error) => {
cleanup();
reject(error);
});
child.once('close', (code, signal) => {
cleanup();
if (code === 0 && !interrupted) {
resolve();
} else {
reject(
new Error(
`Startup process failed (${interrupted || signal || code})`,
),
);
}
});
});
}
+30
View File
@@ -0,0 +1,30 @@
import lockfile from 'proper-lockfile';
import path from 'path';
import config from '../config';
// Protect subscription storage ownership from the first database read through
// cleanup. Creation and URL/branch updates must use the same cross-process lock.
export async function withSubscriptionMutation<T>(
operation: () => Promise<T>,
): Promise<T> {
const target = `${path.resolve(config.scriptPath)}.subscriptions`;
let release: () => Promise<void>;
try {
release = await lockfile.lock(target, {
realpath: false,
stale: 30000,
update: 10000,
retries: { retries: 50, factor: 1, minTimeout: 100, maxTimeout: 100 },
});
} catch (cause) {
throw Object.assign(
new Error('Subscription configuration is busy', { cause }),
{ status: 503 },
);
}
try {
return await operation();
} finally {
await release();
}
}
+45
View File
@@ -0,0 +1,45 @@
import { resolveFileAccess } from './fileAccess';
import { createHash } from 'crypto';
export function assertSubscriptionAlias(alias: string): void {
if (
typeof alias !== 'string' ||
alias.length > 200 ||
!/^[^\s\\\p{C}]+$/u.test(alias) ||
alias.startsWith('~') ||
alias.split('/').some((part) => !part || part === '.' || part === '..')
) {
throw Object.assign(new Error('Invalid subscription alias'), {
status: 400,
});
}
}
/** Keep ordinary SSH names and use a reserved namespace for path-like aliases.
* The database alias and its script/log directories remain unchanged. */
export function getSubscriptionSshAlias(alias: string): string {
assertSubscriptionAlias(alias);
if (/^[\p{L}\p{N}_.+-]+$/u.test(alias) && !alias.endsWith('.config')) {
return alias;
}
return `~sub_${createHash('sha256').update(alias).digest('hex')}`;
}
export function resolveSubscriptionPath(
root: string,
alias: string,
suffix = '',
): string {
// Internal SSH filenames cannot collide with user-supplied aliases.
if (!/^~sub_[a-f0-9]{64}$/.test(alias)) {
assertSubscriptionAlias(
alias.startsWith('~global_') ? alias.slice(8) : alias,
);
}
const target = resolveFileAccess(root, [alias + suffix]);
if (!target)
throw Object.assign(new Error('Invalid subscription path'), {
status: 400,
});
return target;
}
+106
View File
@@ -0,0 +1,106 @@
import type { Subscription } from '../data/subscription';
import { formatUrl } from '../config/subscription';
import path from 'path';
import fs from 'fs';
import { resolveFileAccess } from './fileAccess';
// Storage namespaces come from URLs and branches, not user aliases. In
// particular, a legal Git directory can exceed the alias's 200-character limit.
export function isSubscriptionStorageName(name: string): boolean {
return (
typeof name === 'string' &&
!!name &&
!path.isAbsolute(name) &&
!/[\0\r\n\\]/.test(name) &&
!name.split('/').some((part) => !part || part === '.' || part === '..')
);
}
export function resolveSubscriptionStoragePath(
root: string,
name: string,
): string | undefined {
const target = isSubscriptionStorageName(name)
? resolveFileAccess(root, [name])
: '';
if (!target) {
if (isSubscriptionStorageName(name)) {
try {
fs.lstatSync(path.resolve(root, name));
} catch (error: any) {
// A long query can make the Shell candidate unrepresentable while
// the CLI's pathname-based file exists. Use the actual filesystem
// limit: macOS permits Unicode names exceeding 255 UTF-8 bytes.
if (error.code === 'ENAMETOOLONG') return undefined;
}
}
throw Object.assign(new Error('Invalid subscription path'), {
status: 400,
});
}
return target;
}
/** Match shell/update.sh get_uniq_path and update_raw, including SCP URLs. */
export function getSubscriptionStorageNames(
doc: Subscription,
engine: 'shell' | 'cli' = 'shell',
): {
script: string;
repo?: string;
raw?: string;
} {
// Old imported rows may only have an alias and no URL.
if (!doc.url) return { script: doc.alias, repo: doc.alias };
const url = formatUrl(doc).url!;
const trimmed = url.endsWith('/') ? url.slice(0, -1) : url;
const basename = trimmed.slice(trimmed.lastIndexOf('/') + 1);
const dot = basename.lastIndexOf('.');
const repo = dot < 0 ? basename : basename.slice(0, dot);
const parentUrl = engine === 'cli' ? trimmed : url;
const slash = parentUrl.lastIndexOf('/');
const parent =
engine === 'shell' && slash < 0 ? url : parentUrl.slice(0, slash);
const owner = parent.slice(parent.lastIndexOf('/') + 1);
const author = owner.slice(owner.lastIndexOf(':') + 1);
const stem = `${author.slice(author.lastIndexOf('.') + 1)}_${repo}`;
if (doc.type === 'file') {
const suffix =
engine === 'cli'
? path.extname(new URL(url).pathname)
: `.${url.slice(url.lastIndexOf('.') + 1)}`;
const raw = `${stem}${suffix}`;
return { script: `raw_${raw}`, raw };
}
const directory = doc.branch ? `${stem}_${doc.branch}` : stem;
return { script: directory, repo: directory };
}
// A subscription may have run through either engine before being deleted.
export function getSubscriptionStorageCandidates(doc: Subscription) {
const candidates: ReturnType<typeof getSubscriptionStorageNames>[] = [];
for (const engine of ['shell', 'cli'] as const) {
try {
candidates.push(getSubscriptionStorageNames(doc, engine));
} catch {
// Historical rows may have an invalid SSH alias or missing credentials.
// Retain their URL-derived storage without needing an executable SSH URL.
try {
candidates.push(
getSubscriptionStorageNames(
{
url: doc.url,
alias: doc.alias,
branch: doc.branch,
type: doc.type === 'private-repo' ? 'public-repo' : doc.type,
},
engine,
),
);
} catch {
// One engine's invalid URL must not hide the other engine's candidate.
}
}
}
return candidates;
}
+1 -1
View File
@@ -4,7 +4,7 @@ import path from 'path';
import { writeFile, open, chmod } from 'fs/promises';
import { fileExist } from '../config/util';
function getUniqueLockPath(filePath: string) {
export function getUniqueLockPath(filePath: string) {
const sanitizedPath = filePath
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/^_/, '');
+5 -16
View File
@@ -2,30 +2,19 @@ import 'reflect-metadata';
import OpenService from './services/open';
import { Container } from 'typedi';
import LoggerInstance from './loaders/logger';
import config from './config';
import path from 'path';
import os from 'os';
import { writeFileWithLock } from './shared/utils';
const tokenFile = path.join(config.configPath, 'token.json');
async function getToken() {
try {
Container.set('logger', LoggerInstance);
const openService = Container.get(OpenService);
const appToken = await openService.generateSystemToken();
const appToken = await openService.generateSystemToken(
process.argv.includes('--renew'),
);
console.log(appToken.value);
await writeFile({
value: appToken.value,
expiration: appToken.expiration,
});
} catch (error) {
console.log(error);
console.error(error);
process.exitCode = 1;
}
}
async function writeFile(data: any) {
await writeFileWithLock(tokenFile, `${JSON.stringify(data)}${os.EOL}`);
}
getToken();
+4 -20
View File
@@ -1,5 +1,5 @@
import { Joi } from 'celebrate';
import CronExpressionParser from 'cron-parser';
import { isValidCronSchedule } from '../shared/cronSchedule';
import { ScheduleType } from '../interface/schedule';
import path from 'path';
import config from '../config';
@@ -12,23 +12,7 @@ const validateSchedule = (value: string, helpers: any) => {
return value;
}
// 检测裸 /N 模式:cron-parser 会接受,但 node-schedule 会返回 null
// 提前拦截,避免任务入库后调度器注册失败
if (/\s\/\d/.test(value) || /^\/\d/.test(value)) {
return helpers.error('any.invalid');
}
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数字段上返回 null
if (/\?/.test(value)) {
return helpers.error('any.invalid');
}
try {
if (CronExpressionParser.parse(value).hasNext()) {
return value;
}
} catch (e) {
return helpers.error('any.invalid');
}
if (isValidCronSchedule(value)) return value;
return helpers.error('any.invalid');
};
@@ -75,7 +59,7 @@ export const commonCronSchema = {
}
if (
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test(
!/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w\p{Script=Han}.-]+\/)*[\w\p{Script=Han}.-]+\/?$/u.test(
value,
)
) {
@@ -87,7 +71,7 @@ export const commonCronSchema = {
return value;
})
.messages({
'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符',
'string.pattern.base': '日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符',
'string.max': '日志名称不能超过100个字符',
'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null',
}),
+2
View File
@@ -0,0 +1,2 @@
/dist/
node_modules
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2021 WHYOUR <https://github.com/whyour>.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+45
View File
@@ -0,0 +1,45 @@
# Panel-internal TypeScript tools
[简体中文](LOCAL.md) | **English**
The npm and panel-internal entries both use the name `ql`, but have separate Commander command trees. The npm entry only calls remote APIs; the internal entry only runs local tools. Verify the absolute executable path and `--help` before use. Installing the npm package does not migrate the built-in Shell commands.
These tools ship with panel builds and are excluded from the @whyour/qinglong-cli npm package. Node >=22.12 is required; user configuration/hooks remain Bash and scripts need their interpreters. Development publishing was removed from the CLI; shell/pub.sh remains available to release workflows.
```sh
npm ci --prefix cli
npm run build:cli
node cli/dist/ql.js --help
node cli/dist/ql.js task exec --root /ql demo.js now
node cli/dist/ql.js reload --root /ql
```
Run on the actual panel host or inside its container. For Docker use `docker exec <container> <selected-absolute-entry> ...`. A workstation npm CLI cannot reset a remote account; a mounted data directory alone is not the complete operational environment.
| Capability | Internal commands |
| --- | --- |
| Execution/inventory | `ql task exec [options] [script]`, with `ql task <script>` and `task <script>` shorthands |
| Subscription synchronization | `ql repo <url> [include] [exclude] [dependencies] [branch] [extensions] [proxy] [autoAdd] [autoDelete]`, `ql raw <url> [proxy] [autoAdd] [autoDelete]` |
| Startup/repair | `ql start`, `ql repair-config`, `ql check` |
| Upgrade/reload | `ql update [--mirror github\|gitee] [--download-only]`, `ql reload [--target services\|system\|data]` |
| Logs/extensions | `ql rmlog <days>`, `ql extra`, `ql bot` |
| Recovery | `ql resetlet`, `ql resettfa`, `ql resetpwd -- <value>`, `ql resetname -- <value>` |
`ql local <command>` remains compatible. `update false` means --download-only; reload system/data/services maps to --target. Place maintenance --root, --data-dir and --json before --. Password values appear in process arguments: use the owner's trusted terminal and never request passwords in chat.
Runner options precede the script. now skips delay; conc/desi select accounts; -- passes subsequent arguments through. With QL_DIR set, bare ql task or task lists JS scripts. Explicit --help does not read configuration. JSON mode sends script output to stderr and the final result to stdout, retaining the script exit code. The internal entry rejects remote API commands and does not read remote credentials. Use the separate npm entry for remote management; use task exec for scripts with reserved API action names.
repo/raw retain positional arguments and QL_DIR/QL_DATA_DIR, without --root/--json. Filtering uses native grep -E POSIX ERE; Git/curl and other operation-specific tools are required. The configuration bridge uses Bash and env supporting -0.
check installs dependencies, repairs and reloads; inspect its before/after observations rather than treating it as a read-only probe. reload defaults to service restart; system/data apply staged files. start --no-startup skips OS boot registration for containers; start --reload skips installation, optional hooks and boot registration. Bot setup involves system/pip dependencies and external processes.
## Integration and skill
On a panel containing the selection loader, set `QL_CLI_ROOT=/absolute/path/to/built/cli` to the internal build directory containing the full dist tree, **not an npm installation**. Restart to create private ~/bin ql/task/cron wrappers and prioritize them for panel processes. Independent terminals should use the selected absolute path. Unset the variable and restart to restore Shell entries. Invalid paths report errors without silent fallback. Original Shell entries remain the default.
dist/startup.js retains startup/reload compatibility; dist/compat.js and dist/subscription-worker.js are internal adapters, not npm commands. Lifecycle fields follow the installed panel version; customized versions may select QL_CLI_LIFECYCLE=legacy|extended.
The separate [qinglong-local skill](skills/qinglong-local/SKILL.md) covers execution and maintenance. Use qinglong-cli for remote API management. QL_LANG=en selects English help/messages while JSON fields remain stable.
Distinguish isolated fixtures, real-panel validation, and actual system upgrades/reboots. Fixture success does not establish support for every distribution, Bot or public upgrade download. Development publishing is explicitly excluded. Performance comparisons use temporary evaluation scripts, not shipped product code.
+47
View File
@@ -0,0 +1,47 @@
# 面板内部 TypeScript 工具
**简体中文** | [English](LOCAL.en.md)
npm 与面板内部入口都叫 `ql`,但使用独立的 Commander 命令树:npm 入口只调用远程 API,内部入口只运行本机工具。使用前确认可执行文件的绝对路径和 `--help`;安装 npm 包不会迁移内置 Shell 命令。
这些工具随面板构建交付,不包含在 `@whyour/qinglong-cli` npm 包中。要求 Node >=22.12;用户配置和 hook 保持 Bash,任务还需要对应解释器。开发发布命令已移除,原 `shell/pub.sh` 保留供发布流程使用。
```sh
npm ci --prefix cli
npm run build:cli
node cli/dist/ql.js --help
node cli/dist/ql.js task exec --root /ql demo.js now
node cli/dist/ql.js reload --root /ql
```
必须在实际安装面板的宿主机或容器中运行。Docker 使用 `docker exec <容器> <容器内选定入口> ...`;不能在工作站上用 npm ql 重置远端账号,单独挂载 data 目录也不是完整运行环境。
## 命令
| 能力 | 内部命令 |
| --- | --- |
| 脚本执行/清单 | `ql task exec [选项] [脚本]`,兼容 `ql task <脚本>` 和 `task <脚本>` |
| 订阅同步 | `ql repo <url> [include] [exclude] [dependencies] [branch] [extensions] [proxy] [autoAdd] [autoDelete]`、`ql raw <url> [proxy] [autoAdd] [autoDelete]` |
| 启动与修复 | `ql start`、`ql repair-config`、`ql check` |
| 升级/重载 | `ql update [--mirror github\|gitee] [--download-only]`、`ql reload [--target services\|system\|data]` |
| 日志与扩展 | `ql rmlog <days>`、`ql extra`、`ql bot` |
| 账号恢复 | `ql resetlet`、`ql resettfa`、`ql resetpwd -- <value>`、`ql resetname -- <value>` |
`ql local <命令>` 保留为兼容写法。`update false` 等价于 `--download-only`,`reload system/data/services` 映射到 `--target`。维护选项 `--root`、`--data-dir`、`--json` 必须放在 `--` 前。密码位置参数会出现在进程参数中,使用本人可信终端,不向聊天发送密码。
执行器选项必须放在脚本前;`now` 跳过延迟,`conc`/`desi` 支持账号选择,`--` 后参数透传。设置 QL_DIR 后,无参数 `ql task` 或 `task` 显示 JS 脚本清单;显式 `--help` 不读取配置。`--json` 将脚本输出写 stderr、最终结果写 stdout,并保留脚本退出码。内部入口拒绝远程 API 命令,不读取远程认证配置。远程管理使用独立 npm 入口;与 API 动作同名的脚本使用显式 `task exec`。
repo/raw 沿用位置参数,使用 QL_DIR/QL_DATA_DIR,不接受 --root/--json;筛选采用本机 `grep -E` 的 POSIX ERE,需 Git/curl 等系统工具。配置桥使用 Bash 和支持 `-0` 的 env。
`check` 会安装依赖、修复并重载,并非只读探测;结果须检查 before/after 健康观测。`reload` 默认重启服务,system/data 应用暂存文件。`start --no-startup` 跳过 OS 启动注册,适用于容器;`start --reload` 跳过依赖安装、可选 hook 与启动注册。Bot 涉及系统/pip 依赖与外部进程。
## 集成与 Skill
包含选择加载器的面板设置 `QL_CLI_ROOT=/absolute/path/to/built/cli`,指向包含完整 dist 的面板工具构建目录,**不能指向 npm 包**。重启后加载器在 ~/bin 创建私有 ql/task/cron 包装器,并为面板进程优先使用它们;独立终端应显式使用该路径。清除变量并重启恢复原 Shell 入口。无效路径报错,不静默降级。默认仍保留旧 Shell 入口。
`dist/startup.js` 兼容旧启动/reload;`dist/compat.js` 与 `dist/subscription-worker.js` 保留内部适配用途,不注册为 npm 命令。生命周期根据已安装面板版本选择字段,定制版本可设置 QL_CLI_LIFECYCLE=legacy|extended。
独立 Skill 位于 [skills/qinglong-local](skills/qinglong-local/SKILL.md),包含完整执行与维护参考。远程管理另用 qinglong-cli Skill。`QL_LANG=en` 切换帮助和执行提示;JSON 字段保持一致。
功能验收区分隔离夹具、真实面板和真实系统升级/重启。不能将夹具通过视为所有发行版、Bot 或公网升级均已验证。开发发布从迁移目标中明确排除;性能比较使用临时评测脚本,不随产品发布。
+168
View File
@@ -0,0 +1,168 @@
# QingLong 2.x remote management CLI
[简体中文](README.md) | **English**
The npm and panel-internal entries both use the name `ql`, but have separate Commander command trees. The npm entry only calls remote APIs; the internal entry only runs local tools. Verify the absolute executable path and `--help` before use. Installing the npm package does not migrate the built-in Shell commands.
`@whyour/qinglong-cli` is a standalone npm client for the panel's open API. It registers only `ql`, for the currently supported OpenAPI resources. Local execution, repo/raw workers, reload/update/account recovery belong to the panel's internal tools and are excluded from npm. Remote CLI commands do not manage development publishing.
## Installation and commands
Requires Node >=22.12; Node 24 is recommended. Commander is bundled with no additional runtime npm dependencies. There is no standalone CLI image.
```sh
npm install -g @whyour/qinglong-cli
ql --help
# Temporary use without replacing an existing panel ql
npm exec --package=@whyour/qinglong-cli -- ql --help
```
To build from source, use `npm ci --prefix cli` and `npm run build:cli`, then run `npm pack` in cli and install the local tgz. Global installation occupies the `ql` name. On a panel host use a separate npm prefix or `node /absolute/path/to/cli/dist/npm/ql.js`.
```sh
ql login --url https://ql.example.com
ql auth status --json
ql task list --search example --page 1 --size 50 --json
ql task get 12 --json
ql task logs 12 --tail 200 --json
ql task run 12 --json
ql task stop 12 --json
ql auth status --scope subscriptions --json
ql subscription list --json
ql subscription get 5 --json
ql subscription run 5 --json
ql subscription stop 5 --json
ql subscription logs 5 --tail 200 --json
ql subscription enable 5 --json
ql subscription disable 5 --json
ql auth logout --json
```
Use command `--help` and `QL_LANG=en` for English. Command names and JSON fields do not change with language. The npm CLI never interprets unknown task actions as local scripts and does not register a separate `task` executable.
## Complete OpenAPI management
The CLI also supports task/subscription CRUD, application and secret management, environment variables, configuration, scripts, logs, dependencies, system, dashboard and user APIs. `ql api routes --json` lists all 146 active routes; three retired file-reading endpoints are excluded. CI compares the catalogue against backend routes. See the [complete bilingual reference](skills/qinglong-cli/references/openapi.md) for every command and payload.
```sh
ql task create --name demo --command 'task demo.js' --schedule '0 0 * * *' --json
ql subscription create --type public-repo --url https://example.com/repo.git --alias demo --schedule-type crontab --schedule '0 0 * * *' --json
ql app create --name agent --scopes crons,subscriptions --show-secrets --json
ql env create --data @envs.json --json
ql api request PUT /open/crons/run --data '[12,13]' --json
```
CLI 0.1.2 adds script history listing, detail and confirmed restore commands, requiring the history APIs in QingLong 2.23.0. Restore uses the current hash from the detail response; on 409, read and review the current state again.
```sh
ql script history-list --query '{"filename":"demo.js","path":""}' --json
ql script history-detail --query '{"filename":"demo.js","path":"","id":"VERSION_UUID"}' --json
ql script history-restore --data '{"filename":"demo.js","path":"","id":"VERSION_UUID","expectedHash":"CURRENT_SHA256"}' --json
```
Replace `VERSION_UUID` and `CURRENT_SHA256` with values returned by the list and detail commands.
Use --data JSON/@file/- for bodies, --query for query objects, --file for uploads and --output for downloads. New commands support --timeout seconds. Existing command contracts remain; raw API requests expose all fields and batch operations. Downloads do not overwrite files. App secrets require --show-secrets; other raw resources may contain sensitive data.
`--timeout` only limits waiting for the HTTP response; it does not stop server-side tasks. A failed write may already have taken effect, so inspect the resource before retrying.
Remote ql system commands call the target panel API. Local reload/reset tools remain excluded from npm.
## Authentication
Protected requests use `Authorization: Bearer <token>`. Two credential sources are supported:
| Mode | Supply credentials | Persistence and refresh |
| --- | --- | --- |
| Application login | `ql login --url <panel-url>` prompts for Client ID / Client Secret; automation injects `QL_CLIENT_ID` and `QL_CLIENT_SECRET` | Exchanges credentials at `/open/auth/token`, saves credentials/token locally and refreshes expired tokens |
| Direct access token | Set `QL_URL` and `QL_ACCESS_TOKEN` together; accepts a valid application token or panel session token | Overrides saved configuration, is not persisted and is not refreshed |
### Application login
Create a dedicated panel application with the required scopes, such as crons, subscriptions or envs. `login` and `auth login` are equivalent. Interactive login hides the Client Secret; secret command-line arguments are not supported.
```sh
ql login --url https://ql.example.com
ql auth status --scope crons --json
# For automation, inject QL_CLIENT_ID and QL_CLIENT_SECRET before the same login command
```
Credentials/token are stored in `~/.config/qinglong/cli.json`, a plaintext file owned by the current user with mode 0600. `QL_CLI_CONFIG` selects another file. Login validates application credentials, not every resource permission.
### Direct access token
Inject QL_URL and QL_ACCESS_TOKEN through your terminal or CI credential settings, then invoke commands without login:
```sh
ql auth status --scope apps --json
ql app list --json
```
Both variables are required for protected commands; providing only one is a usage error. They take precedence over the file selected by QL_CLI_CONFIG. Running login still writes application configuration, but subsequent requests continue using the environment token. Run `unset QL_URL QL_ACCESS_TOKEN` in your own terminal to return to saved application credentials.
Application management requires apps permission or an authorized panel session. The current UI does not list every backend scope; the CLI never escalates automatically. Anonymous user login requires only QL_URL and accepts --data @credentials.json or --data -; the returned session is not automatically saved. A two-factor challenge (server 420) exits 3 and directs you to user two-factor-login with username/password/code in its JSON body. Do not put credentials in command arguments or chat.
### Permissions, logout and failures
Auth status checks crons read permission by default. Use --scope subscriptions, --scope apps or another supported scope for that resource. A representative read does not prove permission for every write. The crons scope covers reads and execution.
Auth logout deletes only saved application configuration. It neither revokes server tokens nor clears QL_ACCESS_TOKEN from the parent environment, so direct-token access may continue. Revoke access through the panel's application/session management. Resetting an application secret invalidates its old tokens; login again with the new secret.
Use the panel root URL with any proxy prefix, without /open. Remote connections require HTTPS; loopback HTTP is allowed. Requests never follow redirects. The 2.x application token endpoint carries credentials in query parameters; avoid logging that query at the proxy. Operations are not replayed after 401/403. An expired direct token must be replaced; the CLI does not fall back to saved application credentials.
## Tasks, subscriptions and output
Subscription commands include create/update/delete/list/get/run/stop/logs/enable/disable/status/log-files. Subscription lists return `{code:200,data:[...]}`, without task pagination. The subscription list/get commands omit repository URLs, fetch credentials, proxies and executable hooks; raw API responses and create/update results may contain sensitive fields. Logs are not automatically redacted.
Commands output pretty-printed JSON by default; `--json` produces a single line. Success goes to stdout and errors to stderr. Help with `--json` returns `{code:200,data:{help:"..."}}`.
- Task list: `{code:200,data:{data:[...tasks],total:123}}`. Default page size: 50; maximum: 200. Task fields retain server content.
- Task get: `{code:200,data:{id:12,...}}`.
- Logs: `{code:200,data:"log tail",logStatus:"completed",truncated:true}`. Default tail: 200 lines; maximum: 10000. `logStatus` is omitted when absent from the older API.
- Task run/stop: `{code:200,data:{taskId:12,action:"run",accepted:true}}`. Subscription run/stop/enable/disable use `subscriptionId`; CRUD and raw API operations retain their server response instead. Acceptance does not mean execution succeeded.
- Errors: `{code:1,message:"..."}`. Exit codes: 0 success, 1 API/network/configuration failure, 2 invalid arguments, 3 missing authentication, HTTP/API 401/403 or a two-factor challenge.
IDs must be positive integers. Each task run/stop request addresses one task. The 2.x API provides no separate run ID or idempotency key: an error may leave the outcome unknown. Inspect status before retrying. The CLI does not automatically retry HTTP requests.
Logs may belong to an earlier run; `completed` does not imply success. `--tail` truncates on the client and does not reduce server reads or network traffic. Neither task fields nor logs are automatically redacted.
## Skill, build and validation
The package includes `skills/qinglong-cli`, covering all remote commands. Copy it to your agent's skill directory and verify the remote npm entry. It contains no credentials and does not replace server permissions.
Strict TypeScript and Commander provide shared parsing/output with separate remote and internal entries. `dist/npm/ql.js` is a self-contained remote bundle; its build graph rejects local operational modules. The npm file allowlist includes only the remote bundle/map, licenses, bilingual documentation and remote skill. The full dist tree is panel-internal and is not published to npm.
```sh
npm ci --prefix cli
npm run check:cli
npm run test:cli
node cli/scripts/verify-package.cjs
```
Tests cover requests, token refresh, output, errors, no automatic retries, permissions and isolated installation. Package verification installs the archive offline, verifies the sole ql executable and rejects local commands.
The CLI package workflow checks, builds and tests Node 22.12/24 on relevant PRs, develop/master pushes and manual runs. Node 24 uploads the archive verified by offline installation as `qinglong-cli-<commit>`. After both matrix jobs succeed, master pushes publish that exact archive to npm as latest, using GitHub Actions OIDC trusted publishing without an NPM_TOKEN secret. A manual release requires publish on master, or publish plus an explicit `X.Y.Z-alpha.N`, `X.Y.Z-beta.N` or `X.Y.Z-rc.N` version on develop. Develop prereleases set the same requested version before both Node verification jobs and publish to alpha/beta/rc without changing latest. Ordinary PRs, develop pushes, validation-only manual runs and forks do not publish. Releases and validation use separate concurrency groups; new validation does not cancel an active release. Configure the npm Trusted Publisher for repository `whyour/qinglong`, workflow `cli-package.yml`, with `npm publish` allowed. The panel package `@whyour/qinglong` separately trusts `build-docker-image.yml`. Publishing uses Node 24 with `id-token: write` granted only to the publish job. New packages need an initial publication before configuring their trusted publisher.
The CLI has an independent stable version in cli/package.json and cli/package-lock.json. Before releasing changes, run `npm version patch --prefix cli --no-git-tag-version` (or minor/major) and commit both files. Existing stable versions are skipped with a notice; an existing prerelease is an error and requires a new version. Registry failures stop publication. Manual develop prereleases only update the version in that verification workspace and do not replace stable source metadata maintenance. Publication does not rebuild the verified archive or run package lifecycle scripts.
## Panel-internal tools
Local execution, subscription synchronization and maintenance ship with panel source/builds, using the full internal dist tree and a separate `qinglong-local` skill. See `cli/LOCAL.md` and `cli/LOCAL.en.md` in the source checkout. An npm installation is not a valid QL_CLI_ROOT. Recovery/reload must run on the actual panel host or inside its container, using docker exec for Docker installations.
API task run returns acceptance; a local runner waits for script completion. Their elapsed times are different measurements. Compare the internal TS runner against Shell using identical configuration/scripts; remote API management has no equivalent old Shell management command.
## Source layout
```text
src/
entrypoints/ # Executable composition
remote/ # commands / api / auth
internal/ # commands / execution / subscription / maintenance / runtime / integration
compatibility/ # Legacy entry and argument adapters
shared/ # cli / i18n / response types and errors
```
Shared code cannot import business modules. Remote and internal modules may only import their own area and shared code; integration tests enforce these boundaries. Each surface owns its command registry, passed into the shared Commander parser.
Tests follow the same areas under test/. `npm run test:cli` (repository root) or `npm test` (cli/) discovers the standard suites; test/linux remains an explicitly invoked container integration suite. scripts/entrypoints.cjs preserves existing dist executable paths and dist/local/entrypoints.js and cronEntrypoint.js. Moving sources does not require changing QL_CLI_ROOT or cron commands.
+168
View File
@@ -0,0 +1,168 @@
# QingLong 2.x 远程管理 CLI
**简体中文** | [English](README.en.md)
npm 与面板内部入口都叫 `ql`,但使用独立的 Commander 命令树:npm 入口只调用远程 API,内部入口只运行本机工具。使用前确认可执行文件的绝对路径和 `--help`;安装 npm 包不会迁移内置 Shell 命令。
`@whyour/qinglong-cli` 是独立 npm 包,覆盖当前 develop 的有效 OpenAPI,只注册一个 `ql` 命令。它不包含脚本执行器或本机运维实现;`task exec`、`repo/raw`、`reload/update/reset*` 等属于面板内部工具,不随 npm 包分发。远程 CLI 命令不提供开发发布操作。
## 安装与使用
要求 Node >=22.12,推荐 Node 24。Commander 在构建时打包,无额外运行时 npm 依赖,不单独发布 CLI 镜像。
```sh
npm install -g @whyour/qinglong-cli
ql --help
# 临时使用,不覆盖面板已有的 ql
npm exec --package=@whyour/qinglong-cli -- ql --help
```
也可以从源码构建:执行 `npm ci --prefix cli`、`npm run build:cli`,在 cli 目录执行 `npm pack`,然后安装本地 tgz。全局安装会占用 `ql` 名称;已有面板的机器建议使用独立 npm prefix 或直接执行 `node /absolute/path/to/cli/dist/npm/ql.js`。
```sh
ql login --url https://ql.example.com
ql auth status --json
ql task list --search 示例 --page 1 --size 50 --json
ql task get 12 --json
ql task logs 12 --tail 200 --json
ql task run 12 --json
ql task stop 12 --json
ql auth status --scope subscriptions --json
ql subscription list --json
ql subscription get 5 --json
ql subscription run 5 --json
ql subscription stop 5 --json
ql subscription logs 5 --tail 200 --json
ql subscription enable 5 --json
ql subscription disable 5 --json
ql auth logout --json
```
各命令支持 `--help`,`QL_LANG=en` 切换英文帮助;命令和 JSON 字段不随语言变化。`ql task` 中只包含 API 操作,不会把无效命令解释为本机脚本,也不提供独立 `task` npm 入口。
## 全量 OpenAPI 管理
现在还支持任务/订阅创建、修改、删除,应用管理与密钥重置,以及环境变量、配置、脚本、日志、依赖、系统、仪表盘和用户管理。`ql api routes --json` 列出全部 146 条有效路由;3 条已下线文件读取接口不包含在内。新增命令在 [完整双语参考](skills/qinglong-cli/references/openapi.md) 中逐项列出,路由覆盖由 CI 与后端代码核对。
```sh
ql task create --name demo --command 'task demo.js' --schedule '0 0 * * *' --json
ql subscription create --type public-repo --url https://example.com/repo.git --alias demo --schedule-type crontab --schedule '0 0 * * *' --json
ql app create --name agent --scopes crons,subscriptions --show-secrets --json
ql env create --data @envs.json --json
ql api request PUT /open/crons/run --data '[12,13]' --json
```
CLI 0.1.2 增加脚本历史列表、详情和确认恢复命令,需要青龙 2.23.0 的历史 API。恢复使用详情返回的当前哈希,409 后应重新读取并确认。
```sh
ql script history-list --query '{"filename":"demo.js","path":""}' --json
ql script history-detail --query '{"filename":"demo.js","path":"","id":"VERSION_UUID"}' --json
ql script history-restore --data '{"filename":"demo.js","path":"","id":"VERSION_UUID","expectedHash":"CURRENT_SHA256"}' --json
```
将 `VERSION_UUID` 和 `CURRENT_SHA256` 替换为列表和详情实际返回值。
请求体使用 --data JSON/@file/-,查询使用 --query,上传 --file,下载 --output。新命令支持 --timeout 秒数;旧命令行为保留,完整参数可用 api request。下载不覆盖现有文件,应用密钥默认隐藏,明确加 --show-secrets 才输出。新增资源通常保留原始返回字段,注意环境、配置和会话信息可能敏感。
`--timeout` 只限制等待 HTTP 响应,不停止服务器上的任务;失败的写入可能已经生效,应先查询状态。
远程 `ql system ...` 调用面板 API;本机 reload/reset 等仍不在 npm 包中。不要将远程 API 覆盖理解为本机运维重新混入包。
## 认证
受保护的请求均使用 `Authorization: Bearer <token>`,支持以下两种凭据来源:
| 方式 | 提供方式 | 保存与刷新 |
| --- | --- | --- |
| 应用凭据登录 | `ql login --url <面板地址>`,输入 Client ID / Client Secret;自动化通过 `QL_CLIENT_ID`、`QL_CLIENT_SECRET` 注入 | 通过 `/open/auth/token` 换取 token,凭据和 token 保存到本机,过期自动刷新 |
| 直接访问令牌 | 环境中同时设置 `QL_URL`、`QL_ACCESS_TOKEN`,支持有效应用 token 或面板会话 token | 优先于保存的配置,不落盘、不自动刷新 |
### 应用凭据登录
在面板创建专用应用,按需授予 crons、subscriptions、envs 等权限。`login` 与 `auth login` 等价;交互输入 Client ID 和不回显的 Client Secret,密钥不支持命令行参数。
```sh
ql login --url https://ql.example.com
ql auth status --scope crons --json
# 自动化环境事先注入 QL_CLIENT_ID 和 QL_CLIENT_SECRET,再运行同一 login 命令
```
凭据和 token 保存到 `~/.config/qinglong/cli.json`,为当前用户所有的 0600 明文文件。`QL_CLI_CONFIG` 可选择其他配置文件。登录只验证应用凭据,不意味着具备所有资源权限。
### 直接使用访问令牌
在终端或 CI 的凭据配置中注入 `QL_URL` 与 `QL_ACCESS_TOKEN` 后,直接调用命令,无需再执行 login:
```sh
ql auth status --scope apps --json
ql app list --json
```
两个变量必须同时提供给受保护命令;只提供一个会报参数错误。此方式优先于 `QL_CLI_CONFIG` 指定的配置。即使重新执行 login 写入了应用配置,后续请求仍使用环境令牌;切回保存的应用配置时,在自己的终端执行 `unset QL_URL QL_ACCESS_TOKEN`。
应用管理需要 apps 权限或有效的授权面板会话;当前面板 UI 没有列出所有后端 scope,CLI 不自动提权。匿名 `user login` 可在仅设置 QL_URL 时使用 `--data @credentials.json` 或 `--data -` 提交凭据;返回的会话不会自动保存。启用双因素认证时,服务端 420 对应 CLI 退出码 3,随后使用 `user two-factor-login`,请求体包含 username/password/code。不要将凭据写入命令参数或聊天。
### 权限检查、退出与失败
`auth status` 默认检查 crons 读取权限,可用 `--scope subscriptions`、`--scope apps` 等检查对应资源。它只验证代表性读取,不代表所有写操作都获授权。2.x 的 crons scope 同时覆盖读取和执行。
`auth logout` 仅删除本机应用配置,不撤销服务端 token,也不清除父进程中的 QL_ACCESS_TOKEN。令牌模式仍可能继续访问;如需撤销访问,使用面板提供的应用或会话管理。重置应用密钥会使旧应用 token 失效,需要使用新密钥重新 login。
URL 使用面板根地址,可包含代理路径前缀,不附加 `/open`。远程连接要求 HTTPS,回环地址允许 HTTP;不跟随重定向。2.x 应用 token 接口以查询参数传递凭据,代理应避免记录该查询字符串。401/403 不自动重放操作;直接令牌失效时需更换,CLI 不会转而使用保存的应用凭据。
## 输出契约
订阅支持 create/update/delete/list/get/run/stop/logs/enable/disable/status/log-files。`subscription list/get` 保留管理字段投影;run/stop/enable/disable 返回 subscriptionId/action/accepted。创建、修改、删除及通用 API 请求保留服务端响应,可能包含敏感字段;不能将所有变更都解释为 accepted。
命令默认输出缩进 JSON,`--json` 输出单行 JSON;成功写 stdout,错误写 stderr,互不混用。帮助在 `--json` 下也使用 `{code:200,data:{help:"..."}}`。
- `task list`:`{code:200,data:{data:[...tasks],total:123}}`,默认每页 50,最多 200;任务字段保留服务端内容。
- `task get`:`{code:200,data:{id:12,...}}`。
- `task logs`:`{code:200,data:"日志尾部",logStatus:"completed",truncated:true}`。默认 200 行,最多 10000;旧接口若不返回 logStatus,该字段省略。
- `task run/stop`:`{code:200,data:{taskId:12,action:"run",accepted:true}}`。仅表示请求被接受,不表示任务成功完成。
- 错误:`{code:1,message:"..."}`;退出码 1 为 API/网络/配置错误,2 为参数错误,3 为未登录、HTTP/API 401/403 或需要双因素验证;成功退出码 0。
ID 必须为正整数,运行/停止一次操作一个任务。2.x 没有为此提供独立运行 ID 或幂等键,失败响应可能意味着执行结果未知,应先查询状态,禁止盲目重试。CLI 不自动重试 HTTP 请求。
日志是该任务最新日志,可能属于先前运行;`completed` 不代表成功。`--tail` 在客户端截取,不减少服务端读取量或网络传输量。日志与任务字段不自动脱敏,应按需读取并避免向聊天中暴露敏感信息。
## Skill、构建与验证
npm 包附带 `skills/qinglong-cli`,覆盖全部远程命令。复制到所用 Agent 的 skills 目录,并确认使用 npm 远程入口。Skill 不存放凭据,也不替代服务端权限。
源码使用 TypeScript 严格检查和 Commander 解析;共用参数/输出逻辑,分别构建远程入口和面板内部入口。`dist/npm/ql.js` 是可独立运行的远程 bundle,构建依赖图会拒绝引入本机运维模块;npm 文件白名单仅包含该 bundle、source map、许可证、中英文说明和远程 Skill。完整 `dist` 用于面板内部构建,不随 npm 发布。
```sh
npm ci --prefix cli
npm run check:cli
npm run test:cli
node cli/scripts/verify-package.cjs
```
测试覆盖请求格式、认证刷新、输出、错误、禁止自动重试、权限和独立安装。打包验证会离线安装 tgz,并确认唯一入口是 `ql`,本机命令不可调用。
CLI package 工作流在相关 PR、develop/master 推送和手动触发时执行 Node 22.12/24 类型检查、构建及测试。Node 24 上传通过离线安装验证的 `qinglong-cli-<commit>` artifact。两个矩阵任务成功后,master 推送会将这份已验证的 tgz 发布到 npm 的 latest 标签,通过 GitHub Actions OIDC 可信发布,无需 `NPM_TOKEN` Secret。手动发布在 master 勾选 publish,或在 develop 勾选 publish 并填写 `X.Y.Z-alpha.N`、`X.Y.Z-beta.N` 或 `X.Y.Z-rc.N` 版本。develop 预发布在两个 Node 矩阵中先设置相同版本并验证,发布对应 alpha/beta/rc 标签,不更新 latest。普通 PR、develop 推送、仅验证的手动运行和 fork 不发布。发布与验证使用独立并发组,正在运行的发布不会被新验证取消。npm 的 Trusted Publisher 需绑定仓库 `whyour/qinglong` 和工作流 `cli-package.yml`,允许 `npm publish`;面板包 `@whyour/qinglong` 单独绑定 `build-docker-image.yml`。发布 job 使用 Node 24,并仅在该 job 授予 `id-token: write`。新包需先完成首次发布,再配置对应包的可信发布关系。
CLI 版本独立维护在 cli/package.json 和 cli/package-lock.json。发布改动前执行 `npm version patch --prefix cli --no-git-tag-version`(也可用 minor/major),提交这两个文件。已存在的稳定版本提示并跳过;预发布版本已存在则失败,需选择新版本。registry 查询失败停止发布。手动 develop 预发布只临时修改本轮验证工作区的版本,不代替稳定版本元数据维护。发布不重新构建已验证归档,也不执行包生命周期脚本。
## 面板内部工具
本机执行、订阅同步和运维随面板源码/构建交付,使用完整内部 `dist` 与独立 `qinglong-local` Skill;源码说明见 `cli/LOCAL.md` 和 `cli/LOCAL.en.md`。npm 包不能用作 `QL_CLI_ROOT`。账号恢复、服务重载必须在实际面板宿主机或容器中执行;Docker 使用 `docker exec` 调用容器内选定入口。
API `task run` 返回请求接受,本机执行器等待脚本结束,二者耗时不能直接对比。Shell 迁移性能应比较相同配置和脚本下的内部 TS 执行器与原 Shell;远程 API 操作没有对应的旧 Shell 管理命令。
## 源码结构
```text
src/
entrypoints/ # 可执行入口组装
remote/ # commands / api / auth
internal/ # commands / execution / subscription / maintenance / runtime / integration
compatibility/ # 旧入口和参数适配
shared/ # cli / i18n / 响应类型与错误
```
shared 不能引用业务模块;remote 与 internal 只能引用各自区域及 shared,集成测试检查这些依赖边界。两套业务注册表分别注入共享 Commander 解析器。
测试按同样的区域分组。仓库根目录 `npm run test:cli` 或 cli/ 下 `npm test` 自动发现标准测试;test/linux 保持为显式执行的容器集成测试。scripts/entrypoints.cjs 保留现有 dist 可执行入口,以及 dist/local/entrypoints.js、cronEntrypoint.js;移动源码不要求修改 QL_CLI_ROOT 或 cron 命令。
+550
View File
@@ -0,0 +1,550 @@
{
"name": "@whyour/qinglong-cli",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@whyour/qinglong-cli",
"version": "0.1.2",
"bin": {
"ql": "dist/npm/ql.js"
},
"devDependencies": {
"@types/node": "22.19.0",
"commander": "15.0.0",
"esbuild": "0.28.2",
"typescript": "5.2.2"
},
"engines": {
"node": ">=22.12.0"
},
"license": "Apache-2.0"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.19.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz",
"integrity": "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/commander": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/typescript": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
"integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@whyour/qinglong-cli",
"version": "0.1.2",
"description": "Authenticated remote management CLI for QingLong 2.x",
"type": "commonjs",
"bin": {
"ql": "dist/npm/ql.js"
},
"engines": {
"node": ">=22.12.0"
},
"files": [
"dist/npm",
"skills/qinglong-cli",
"README.md",
"README.en.md",
"LICENSE"
],
"scripts": {
"build": "node scripts/build.cjs",
"check": "tsc -p tsconfig.json --noEmit",
"test": "npm run build && node scripts/test.cjs",
"prepack": "npm run build"
},
"devDependencies": {
"commander": "15.0.0",
"esbuild": "0.28.2",
"typescript": "5.2.2",
"@types/node": "22.19.0"
},
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/whyour/qinglong.git",
"directory": "cli"
},
"publishConfig": {
"access": "public"
}
}
+83
View File
@@ -0,0 +1,83 @@
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
// Deleted commands must not survive incremental builds or npm prepack.
fs.rmSync(path.join(root, 'dist'), { recursive: true, force: true });
execFileSync(
process.execPath,
[
require.resolve('typescript/bin/tsc'),
'-p',
path.join(root, 'tsconfig.json'),
],
{ stdio: 'inherit' },
);
fs.copyFileSync(
path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'),
path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'),
);
require('esbuild').buildSync({
entryPoints: [path.join(root, 'src/shared/cli/commander.ts')],
outfile: path.join(root, 'dist/shared/cli/commander.js'),
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node22',
minify: true,
sourcemap: true,
legalComments: 'inline',
});
const license = path.join(
path.dirname(require.resolve('commander')),
'LICENSE',
);
fs.mkdirSync(path.join(root, 'dist/licenses'), { recursive: true });
fs.copyFileSync(license, path.join(root, 'dist/licenses/commander-LICENSE'));
// The public npm artifact must not contain or import local operational code.
const remote = require('esbuild').buildSync({
entryPoints: [path.join(root, 'src/entrypoints/remote.ts')],
outfile: path.join(root, 'dist/npm/ql.js'),
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node22',
minify: true,
sourcemap: true,
legalComments: 'inline',
metafile: true,
});
for (const input of Object.keys(remote.metafile.inputs)) {
if (
/(?:^|[\\/])src[\\/](?:internal|compatibility|developer)[\\/]/.test(input)
)
throw new Error(
'Local implementation leaked into remote npm bundle: ' + input,
);
}
fs.copyFileSync(license, path.join(root, 'dist/npm/commander-LICENSE'));
// Keep existing panel/cron executable paths stable after source reorganization.
for (const [name, entry] of Object.entries(require('./entrypoints.cjs'))) {
const target = path.join(root, 'dist', name);
let relative = path
.relative(path.dirname(target), path.join(root, 'dist', entry.module))
.split(path.sep)
.join('/');
if (!relative.startsWith('.')) relative = './' + relative;
const run = entry.method
? `
if (require.main === module) void entry.${entry.method}().then(code => { process.exitCode = code; });`
: '';
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(
target,
'#!/usr/bin/env node\nconst entry = require(' +
JSON.stringify(relative) +
');\nmodule.exports = entry;' +
run +
'\n',
{ mode: 0o755 },
);
}
+55
View File
@@ -0,0 +1,55 @@
// Stable panel integration and executable paths. Source folders may move independently.
module.exports = {
'ql.js': {
module: 'entrypoints/ql.js',
method: 'qlMain',
},
'task.js': {
module: 'entrypoints/task.js',
method: 'taskMain',
},
'runner.js': {
module: 'internal/execution/runner.js',
method: 'runnerMain',
},
'remote.js': {
module: 'entrypoints/remote.js',
method: 'remoteMain',
},
'compat.js': {
module: 'compatibility/legacy.js',
method: 'compatibilityMain',
},
'startup.js': {
module: 'compatibility/startup.js',
method: 'startupMain',
},
'subscription-worker.js': {
module: 'internal/subscription/worker.js',
method: 'subscriptionWorker',
},
'main.js': {
module: 'compatibility/main.js',
method: null,
},
'index.js': {
module: 'entrypoints/index.js',
method: null,
},
'admin.js': {
module: 'entrypoints/admin.js',
method: null,
},
'container.js': {
module: 'entrypoints/container.js',
method: null,
},
'local/entrypoints.js': {
module: 'internal/integration/entrypoints.js',
method: null,
},
'local/cronEntrypoint.js': {
module: 'internal/integration/cronEntrypoint.js',
method: null,
},
};
+22
View File
@@ -0,0 +1,22 @@
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const root = path.resolve(__dirname, '../test');
const files = [
'remote',
'internal',
'shared',
'compatibility',
'integration',
].flatMap((group) =>
fs
.readdirSync(path.join(root, group))
.filter((name) => name.endsWith('.test.cjs'))
.sort()
.map((name) => path.join(root, group, name)),
);
const result = spawnSync(process.execPath, ['--test', ...files], {
stdio: 'inherit',
});
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
+124
View File
@@ -0,0 +1,124 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync, spawnSync } = require('node:child_process');
const assert = require('node:assert/strict');
const root = path.resolve(__dirname, '..');
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-package-'));
const run = (program, args, options = {}) =>
execFileSync(program, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 60000,
...options,
});
try {
const cache = path.join(temp, 'cache');
const manifest = JSON.parse(
run(
'npm',
[
'pack',
'--json',
'--ignore-scripts',
'--cache',
cache,
'--pack-destination',
temp,
],
{ cwd: root },
),
)[0];
for (const name of ['README.md', 'README.en.md', 'LICENSE', 'dist/npm/commander-LICENSE'])
assert.ok(manifest.files.some((file) => file.path === name), name);
assert.ok(manifest.files.every((file) => /^(dist\/npm\/|skills\/qinglong-cli\/|README(?:\.en)?\.md$|LICENSE$|package\.json$)/.test(file.path)));
assert.ok(
manifest.files.some((file) => file.path === 'skills/qinglong-cli/SKILL.md'),
);
assert.ok(
!manifest.files.some(
(file) => file.path.startsWith('src/') || file.path.startsWith('test/'),
),
);
const prefix = path.join(temp, 'install');
run(
'npm',
[
'install',
'--offline',
'--ignore-scripts',
'--no-audit',
'--no-fund',
'--cache',
cache,
'--prefix',
prefix,
path.join(temp, manifest.filename),
],
{ cwd: temp },
);
const installed = path.join(prefix, 'node_modules/@whyour/qinglong-cli');
const metadata = JSON.parse(
fs.readFileSync(path.join(installed, 'package.json'), 'utf8'),
);
assert.equal(Object.keys(metadata.dependencies || {}).length, 0);
assert.equal(metadata.bin['ql-dev-cli'], undefined);
assert.ok(!manifest.files.some(file => /^dist\/developer(?:\/|\.)/.test(file.path)));
assert.deepEqual(metadata.bin, { ql: 'dist/npm/ql.js' });
for (const reference of ['panel.md', 'openapi.md'])
assert.ok(fs.existsSync(path.join(installed, 'skills/qinglong-cli/references', reference)));
assert.equal(
fs.readFileSync(
path.join(installed, 'skills/qinglong-cli/SKILL.md'),
'utf8',
),
fs.readFileSync(path.join(root, 'skills/qinglong-cli/SKILL.md'), 'utf8'),
);
const entries = [];
for (const [name, target] of Object.entries(metadata.bin)) {
const entry = path.join(prefix, 'node_modules/.bin', name);
assert.equal(
fs.realpathSync(entry),
fs.realpathSync(path.join(installed, target)),
);
const output = run(entry, ['--help'], {
cwd: temp,
env: { ...process.env, QL_DIR: '', QL_DATA_DIR: '' },
});
assert.match(output, /Usage:|用法|usage/i);
entries.push(name);
}
assert.ok(!manifest.files.some(file => file.path.includes('qinglong-local')));
for (const args of [['reload'], ['resetpwd', 'fixture'], ['local', 'check'], ['task', 'exec', 'fixture.js'], ['task', 'fixture.js'], ['repo', 'fixture'], ['dev', 'release']]) {
const result = spawnSync(process.execPath, [path.join(installed, metadata.bin.ql), ...args, '--json'], {
cwd: temp, encoding: 'utf8', timeout: 10000,
env: { PATH: process.env.PATH, QL_CLI_CONFIG: path.join(temp, 'missing.json'), QL_DIR: '/nonexistent-panel' },
});
assert.equal(result.status, 2, JSON.stringify(args) + result.stderr);
assert.equal(result.stdout, '');
assert.equal(JSON.parse(result.stderr).code, 2);
}
// CI uploads the exact archive whose isolated installation passed above.
if (process.env.QL_CLI_PACKAGE_OUTPUT) {
const output = path.resolve(process.env.QL_CLI_PACKAGE_OUTPUT);
fs.mkdirSync(output, { recursive: true });
fs.copyFileSync(path.join(temp, manifest.filename), path.join(output, manifest.filename));
}
process.stdout.write(
JSON.stringify(
{
entries,
packedBytes: manifest.size,
unpackedBytes: manifest.unpackedSize,
runtimeDependencies: 0,
standaloneInstall: true,
},
null,
2,
) + '\n',
);
} finally {
fs.rmSync(temp, { recursive: true, force: true });
}

Some files were not shown because too many files have changed in this diff Show More