Compare commits

..
Author SHA1 Message Date
whyour 50799b077f Set 2.24.0 publication timestamp 2026-10-29 02:15:13 +08:00
whyour 0640a3d64d Align release workflow tests with supported images 2026-10-29 01:41:12 +08:00
whyour 6b63d2d443 Prepare Qinglong 2.24.0 release 2026-10-29 01:31:58 +08:00
25 changed files with 88 additions and 909 deletions
+1 -1
View File
@@ -507,7 +507,7 @@ jobs:
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
npm publish "./$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
npm publish "$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
else
echo '::error::Could not check the published npm version; refusing to publish.'
exit 1
+2 -20
View File
@@ -25,7 +25,7 @@ on:
type: boolean
default: false
prerelease_version:
description: Develop only, e.g. 0.1.5-beta.0 (alpha/beta/rc)
description: Develop only, e.g. 0.1.2-beta.0 (alpha/beta/rc)
type: string
default: ''
@@ -93,26 +93,8 @@ jobs:
if-no-files-found: error
retention-days: 14
platform-smoke:
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-24.04-arm]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
cache-dependency-path: cli/package-lock.json
- run: npm ci --prefix cli --no-audit --no-fund
- run: npm run build:cli
- run: node --test cli/test/remote/platformSmoke.test.cjs
publish:
needs: [package, platform-smoke]
needs: package
if: >-
github.repository == 'whyour/qinglong' &&
((github.ref == 'refs/heads/master' &&
+6 -68
View File
@@ -11,11 +11,12 @@ export default (app: Router) => {
route.get(
'/',
celebrate({
query: Joi.object({
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}).unknown(true),
query:
Joi.object({
searchValue: Joi.string().optional().allow(''),
type: Joi.string().optional().allow(''),
status: Joi.string().optional().allow(''),
}).unknown(true),
}),
async (req: Request, res: Response, next: NextFunction) => {
const logger: Logger = Container.get('logger');
@@ -30,69 +31,6 @@ export default (app: Router) => {
},
);
route.post(
'/refresh',
celebrate({
body: Joi.object({
type: Joi.string().valid('nodejs', 'python3').required(),
}),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const { job, accepted } = dependenceService.startRefresh(req.body.type);
if (!accepted) {
return res.status(409).send({
code: 409,
message: 'Another dependency refresh is already running',
});
}
return res.send({ code: 200, data: job });
} catch (e) {
return next(e);
}
},
);
route.get(
'/refresh/active',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
res.set('Cache-Control', 'no-store');
return res.send({
code: 200,
data: dependenceService.getActiveRefreshJob() || null,
});
} catch (e) {
return next(e);
}
},
);
route.get(
'/refresh/:id',
celebrate({
params: Joi.object({ id: Joi.string().uuid().required() }),
}),
async (req: Request, res: Response, next: NextFunction) => {
try {
const dependenceService = Container.get(DependenceService);
const job = dependenceService.getRefreshJob(req.params.id);
if (!job) {
return res.status(404).send({
code: 404,
message: 'Dependency refresh job not found',
});
}
res.set('Cache-Control', 'no-store');
return res.send({ code: 200, data: job });
} catch (e) {
return next(e);
}
},
);
route.post(
'/',
celebrate({
+19 -76
View File
@@ -27,20 +27,9 @@ import taskLimit from '../shared/pLimit';
import { detectOS } from '../config/util';
import { LINUX_DEPENDENCE_COMMAND } from '../config/const';
import { t, tf } from '../shared/i18n';
import { randomUUID } from 'crypto';
type RefreshType = 'nodejs' | 'python3';
type RefreshJob = {
id: string;
type: RefreshType;
state: 'running' | 'completed' | 'failed';
};
@Service()
export default class DependenceService {
private activeRefreshJob?: RefreshJob;
private refreshJobs = new Map<string, RefreshJob>();
constructor(
@Inject('logger') private logger: winston.Logger,
private sockService: SockService,
@@ -129,6 +118,11 @@ export default class DependenceService {
query: any = {},
): Promise<Dependence[]> {
let condition = query;
const dependenceType =
type && DependenceTypes[type] !== undefined
? DependenceTypes[type]
: undefined;
await this.refreshInstalledStatuses(dependenceType);
if (type && DependenceTypes[type] !== undefined) {
condition.type = DependenceTypes[type];
}
@@ -151,52 +145,6 @@ export default class DependenceService {
}
}
public startRefresh(type: RefreshType): {
job: RefreshJob;
accepted: boolean;
} {
if (this.activeRefreshJob) {
return {
job: this.activeRefreshJob,
accepted: this.activeRefreshJob.type === type,
};
}
const job: RefreshJob = { id: randomUUID(), type, state: 'running' };
this.activeRefreshJob = job;
this.refreshJobs.set(job.id, job);
while (this.refreshJobs.size > 20) {
const oldestId = this.refreshJobs.keys().next().value;
this.refreshJobs.delete(oldestId);
}
void this.refreshInstalledStatuses(DependenceTypes[type])
.then(() => {
job.state = 'completed';
})
.catch((error) => {
this.logger.error('Dependency refresh failed: %o', error);
job.state = 'failed';
})
.finally(() => {
if (this.activeRefreshJob === job) {
this.activeRefreshJob = undefined;
}
});
return { job, accepted: true };
}
public getRefreshJob(id: string): RefreshJob | undefined {
return this.refreshJobs.get(id);
}
public getActiveRefreshJob(): RefreshJob | undefined {
return this.activeRefreshJob?.state === 'running'
? this.activeRefreshJob
: undefined;
}
private async refreshInstalledStatuses(type?: DependenceTypes) {
const cacheDependenceTypes = [
DependenceTypes.nodejs,
@@ -212,31 +160,26 @@ export default class DependenceService {
type: type === undefined ? { [Op.in]: cacheDependenceTypes } : type,
},
});
const checkableDocs = docs.filter(
(doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
);
const checks = await concurrentRun(
checkableDocs.map((doc) => async () => this.isDependenceInstalled(doc)),
docs
.filter(
(doc) =>
doc.type !== DependenceTypes.python3 ||
!isPythonSourceDependency(doc.name),
)
.map((doc) => async () => {
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
}),
5,
);
const missingIds = (checks || []).filter(
(id): id is number => id !== undefined,
);
for (const [index, installed] of (checks || []).entries()) {
if (installed) continue;
const doc = checkableDocs[index];
if (missingIds.length) {
await DependenceModel.update(
{ status: DependenceStatus.installFailed },
{
where: {
id: doc.id,
status: DependenceStatus.installed,
name: doc.name,
type: doc.type,
updatedAt: (doc as typeof doc & { updatedAt: Date }).updatedAt,
// Sequelize's automatic timestamp is absent from Dependence's attribute type.
} as any,
},
{ where: { id: missingIds } },
);
}
}
+4 -4
View File
@@ -76,7 +76,7 @@ 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` as plaintext. The CLI requests mode `0600` when creating the file and rejects symlinks and non-regular files when reading it. Windows, WSL mounts, and some network or device filesystems do not reliably report POSIX permissions, so reads do not depend on mode bits or UID. Keep the config in a location accessible only to your user and protect it with the filesystem's actual permissions or ACLs; use the direct-token environment variables below if that cannot be guaranteed. `QL_CLI_CONFIG` selects another file. Login validates application credentials, not every resource permission.
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
@@ -128,11 +128,11 @@ npm run test:cli
node cli/scripts/verify-package.cjs
```
Tests cover requests, token refresh, output, errors, no automatic retries, config file type and isolated installation. Package verification installs the archive offline, verifies the sole ql executable and rejects local commands.
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, and verifies saved login and command reads on real Windows and Linux ARM64 filesystems. Node 24 uploads the archive verified by offline installation as `qinglong-cli-<commit>`. After all validation jobs succeed, master pushes publish that exact archive to npm as `latest`; a manual run on master with `publish` enabled can also publish a stable release. Develop pushes only verify. A manual run on develop with `publish` enabled and `prerelease_version` set (for example `0.1.5-beta.0`) publishes under the matching `alpha`, `beta` or `rc` tag. PRs and forks never publish. CLI-only changes do not trigger the main image build, except for the few internal CLI upgrade files used by the image. 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`. The publish job uses Node 24 and GitHub Actions OIDC without an `NPM_TOKEN` secret, with `id-token: write` granted only to that job. New packages need an initial publication before configuring their trusted publisher.
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. Manual runs publish only when run on master with publish enabled; PRs, develop and forks never publish. 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 stable version is maintained independently 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. A develop prerelease version is set only in the workflow run and is not written back to the repository. An existing stable version is skipped with a notice; a duplicate prerelease version fails, as does a registry lookup error. The publish job does not rebuild the verified archive or run package lifecycle scripts.
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 versions are skipped with a notice; registry failures stop publication. Only stable X.Y.Z versions are published by this workflow. Publication does not rebuild the verified archive or run package lifecycle scripts.
## Panel-internal tools
+4 -4
View File
@@ -76,7 +76,7 @@ ql auth status --scope crons --json
# 自动化环境事先注入 QL_CLIENT_ID 和 QL_CLIENT_SECRET,再运行同一 login 命令
```
凭据和 token 保存到 `~/.config/qinglong/cli.json` 明文文件。CLI 创建文件时会请求 `0600` 权限,并在读取时拒绝符号链接和非普通文件;由于 Windows、WSL 挂载盘及部分网络或设备文件系统不能可靠提供 POSIX 权限位,读取时不以权限位或 UID 为准。请将配置保存在仅当前用户可访问的位置,并按实际文件系统的权限或 ACL 保护它;无法保证时可使用下述环境令牌方式。`QL_CLI_CONFIG` 可选择其他配置文件。登录只验证应用凭据,不意味着具备所有资源权限。
凭据和 token 保存到 `~/.config/qinglong/cli.json`,为当前用户所有的 0600 明文文件。`QL_CLI_CONFIG` 可选择其他配置文件。登录只验证应用凭据,不意味着具备所有资源权限。
### 直接使用访问令牌
@@ -128,11 +128,11 @@ npm run test:cli
node cli/scripts/verify-package.cjs
```
测试覆盖请求格式、认证刷新、输出、错误、禁止自动重试、配置文件类型和独立安装。打包验证会离线安装 tgz,并确认唯一入口是 `ql`,本机命令不可调用。
测试覆盖请求格式、认证刷新、输出、错误、禁止自动重试、权限和独立安装。打包验证会离线安装 tgz,并确认唯一入口是 `ql`,本机命令不可调用。
CLI package 工作流在相关 PR、develop/master 推送和手动触发时执行 Node 22.12/24 类型检查、构建及测试,并在 Windows 与 Linux ARM64 上用真实文件系统验证登录和命令读取。Node 24 上传通过离线安装验证的 `qinglong-cli-<commit>` artifact。全部验证成功后,master 推送会将这份 tgz 发布到 npm 的 `latest` 标签;手动在 master 勾选 `publish` 也可发布稳定版。develop 推送只验证;手动在 develop 勾选 `publish` 并填写 `prerelease_version`(例如 `0.1.5-beta.0`)可发布到对应的 `alpha`、`beta` 或 `rc` 标签。PR 和 fork 不发布。CLI 专属改动不会触发主镜像构建,少数被镜像使用的 CLI 内部升级文件除外。npm 的 Trusted Publisher 需绑定仓库 `whyour/qinglong` 和工作流 `cli-package.yml`,允许 `npm publish`;面板包 `@whyour/qinglong` 单独绑定 `build-docker-image.yml`。发布 job 使用 Node 24 和 GitHub Actions OIDC,无需 `NPM_TOKEN` Secret,仅该 job 授予 `id-token: write`。新包需先完成首次发布,再配置对应包的可信发布关系。
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;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),提交这两个文件。develop 预发布版版本号仅在工作流运行时设置,不写回仓库。已发布的稳定版版本号会提示并跳过;重复的预发布版版本号会报错,registry 查询失败也会停止发布。发布 job 不重新构建产物或执行包生命周期脚本。
CLI 版本独立维护在 cli/package.json 和 cli/package-lock.json。发布改动前执行 `npm version patch --prefix cli --no-git-tag-version`(也可用 minor/major),提交这两个文件。已发布的版本会提示并跳过;registry 查询失败则停止发布。该流程仅发布 X.Y.Z 稳定版本,不重新构建产物或执行包生命周期脚本。
## 面板内部工具
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@whyour/qinglong-cli",
"version": "0.1.4",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@whyour/qinglong-cli",
"version": "0.1.4",
"version": "0.1.3",
"license": "Apache-2.0",
"bin": {
"ql": "dist/npm/ql.js"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@whyour/qinglong-cli",
"version": "0.1.4",
"version": "0.1.3",
"description": "Authenticated remote management CLI for QingLong 2.x",
"type": "commonjs",
"bin": {
+2 -12
View File
@@ -1,8 +1,8 @@
# OpenAPI 全量参考 / Complete OpenAPI reference
CLI 对应本版本后端的 151 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
CLI 对应本版本后端的 146 条有效路由。通过 `ql api routes --json` 查看方法、路径、对应命令、位置参数、请求体与上传字段;CLI 测试与 back/api 路由逐项核对。GET configs/:file、scripts/:file、logs/:file 已返回 410,改用 detail 命令,不再暴露旧入口。旧版面板可能没有新路由,以服务端响应为准。
The catalogue covers 151 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
The catalogue covers 146 active routes in the current backend. `ql api routes --json` lists methods, paths, commands, positional parameters, bodies and upload fields. A route-coverage test detects drift. Three retired GET :file routes return 410; use detail-based commands. Older panel versions may not implement newer routes.
## 认证与权限 / Authentication and permissions
@@ -53,19 +53,12 @@ ql config save --data '{"name":"example.sh","content":"# example"}' --json
ql config get --query '{"path":"example.sh"}' --json
ql log download --data '{"filename":"example.log","path":"example"}' --output ./example.log --json
ql dependency create --data '[{"name":"example-package","type":0}]' --json
ql dependency refresh --data '{"type":"python3"}' --json
ql dependency refresh-active --json
ql dependency refresh-status <JOB_UUID> --json
ql system data-export --data '{}' --output ./panel.tgz --json
ql system data-import --file ./panel.tgz --json
ql api request PUT /open/crons/run --data '[12,13]' --json
ql api request GET /open/crons --query '{"page":1,"size":100,"searchValue":"demo"}' --json
```
依赖 refresh 的 type 为 nodejs 或 python3,立即返回后台任务 id 与 state;用 refresh-status 查询该 UUID,直到 completed 或 failed。refresh-active 返回运行中的任务或 null,普通 list 不触发检查。
Dependency refresh accepts type nodejs or python3 and immediately returns a background job id and state. Poll refresh-status with that UUID until completed or failed. refresh-active returns the running job or null; listing dependencies does not trigger checks.
任务 create/update 需要 command/schedule;name、labels、sub_id、extra_schedules、task_before/after、log_name、allow_multiple_instances、work_dir 等字段可通过 --data 提交。订阅 create 需要 type/url/alias/schedule_type,update 需要 id/type/url/alias;interval_schedule、pull_option、dependences、extensions、sub_before/after、proxy、autoAddCron/autoDelCron 等用 --data。private-repo 凭据仅通过受保护文件或 stdin 提供。环境变量与依赖 create 接受对象数组,应用 scopes 为字符串数组。其余准确字段/枚举由当前后端 Joi schema 校验:见 [back/api](https://github.com/whyour/qinglong/tree/develop/back/api) 和 [定时规则 schema](https://github.com/whyour/qinglong/blob/develop/back/validation/schedule.ts)。
Task creation/update needs command/schedule; additional fields use --data. Subscription creation needs type/url/alias/schedule_type; updates need id/type/url/alias. Interval schedules, private repository credentials, filters, hooks and booleans use --data. Env/dependency creation accepts arrays; application scopes is a string array. The server validates field types/enums; the linked backend schemas are authoritative.
@@ -158,9 +151,6 @@ System/user operations below execute remotely through the panel API. They do not
| `ql log delete ` | DELETE | `logs` | --data, --query |
| `ql log download ` | POST | `logs/download` | --data, --output, --query |
| `ql dependency list ` | GET | `dependencies` | --query |
| `ql dependency refresh ` | POST | `dependencies/refresh` | --data, --query |
| `ql dependency refresh-active ` | GET | `dependencies/refresh/active` | --query |
| `ql dependency refresh-status <id>` | GET | `dependencies/refresh/:id` | --query |
| `ql dependency create ` | POST | `dependencies` | --data, --query |
| `ql dependency update <id>` | PUT | `dependencies` | --data, --query |
| `ql dependency delete <id...>` | DELETE | `dependencies` | --query |
-19
View File
@@ -5,7 +5,6 @@ export interface OpenOperation {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
params?: string[];
uuidParams?: string[];
body?: 'ids' | 'json' | 'object-id';
upload?: string;
download?: boolean;
@@ -335,24 +334,6 @@ export const openOperations: readonly OpenOperation[] = [
download: true,
},
{ name: 'dependency list', method: 'GET', path: 'dependencies' },
{
name: 'dependency refresh',
method: 'POST',
path: 'dependencies/refresh',
body: 'json',
},
{
name: 'dependency refresh-active',
method: 'GET',
path: 'dependencies/refresh/active',
},
{
name: 'dependency refresh-status',
method: 'GET',
path: 'dependencies/refresh/:id',
params: ['id'],
uuidParams: ['id'],
},
{
name: 'dependency create',
method: 'POST',
+7 -15
View File
@@ -17,28 +17,20 @@ export function configPath(): string {
export function readConfig(): StoredConfig {
let fd: number | undefined;
try {
const file = configPath();
const pathStat = fs.lstatSync(file);
if (!pathStat.isFile())
fail(
translate(
process.env,
'CLI 配置必须是普通文件,且不能是符号链接。',
),
);
// Not every platform or filesystem implements POSIX mode bits or O_NOFOLLOW.
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
fd = fs.openSync(
configPath(),
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
);
const stat = fs.fstatSync(fd);
if (
!stat.isFile() ||
pathStat.dev !== stat.dev ||
pathStat.ino !== stat.ino
stat.mode & 0o077 ||
(process.getuid && stat.uid !== process.getuid())
) {
fail(
translate(
process.env,
'CLI 配置必须是普通文件,且不能是符号链接。',
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。',
),
);
}
+3 -15
View File
@@ -49,19 +49,12 @@ async function input(value: string | boolean | undefined): Promise<unknown> {
}
}
const UUID_PATTERN =
'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
function route(method: string, path: string): OpenOperation {
const match = openOperations.find(
(op) =>
op.method === method &&
new RegExp(
'^' +
op.path.replace(/:([A-Za-z]+)/g, (_match, name) =>
op.uuidParams?.includes(name) ? UUID_PATTERN : '[1-9][0-9]*',
) +
'$',
'^' + op.path.replace(/:[A-Za-z]+/g, '[1-9][0-9]*') + '$',
).test(path),
);
if (!match)
@@ -70,8 +63,7 @@ function route(method: string, path: string): OpenOperation {
'Method/path is not a supported OpenAPI route.',
);
for (const [index, part] of match.path.split('/').entries())
if (part.startsWith(':') && !match.uuidParams?.includes(part.slice(1)))
integer(path.split('/')[index]!);
if (part.startsWith(':')) integer(path.split('/')[index]!);
return match;
}
@@ -115,11 +107,7 @@ export async function openCommand(
if (!generic) {
endpoint = op.path;
for (const [index, name] of (op.params ?? []).entries()) {
const positional = command.positionals[index]!;
const isUuid = op.uuidParams?.includes(name);
if (isUuid && !new RegExp(`^${UUID_PATTERN}$`).test(positional))
usage('任务 ID 必须为 UUID。', 'Job ID must be a UUID.');
const value = isUuid ? positional : integer(positional);
const value = integer(command.positionals[index]!);
endpoint = endpoint.replace(`:${name}`, String(value));
if (op.body === 'object-id') {
if (body !== undefined && !isRecord(body))
+2 -2
View File
@@ -175,8 +175,8 @@ export const english: Record<string, string> = {
'Request failed or returned invalid JSON. Check the instance URL, network and TLS certificate.',
'API 请求被拒绝(code %s),请检查%s。':
'API request rejected (code %s). Check %s.',
'CLI 配置必须是普通文件,且不能是符号链接。':
'CLI config must be a regular file, not a symbolic link.',
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。':
'CLI config must be an owned private regular file (0600).',
'CLI 配置无效,请运行 ql login。': 'Invalid CLI config. Run ql login.',
'尚未登录,请运行 ql login。': 'Not logged in. Run ql login.',
'无法读取 CLI 配置,请检查文件权限并运行 ql login。':
+3 -66
View File
@@ -296,13 +296,11 @@ test('malformed successful responses are rejected, including expired authenticat
failure(await f.invoke(['login', '--url', f.url, '--json']), 1);
});
test('config content and symlinks are checked independently of permission bits', async (t) => {
test('config permissions, malformed content and symlinks fail closed', async (t) => {
const f = await fixture(t);
await f.login();
fs.chmodSync(f.file, 0o644);
const sharedMode = await f.invoke(['task', 'list', '--json']);
assert.equal(sharedMode.code, 0, sharedMode.err);
assert.equal(JSON.parse(sharedMode.out).data.total, 1);
failure(await f.invoke(['task', 'list', '--json']), 1);
fs.chmodSync(f.file, 0o600);
fs.writeFileSync(f.file, '{broken');
failure(await f.invoke(['task', 'list', '--json']), 1);
@@ -314,73 +312,12 @@ test('config content and symlinks are checked independently of permission bits',
const target = path.join(f.dir, 'target');
fs.renameSync(f.file, target);
fs.symlinkSync(target, f.file);
const linked = await f.invoke(['task', 'list', '--json'], { QL_LANG: 'en' });
failure(linked, 1);
assert.match(JSON.parse(linked.err).message, /regular file, not a symbolic link/);
failure(await f.invoke(['task', 'list', '--json']), 1);
await f.login();
assert.equal(fs.lstatSync(f.file).isSymbolicLink(), false);
assert.equal(JSON.parse(fs.readFileSync(target)).clientId, 123);
});
test('Windows accepts a saved config despite synthetic 0666 mode bits', async (t) => {
const f = await fixture(t);
await f.login();
const preload = path.join(f.dir, 'windows-mode.cjs');
fs.writeFileSync(
preload,
`Object.defineProperty(process, 'platform', { value: 'win32' });
const fs = require('node:fs');
const originalFstatSync = fs.fstatSync;
const originalOpenSync = fs.openSync;
fs.openSync = function (file, flags, ...args) {
return originalOpenSync(file, typeof flags === 'number'
? flags & ~fs.constants.O_NOFOLLOW : flags, ...args);
};
fs.fstatSync = function (...args) {
const stat = originalFstatSync(...args);
stat.mode = (stat.mode & ~0o777) | 0o666;
return stat;
};\n`,
);
const env = { NODE_OPTIONS: `--require=${preload}` };
const status = await f.invoke(['auth', 'status', '--json'], env);
assert.equal(status.code, 0, status.err);
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
const list = await f.invoke(['task', 'list', '--json'], env);
assert.equal(list.code, 0, list.err);
assert.equal(JSON.parse(list.out).data.total, 1);
const target = path.join(f.dir, 'target');
fs.renameSync(f.file, target);
fs.symlinkSync(target, f.file);
failure(await f.invoke(['task', 'list', '--json'], env), 1);
});
test('Linux ARM accepts a saved config with synthetic mode and owner', async (t) => {
const f = await fixture(t);
await f.login();
const preload = path.join(f.dir, 'arm-mode.cjs');
fs.writeFileSync(
preload,
`Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'arm' });
const fs = require('node:fs');
const originalFstatSync = fs.fstatSync;
fs.fstatSync = function (...args) {
const stat = originalFstatSync(...args);
stat.mode = (stat.mode & ~0o777) | 0o666;
stat.uid = 999999;
return stat;
};\n`,
);
const env = { NODE_OPTIONS: `--require=${preload}` };
const status = await f.invoke(['auth', 'status', '--json'], env);
assert.equal(status.code, 0, status.err);
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
const list = await f.invoke(['task', 'list', '--json'], env);
assert.equal(list.code, 0, list.err);
assert.equal(JSON.parse(list.out).data.total, 1);
});
test('standalone compiled artifact and symlink run without repo runtime dependencies', async (t) => {
const f = await fixture(t);
const dist = path.join(f.dir, 'standalone');
+2 -24
View File
@@ -83,27 +83,6 @@ test('script history commands send exact query parameters and restore payloads',
assert.equal(f.requests.length, count, 'missing restore payload must not issue a request');
});
test('dependency refresh commands send the dependency type and address background jobs', async t => {
const f = await fixture(t);
success(await f.run(['dependency', 'refresh', '--data', '{"type":"python3"}']));
assert.equal(f.requests[0].method, 'POST');
assert.equal(f.requests[0].url, '/panel/open/dependencies/refresh');
assert.deepEqual(JSON.parse(f.requests[0].body), { type: 'python3' });
const id = '32ec8b22-2c48-4bcf-85d4-b394198417ba';
success(await f.run(['dependency', 'refresh-status', id]));
assert.equal(f.requests[1].method, 'GET');
assert.equal(f.requests[1].url, `/panel/open/dependencies/refresh/${id}`);
success(await f.run(['dependency', 'refresh-active']));
assert.equal(f.requests[2].method, 'GET');
assert.equal(f.requests[2].url, '/panel/open/dependencies/refresh/active');
success(await f.run(['api', 'request', 'GET', `/open/dependencies/refresh/${id}`]));
assert.equal(f.requests[3].url, `/panel/open/dependencies/refresh/${id}`);
assert.equal((await f.run(['dependency', 'refresh-status', '7'])).code, 2);
assert.equal((await f.run(['api', 'request', 'GET', '/open/dependencies/refresh/7'])).code, 2);
assert.equal((await f.run(['dependency', 'refresh'])).code, 2);
assert.equal(f.requests.length, 4, 'invalid arguments must not issue a request');
});
test('OpenAPI catalogue covers every active registered backend route; retired 410 routes are explicit', async () => {
const root = path.resolve(__dirname, '../../../back/api');
const expected = [];
@@ -153,8 +132,7 @@ test('all added named operations reach their registered method/path, including u
const file = path.join(f.root, 'fixture.json'); await fs.writeFile(file, '{}');
for (const op of openOperations.filter(op => !op.existing)) {
const args = op.name.split(' ');
const parameter = name => op.uuidParams?.includes(name) ? '32ec8b22-2c48-4bcf-85d4-b394198417ba' : '7';
for (const name of op.params ?? []) args.push(parameter(name));
for (const _ of op.params ?? []) args.push('7');
if (op.body === 'ids') args.push('7', '8');
else if (op.body) {
const data = op.name.startsWith('task ') ? { command: 'echo fixture', schedule: '0 0 * * *' }
@@ -166,7 +144,7 @@ test('all added named operations reach their registered method/path, including u
success(await f.run(args));
const last = f.requests.at(-1);
assert.equal(last.method, op.method, op.name);
assert.equal(last.url, '/panel/open/' + op.path.replace(/:([A-Za-z]+)/g, (_match, name) => parameter(name)), op.name);
assert.equal(last.url, '/panel/open/' + op.path.replace(/:[A-Za-z]+/g, '7'), op.name);
assert.equal(last.headers.authorization, op.anonymous ? undefined : 'Bearer fixture-token', op.name);
if (op.upload) assert.ok(last.body.includes(`name="${op.upload}"; filename="fixture.json"`), op.name);
}
-74
View File
@@ -1,74 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const http = require('node:http');
const { spawn } = require('node:child_process');
const entry = path.resolve(__dirname, '../../dist/npm/ql.js');
test('saved login works on the host filesystem', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-platform-smoke-'));
const config = path.join(dir, 'cli.json');
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
res.setHeader('content-type', 'application/json');
if (url.pathname === '/panel/open/auth/token') {
assert.equal(url.searchParams.get('client_id'), 'smoke-id');
assert.equal(url.searchParams.get('client_secret'), 'smoke-secret');
res.end(JSON.stringify({
code: 200,
data: {
token: 'smoke-token',
expiration: Math.floor(Date.now() / 1000) + 3600,
},
}));
return;
}
assert.equal(req.headers.authorization, 'Bearer smoke-token');
assert.equal(url.pathname, '/panel/open/crons');
res.end(JSON.stringify({ code: 200, data: { data: [], total: 0 } }));
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
t.after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
});
const env = {
...process.env,
QL_CLI_CONFIG: config,
QL_CLIENT_ID: 'smoke-id',
QL_CLIENT_SECRET: 'smoke-secret',
QL_URL: '',
QL_ACCESS_TOKEN: '',
};
const invoke = (args) => new Promise((resolve, reject) => {
const child = spawn(process.execPath, [entry, ...args], {
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '';
let err = '';
child.stdout.on('data', (data) => (out += data));
child.stderr.on('data', (data) => (err += data));
child.once('error', reject);
child.once('close', (code) => resolve({ code, out, err }));
});
const url = `http://127.0.0.1:${server.address().port}/panel`;
const login = await invoke(['login', '--url', url, '--json']);
assert.equal(login.code, 0, login.err);
assert.equal(JSON.parse(login.out).data.authenticated, true);
assert.equal(fs.lstatSync(config).isFile(), true);
const status = await invoke(['auth', 'status', '--json']);
assert.equal(status.code, 0, status.err);
assert.equal(JSON.parse(status.out).data.authenticated, true);
const list = await invoke(['task', 'list', '--json']);
assert.equal(list.code, 0, list.err);
assert.equal(JSON.parse(list.out).data.total, 0);
});
-4
View File
@@ -1,9 +1,5 @@
{
"选中条数": "Selected {count} items",
"重新检查依赖": "Recheck dependencies",
"依赖检查失败": "Dependency check failed",
"正在检查依赖": "Checking {type} dependencies",
"依赖检查状态已中断": "Dependency check status was lost. Please retry.",
"登录失败次数": "Failed {count} time(s)",
"间隔定时规则": "Every {value} {unit}",
"SOCK5代理,例如 IP:PORT": "SOCKS5 proxy, e.g. IP:PORT",
-4
View File
@@ -1,9 +1,5 @@
{
"选中条数": "已选择 {count} 项",
"重新检查依赖": "重新检查依赖",
"依赖检查失败": "依赖检查失败",
"正在检查依赖": "正在检查 {type} 依赖",
"依赖检查状态已中断": "依赖检查状态已中断,请重新检查",
"登录失败次数": "失败{count}次",
"间隔定时规则": "每{value}{unit}",
"SOCK5代理,例如 IP:PORT": "SOCK5代理,例如 IP:PORT",
+10 -175
View File
@@ -87,7 +87,6 @@ const StatusMap: Record<number, { icon: React.ReactNode; color: string }> = {
const Dependence = () => {
const { headerStyle, isPhone } = useOutletContext<SharedContext>();
const [statusFilter, setStatusFilter] = useState<number[]>([]);
const columns: any = [
{
title: intl.get('序号'),
@@ -107,7 +106,6 @@ const Dependence = () => {
key: 'status',
width: 120,
dataIndex: 'status',
filteredValue: statusFilter.length ? statusFilter : null,
filters: [
{
text: intl.get('队列中'),
@@ -235,9 +233,6 @@ const Dependence = () => {
];
const [value, setValue] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [refreshingType, setRefreshingType] = useState<string | null>(null);
const [refreshGeneration, setRefreshGeneration] = useState(0);
const [isModalVisible, setIsModalVisible] = useState(false);
const [editedDependence, setEditedDependence] = useState();
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
@@ -245,26 +240,9 @@ const Dependence = () => {
const [logDependence, setLogDependence] = useState<any>();
const [isLogModalVisible, setIsLogModalVisible] = useState(false);
const [type, setType] = useState('nodejs');
const listQuery = useRef({ type, searchText, statusFilter });
listQuery.current = { type, searchText, statusFilter };
const tableRef = useRef<HTMLDivElement>(null);
const latestListRequest = useRef(0);
const refreshTimer = useRef<ReturnType<typeof setTimeout>>();
const activeRefreshId = useRef<string | null>(null);
const refreshRequestVersion = useRef(0);
const alive = useRef(true);
const [height, setHeight] = useState<number>(0);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
latestListRequest.current += 1;
refreshRequestVersion.current += 1;
if (refreshTimer.current) clearTimeout(refreshTimer.current);
};
}, []);
useResizeObserver(tableRef, (entry) => {
const _height =
entry.target?.parentElement?.parentElement?.parentElement?.offsetHeight;
@@ -278,146 +256,23 @@ const Dependence = () => {
});
const getDependencies = (status?: number[]) => {
const query = listQuery.current;
const requestId = ++latestListRequest.current;
setLoading(true);
request
.get(
`${config.apiPrefix}dependencies?searchValue=${query.searchText}&type=${
query.type
}&status=${status ?? query.statusFilter}`,
`${
config.apiPrefix
}dependencies?searchValue=${searchText}&type=${type}&status=${
status || ''
}`,
)
.then(({ code, data }) => {
if (
alive.current &&
requestId === latestListRequest.current &&
code === 200
) {
if (code === 200) {
setValue(data);
}
})
.finally(() => {
if (alive.current && requestId === latestListRequest.current) {
setLoading(false);
}
});
.finally(() => setLoading(false));
};
const scheduleRefresh = (callback: () => void, delay: number) => {
if (refreshTimer.current) clearTimeout(refreshTimer.current);
refreshTimer.current = setTimeout(callback, delay);
};
const finishRefresh = (id: string, completed: boolean) => {
if (activeRefreshId.current !== id) return;
refreshRequestVersion.current += 1;
activeRefreshId.current = null;
if (refreshTimer.current) clearTimeout(refreshTimer.current);
setRefreshing(false);
setRefreshingType(null);
setRefreshGeneration((generation) => generation + 1);
if (!completed) {
message.error(intl.get('依赖检查失败'));
}
};
const attachRefresh = (job: { id: string; type: string }) => {
if (!alive.current) return;
refreshRequestVersion.current += 1;
if (refreshTimer.current) clearTimeout(refreshTimer.current);
activeRefreshId.current = job.id;
setRefreshing(true);
setRefreshingType(job.type);
scheduleRefresh(() => pollRefresh(job.id), 0);
};
const recoverRefresh = (
reloadWhenIdle = false,
retryOnError = false,
notifyWhenIdle = false,
) => {
const version = ++refreshRequestVersion.current;
request
.get(`${config.apiPrefix}dependencies/refresh/active`)
.then(({ code, data }) => {
if (!alive.current || version !== refreshRequestVersion.current) return;
if (code === 200 && data) {
attachRefresh(data);
} else if (reloadWhenIdle) {
activeRefreshId.current = null;
setRefreshing(false);
setRefreshingType(null);
setRefreshGeneration((generation) => generation + 1);
if (notifyWhenIdle) message.warning(intl.get('依赖检查状态已中断'));
}
})
.catch(() => {
if (
alive.current &&
version === refreshRequestVersion.current &&
retryOnError
) {
scheduleRefresh(() => {
if (alive.current && version === refreshRequestVersion.current) {
recoverRefresh(reloadWhenIdle, true, notifyWhenIdle);
}
}, 5000);
}
});
};
const pollRefresh = (id: string) => {
request
.get(`${config.apiPrefix}dependencies/refresh/${id}`)
.then(({ code, data }) => {
if (!alive.current || activeRefreshId.current !== id) return;
if (code !== 200) {
scheduleRefresh(() => pollRefresh(id), 5000);
return;
}
if (data.state === 'running') {
scheduleRefresh(() => pollRefresh(id), 2000);
} else {
finishRefresh(id, data.state === 'completed');
}
})
.catch((error) => {
if (!alive.current || activeRefreshId.current !== id) return;
if (error?.response?.status === 404) {
activeRefreshId.current = null;
recoverRefresh(true, true, true);
} else {
scheduleRefresh(() => pollRefresh(id), 5000);
}
});
};
const startRefresh = () => {
if (activeRefreshId.current) return;
const version = ++refreshRequestVersion.current;
setRefreshing(true);
setRefreshingType(type);
request
.post(`${config.apiPrefix}dependencies/refresh`, { type })
.then(({ code, data }) => {
if (!alive.current || version !== refreshRequestVersion.current) return;
if (code === 200) {
attachRefresh(data);
} else {
recoverRefresh(true, true);
}
})
.catch(() => {
if (alive.current && version === refreshRequestVersion.current) {
recoverRefresh(true, true);
}
});
};
useEffect(() => {
recoverRefresh(false, true);
}, []);
const addDependence = () => {
setEditedDependence(null as any);
setIsModalVisible(true);
@@ -595,16 +450,12 @@ const Dependence = () => {
};
const onSearch = (value: string) => {
const trimmed = value.trim();
if (trimmed !== searchText) {
latestListRequest.current += 1;
setSearchText(trimmed);
}
setSearchText(value.trim());
};
useEffect(() => {
getDependencies();
}, [searchText, type, refreshGeneration]);
}, [searchText, type]);
useEffect(() => {
if (logDependence) {
@@ -655,7 +506,6 @@ const Dependence = () => {
}, []);
const onTabChange = (activeKey: string) => {
latestListRequest.current += 1;
setSelectedRowIds([]);
setType(activeKey);
};
@@ -701,9 +551,7 @@ const Dependence = () => {
scroll={{ x: 768, y: height }}
loading={loading}
onChange={(pagination, filters) => {
const status = (filters?.status as number[]) || [];
setStatusFilter(status);
getDependencies(status);
getDependencies(filters?.status as number[]);
}}
/>
</DndProvider>
@@ -715,19 +563,6 @@ const Dependence = () => {
className="ql-container-wrapper dependence-wrapper ql-container-wrapper-has-tab"
title={intl.get('依赖管理')}
extra={[
<Button
key="refresh"
icon={<SyncOutlined />}
loading={refreshing}
disabled={type === 'linux' || loading}
onClick={startRefresh}
>
{refreshingType
? intl.get('正在检查依赖', {
type: refreshingType === 'nodejs' ? 'Node' : 'Python',
})
: intl.get('重新检查依赖')}
</Button>,
<Search
placeholder={intl.get('请输入名称')}
style={{ width: 'auto' }}
+15 -93
View File
@@ -1,7 +1,7 @@
const assert = require('node:assert/strict');
const test = require('node:test');
test('dependency listing uses stored status until explicitly refreshed', async (t) => {
test('dependency listing marks cache entries missing from disk for reinstall', async (t) => {
const moduleStubs = new Map();
const stubModule = (modulePath, exports) => {
const resolved = require.resolve(modulePath);
@@ -23,47 +23,24 @@ test('dependency listing uses stored status until explicitly refreshed', async (
};
const DependenceTypes = { nodejs: 0, python3: 1, linux: 2 };
const docs = [
{
id: 1,
name: 'missing-package',
type: 0,
status: 1,
updatedAt: new Date('2026-01-01T00:00:00Z'),
},
{
id: 2,
name: 'present-package',
type: 0,
status: 1,
updatedAt: new Date('2026-01-01T00:00:00Z'),
},
{ id: 1, name: 'missing-package', type: 0, status: 1 },
{ id: 2, name: 'present-package', type: 0, status: 1 },
];
const updates = [];
const probes = [];
const DependenceModel = {
findAll: async ({ where }) => {
if (where.status === DependenceStatus.installed) {
return docs
.filter((doc) => doc.status === DependenceStatus.installed)
.map((doc) => ({ ...doc }));
return docs;
}
return docs.map((doc) => ({ ...doc }));
return docs.map((doc) => ({
...doc,
status: updates.some((update) => update.ids.includes(doc.id))
? DependenceStatus.installFailed
: doc.status,
}));
},
update: async ({ status }, { where }) => {
const changed = docs.filter(
(doc) =>
doc.id === where.id &&
doc.status === where.status &&
doc.name === where.name &&
doc.type === where.type &&
doc.updatedAt.getTime() === where.updatedAt.getTime(),
);
for (const doc of changed) {
doc.status = status;
doc.updatedAt = new Date(doc.updatedAt.getTime() + 1000);
}
if (changed.length)
updates.push({ status, ids: changed.map((d) => d.id) });
updates.push({ status, ids: where.id });
},
};
@@ -88,12 +65,8 @@ test('dependency listing uses stored status until explicitly refreshed', async (
getPid: async () => 0,
getUninstallCommand: () => '',
killTask: async () => {},
promiseExecSuccess: async (command) => {
probes.push(command);
return command === 'check:present-package'
? 'present-package 1.0.0\n'
: '';
},
promiseExecSuccess: async (command) =>
command === 'check:present-package' ? 'present-package 1.0.0\n' : '',
});
stubModule('../../back/config/const', {
LINUX_DEPENDENCE_COMMAND: { Alpine: {} },
@@ -131,29 +104,15 @@ test('dependency listing uses stored status until explicitly refreshed', async (
const DependenceService = require('../../back/services/dependence').default;
const service = new DependenceService({}, {});
const query = {
const result = await service.dependencies({
searchValue: '',
type: 'nodejs',
status: '',
};
const cached = await service.dependencies(query);
assert.deepEqual(updates, []);
assert.deepEqual(probes, []);
assert.deepEqual(
cached.map(({ status }) => status),
[1, 1],
);
const { job } = service.startRefresh('nodejs');
assert.equal(job.state, 'running');
await new Promise(setImmediate);
assert.equal(service.getRefreshJob(job.id).state, 'completed');
const result = await service.dependencies(query);
});
assert.deepEqual(updates, [
{ status: DependenceStatus.installFailed, ids: [1] },
]);
assert.deepEqual(probes, ['check:missing-package', 'check:present-package']);
assert.deepEqual(
result.map(({ id, status }) => ({ id, status })),
[
@@ -164,41 +123,4 @@ test('dependency listing uses stored status until explicitly refreshed', async (
await service.dependencies({ searchValue: '', type: 'linux', status: '' });
assert.equal(updates.length, 1);
assert.equal(probes.length, 2);
let finishProbe;
service.isDependenceInstalled = async () => {
await new Promise((resolve) => {
finishProbe = resolve;
});
return false;
};
const racing = service.startRefresh('nodejs');
await new Promise(setImmediate);
docs[1].updatedAt = new Date(docs[1].updatedAt.getTime() + 1000);
finishProbe();
await new Promise(setImmediate);
assert.equal(racing.job.state, 'completed');
assert.equal(docs[1].status, DependenceStatus.installed);
assert.equal(updates.length, 1);
let finishRefresh;
service.refreshInstalledStatuses = () =>
new Promise((resolve) => {
finishRefresh = resolve;
});
const first = service.startRefresh('nodejs');
assert.equal(first.accepted, true);
assert.equal(service.getActiveRefreshJob().id, first.job.id);
assert.equal(service.startRefresh('nodejs').job.id, first.job.id);
assert.equal(service.startRefresh('python3').accepted, false);
finishRefresh();
await new Promise(setImmediate);
assert.equal(first.job.state, 'completed');
assert.equal(service.getActiveRefreshJob(), undefined);
const second = service.startRefresh('python3');
assert.equal(second.accepted, true);
finishRefresh();
await new Promise(setImmediate);
assert.equal(second.job.state, 'completed');
});
-62
View File
@@ -1,62 +0,0 @@
require('reflect-metadata');
const assert = require('node:assert/strict');
const test = require('node:test');
const { randomUUID } = require('node:crypto');
const express = require('express');
const { errors } = require('celebrate');
const { Container } = require('typedi');
const load = require('../helpers/load-security-module.cjs');
test('dependency refresh API starts one job and exposes its status', async (t) => {
const job = { id: randomUUID(), type: 'nodejs', state: 'running' };
class DependenceService {
startRefresh(type) {
return { job, accepted: type === job.type };
}
getRefreshJob(id) {
return id === job.id ? job : undefined;
}
getActiveRefreshJob() {
return job.state === 'running' ? job : undefined;
}
}
Container.set(DependenceService, new DependenceService());
t.after(() => Container.remove(DependenceService));
const api = load('back/api/dependence.ts', {
'../services/dependence': { __esModule: true, default: DependenceService },
});
const app = express();
app.use(express.json());
api.default(app);
app.use(errors());
const server = app.listen(0, '127.0.0.1');
await new Promise((resolve) => server.once('listening', resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
const base = `http://127.0.0.1:${server.address().port}/dependencies/refresh`;
const start = (type) =>
fetch(base, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type }),
});
const started = await start('nodejs');
assert.equal(started.status, 200);
assert.deepEqual((await started.json()).data, job);
assert.equal((await start('python3')).status, 409);
assert.equal((await start('linux')).status, 400);
const active = await fetch(`${base}/active`);
assert.equal(active.headers.get('cache-control'), 'no-store');
assert.deepEqual((await active.json()).data, job);
const running = await fetch(`${base}/${job.id}`);
assert.equal(running.headers.get('cache-control'), 'no-store');
assert.equal((await running.json()).data.state, 'running');
job.state = 'completed';
assert.equal((await (await fetch(`${base}/active`)).json()).data, null);
assert.equal(
(await (await fetch(`${base}/${job.id}`)).json()).data.state,
'completed',
);
assert.equal((await fetch(`${base}/${randomUUID()}`)).status, 404);
});
+3 -20
View File
@@ -35,15 +35,8 @@ function serviceFor(docs) {
(!where.id || where.id.includes(d.id)),
),
update: async (values, { where }) => {
const ids = Array.isArray(where.id) ? where.id : [where.id];
docs
.filter(
(d) =>
ids.includes(d.id) &&
(where.status === undefined || d.status === where.status) &&
(where.updatedAt === undefined ||
d.updatedAt === where.updatedAt),
)
.filter((d) => where.id.includes(d.id))
.forEach((d) => Object.assign(d, values));
},
destroy: async ({ where }) => removed.push(...where.id),
@@ -70,7 +63,7 @@ function serviceFor(docs) {
return { service, probes, removed, uninstalls };
}
test('explicit refresh preserves Python sources and detects missing named packages', async () => {
test('listing preserves Python source results and still detects missing named packages', async () => {
const docs = [
' requirements with spaces/requirements.txt ',
'requirements-dev.in',
@@ -78,13 +71,7 @@ test('explicit refresh preserves Python sources and detects missing named packag
'git+https://example.invalid/repo.git',
'https://example.invalid/pkg.whl',
'missing-package',
].map((name, i) => ({
id: i + 1,
name,
type: 1,
status: 1,
updatedAt: new Date('2026-01-01T00:00:00Z'),
}));
].map((name, i) => ({ id: i + 1, name, type: 1, status: 1 }));
const { service, probes } = serviceFor(docs);
for (let i = 0; i < 2; i++) {
await service.dependencies({
@@ -93,10 +80,6 @@ test('explicit refresh preserves Python sources and detects missing named packag
status: '',
});
}
assert.deepEqual(probes, []);
const { job } = service.startRefresh('python3');
await new Promise(setImmediate);
assert.equal(job.state, 'completed');
assert.deepEqual(
docs.map((d) => d.status),
[1, 1, 1, 1, 1, 2],
+1 -1
View File
@@ -220,7 +220,7 @@ test('npm publication consumes the exact archive whose isolated installation pas
const download = workflow.jobs.publish.steps.find((step) => step.uses?.startsWith('actions/download-artifact@'));
assert.equal(download.with.name, 'qinglong-npm-${{ github.sha }}');
const publication = workflow.jobs.publish.steps.find((step) => step.name === 'Publish npm package with OIDC');
assert.match(publication.run, /npm publish "\.\/\$archive" --ignore-scripts/);
assert.match(publication.run, /npm publish "\$archive" --ignore-scripts/);
assert.ok(!workflow.jobs.publish.steps.some((step) => /pnpm build/.test(step.run || '')));
});
+1 -1
View File
@@ -75,7 +75,7 @@ case 'network': console.log('upstream unavailable'); process.exit(1);
assert.equal(fs.existsSync(path.join(root, 'published')), published);
if (published)
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, 'published'), 'utf8')), [
'./npm-panel/verified.tgz', '--ignore-scripts', '--access', 'public', '--registry=https://registry.npmjs.org',
'npm-panel/verified.tgz', '--ignore-scripts', '--access', 'public', '--registry=https://registry.npmjs.org',
]);
});
}
-146
View File
@@ -1,146 +0,0 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require('typescript');
const source = ts.createSourceFile(
'index.tsx',
fs.readFileSync(
path.join(__dirname, '../../src/pages/dependence/index.tsx'),
'utf8',
),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
const names = new Set([
'scheduleRefresh',
'finishRefresh',
'attachRefresh',
'recoverRefresh',
'pollRefresh',
'startRefresh',
]);
const declarations = [];
function visit(node) {
if (ts.isVariableDeclaration(node) && names.has(node.name.getText(source))) {
declarations.push(`var ${node.getText(source)};`);
}
ts.forEachChild(node, visit);
}
visit(source);
function fixture() {
const requests = [];
const timers = new Map();
let timerId = 0;
const state = { refreshing: false, type: null, reloads: 0 };
const request = (url) => {
let resolve, reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
requests.push({ url, resolve, reject });
return promise;
};
const context = {
config: { apiPrefix: '/api/' },
type: 'python3',
alive: { current: true },
activeRefreshId: { current: null },
refreshRequestVersion: { current: 0 },
refreshTimer: {},
request: { get: request, post: request },
setRefreshing: (value) => {
state.refreshing = value;
},
setRefreshingType: (value) => {
state.type = value;
},
setRefreshGeneration: () => {
state.reloads += 1;
},
message: { error() {}, warning() {} },
intl: { get: (value) => value },
setTimeout: (fn, delay) => {
timers.set(++timerId, { fn, delay });
return timerId;
},
clearTimeout: (id) => timers.delete(id),
};
vm.createContext(context);
vm.runInContext(
ts.transpile(declarations.join('\n'), { target: ts.ScriptTarget.ES2020 }),
context,
);
return { context, requests, timers, state };
}
const settle = () => new Promise(setImmediate);
const newJob = { id: 'new-job', type: 'python3' };
test('late recovery cannot replace a newly attached job or clear it as idle', async () => {
for (const data of [{ id: 'old-job', type: 'nodejs' }, null]) {
const f = fixture();
f.context.recoverRefresh(true, true);
f.context.attachRefresh(newJob);
f.requests[0].resolve({ code: 200, data });
await settle();
assert.equal(f.context.activeRefreshId.current, newJob.id);
assert.equal(f.state.refreshing, true);
assert.equal(f.state.reloads, 0);
assert.equal([...f.timers.values()][0].delay, 0);
}
});
test('late recovery failure cannot replace the new job poll timer', async () => {
const f = fixture();
f.context.recoverRefresh(false, true);
f.context.attachRefresh(newJob);
f.requests[0].reject(new Error('offline'));
await settle();
assert.equal(f.timers.size, 1);
assert.equal([...f.timers.values()][0].delay, 0);
});
test('a queued recovery retry is invalidated when a new job starts', async () => {
const f = fixture();
f.context.recoverRefresh(false, true);
f.requests[0].reject(new Error('offline'));
await settle();
const retry = [...f.timers.values()][0].fn;
f.context.startRefresh();
retry();
assert.equal(f.requests.length, 2);
f.requests[1].resolve({ code: 200, data: newJob });
await settle();
assert.equal(f.context.activeRefreshId.current, newJob.id);
});
test('starting a job invalidates recovery before the start response arrives', async () => {
const f = fixture();
f.context.recoverRefresh(false, true);
f.context.startRefresh();
f.requests[0].resolve({ code: 200, data: { id: 'old-job', type: 'nodejs' } });
await settle();
assert.equal(f.context.activeRefreshId.current, null);
f.requests[1].resolve({ code: 200, data: newJob });
await settle();
assert.equal(f.context.activeRefreshId.current, newJob.id);
});
test('current recovery still attaches and polls the running job', async () => {
const f = fixture();
f.context.recoverRefresh();
f.requests[0].resolve({ code: 200, data: newJob });
await settle();
[...f.timers.values()][0].fn();
assert.equal(f.requests[1].url, '/api/dependencies/refresh/new-job');
f.requests[1].resolve({ code: 200, data: { ...newJob, state: 'completed' } });
await settle();
assert.equal(f.state.refreshing, false);
assert.equal(f.state.reloads, 1);
});