mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-17 07:03:29 +08:00
fix: preserve Python source state and repair dev regressions
This commit is contained in:
+9
-6
@@ -716,17 +716,20 @@ function isPyprojectToml(name: string): boolean {
|
|||||||
return name.endsWith('pyproject.toml');
|
return name.endsWith('pyproject.toml');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPythonSourceDependency(name: string): boolean {
|
||||||
|
const source = name.trim();
|
||||||
|
return (
|
||||||
|
isGitHubUrl(source) || isRequirementsFile(source) || isPyprojectToml(source)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getGetCommand(type: DependenceTypes, name: string): string {
|
export function getGetCommand(type: DependenceTypes, name: string): string {
|
||||||
const trimmedName = name.trim();
|
const trimmedName = name.trim();
|
||||||
|
|
||||||
// For Python dependencies installed from GitHub or requirements files,
|
// For Python dependencies installed from GitHub or requirements files,
|
||||||
// we can't reliably check if they're installed, so skip the check
|
// we can't reliably check if they're installed, so skip the check
|
||||||
if (type === DependenceTypes.python3) {
|
if (type === DependenceTypes.python3) {
|
||||||
if (
|
if (isPythonSourceDependency(trimmedName)) {
|
||||||
isGitHubUrl(trimmedName) ||
|
|
||||||
isRequirementsFile(trimmedName) ||
|
|
||||||
isPyprojectToml(trimmedName)
|
|
||||||
) {
|
|
||||||
// Return a command that will always indicate not installed
|
// Return a command that will always indicate not installed
|
||||||
// This ensures GitHub URLs and requirements files are always installed
|
// This ensures GitHub URLs and requirements files are always installed
|
||||||
return 'echo ""';
|
return 'echo ""';
|
||||||
@@ -917,7 +920,7 @@ async function _updateLinuxMirror(
|
|||||||
throw new Error('Unsupported OS type for updating mirrors.');
|
throw new Error('Unsupported OS type for updating mirrors.');
|
||||||
const target = mirror || defaults[osType];
|
const target = mirror || defaults[osType];
|
||||||
normalizeMirror(target);
|
normalizeMirror(target);
|
||||||
const S = isInContainer() ? 'sudo ' : '';
|
const S = osType !== 'Alpine' && isInContainer() ? 'sudo ' : '';
|
||||||
const update =
|
const update =
|
||||||
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
|
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
|
||||||
let file =
|
let file =
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
getInstallCommand,
|
getInstallCommand,
|
||||||
getUninstallCommand,
|
getUninstallCommand,
|
||||||
getGetCommand,
|
getGetCommand,
|
||||||
|
isPythonSourceDependency,
|
||||||
} from '../config/util';
|
} from '../config/util';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import taskLimit from '../shared/pLimit';
|
import taskLimit from '../shared/pLimit';
|
||||||
@@ -76,21 +77,25 @@ export default class DependenceService {
|
|||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
taskLimit.removeQueuedDependency(doc);
|
taskLimit.removeQueuedDependency(doc);
|
||||||
}
|
}
|
||||||
const unInstalledDeps = docs.filter(
|
|
||||||
(x) => x.status !== DependenceStatus.installed,
|
|
||||||
);
|
|
||||||
const installedDeps = docs.filter(
|
const installedDeps = docs.filter(
|
||||||
(x) => x.status === DependenceStatus.installed,
|
(x) =>
|
||||||
|
x.status === DependenceStatus.installed ||
|
||||||
|
// Older list refreshes marked unprobeable sources as failed. They may
|
||||||
|
// still have installed distributions (including partial pip installs).
|
||||||
|
(x.status === DependenceStatus.installFailed &&
|
||||||
|
x.type === DependenceTypes.python3 &&
|
||||||
|
isPythonSourceDependency(x.name)),
|
||||||
);
|
);
|
||||||
|
const unInstalledDeps = docs.filter((x) => !installedDeps.includes(x));
|
||||||
await this.removeDb(unInstalledDeps.map((x) => x.id!));
|
await this.removeDb(unInstalledDeps.map((x) => x.id!));
|
||||||
|
|
||||||
if (installedDeps.length) {
|
if (installedDeps.length) {
|
||||||
await DependenceModel.update(
|
await DependenceModel.update(
|
||||||
{ status: DependenceStatus.queued, log: [] },
|
{ status: DependenceStatus.queued, log: [] },
|
||||||
{ where: { id: ids } },
|
{ where: { id: installedDeps.map((x) => x.id!) } },
|
||||||
);
|
);
|
||||||
|
|
||||||
this.installDependenceOneByOne(docs, false, force);
|
this.installDependenceOneByOne(installedDeps, false, force);
|
||||||
}
|
}
|
||||||
return docs;
|
return docs;
|
||||||
}
|
}
|
||||||
@@ -156,7 +161,13 @@ export default class DependenceService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const checks = await concurrentRun(
|
const checks = await concurrentRun(
|
||||||
docs.map((doc) => async () => {
|
docs
|
||||||
|
.filter(
|
||||||
|
(doc) =>
|
||||||
|
doc.type !== DependenceTypes.python3 ||
|
||||||
|
!isPythonSourceDependency(doc.name),
|
||||||
|
)
|
||||||
|
.map((doc) => async () => {
|
||||||
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
|
return (await this.isDependenceInstalled(doc)) ? undefined : doc.id;
|
||||||
}),
|
}),
|
||||||
5,
|
5,
|
||||||
|
|||||||
@@ -700,6 +700,8 @@
|
|||||||
"已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.",
|
"已保存,但旧历史清理失败,请检查存储权限和空间": "Saved, but old history could not be removed. Check storage permissions and free space.",
|
||||||
"保存失败": "Unable to save",
|
"保存失败": "Unable to save",
|
||||||
"通知日志": "Notification log",
|
"通知日志": "Notification log",
|
||||||
|
"标题": "Title",
|
||||||
|
"内容": "Content",
|
||||||
"发送时间": "Sent at",
|
"发送时间": "Sent at",
|
||||||
"推送渠道": "Channel",
|
"推送渠道": "Channel",
|
||||||
"发送状态": "Delivery status",
|
"发送状态": "Delivery status",
|
||||||
|
|||||||
@@ -700,6 +700,8 @@
|
|||||||
"已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间",
|
"已保存,但旧历史清理失败,请检查存储权限和空间": "已保存,但旧历史清理失败,请检查存储权限和空间",
|
||||||
"保存失败": "保存失败",
|
"保存失败": "保存失败",
|
||||||
"通知日志": "通知日志",
|
"通知日志": "通知日志",
|
||||||
|
"标题": "标题",
|
||||||
|
"内容": "内容",
|
||||||
"发送时间": "发送时间",
|
"发送时间": "发送时间",
|
||||||
"推送渠道": "推送渠道",
|
"推送渠道": "推送渠道",
|
||||||
"发送状态": "发送状态",
|
"发送状态": "发送状态",
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ test('dependency listing marks cache entries missing from disk for reinstall', a
|
|||||||
fileExist: async () => false,
|
fileExist: async () => false,
|
||||||
getGetCommand: (_type, name) => `check:${name}`,
|
getGetCommand: (_type, name) => `check:${name}`,
|
||||||
getInstallCommand: () => '',
|
getInstallCommand: () => '',
|
||||||
|
isPythonSourceDependency: () => false,
|
||||||
getPid: async () => 0,
|
getPid: async () => 0,
|
||||||
getUninstallCommand: () => '',
|
getUninstallCommand: () => '',
|
||||||
killTask: async () => {},
|
killTask: async () => {},
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
|
const load = require('../helpers/load-security-module.cjs');
|
||||||
|
|
||||||
|
const types = { nodejs: 0, python3: 1, linux: 2 };
|
||||||
|
const statuses = { installed: 1, installFailed: 2, queued: 6, cancelled: 7 };
|
||||||
|
const util = load('back/config/util.ts', {
|
||||||
|
'./index': {},
|
||||||
|
'./const': {},
|
||||||
|
'../loaders/logger': {},
|
||||||
|
'../shared/utils': {},
|
||||||
|
'../data/dependence': { DependenceTypes: types },
|
||||||
|
});
|
||||||
|
|
||||||
|
function serviceFor(docs) {
|
||||||
|
const probes = [],
|
||||||
|
removed = [],
|
||||||
|
uninstalls = [];
|
||||||
|
const Service = load('back/services/dependence.ts', {
|
||||||
|
'../config': {},
|
||||||
|
'../data/dependence': {
|
||||||
|
Dependence: class {},
|
||||||
|
DependenceTypes: types,
|
||||||
|
DependenceStatus: statuses,
|
||||||
|
versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' },
|
||||||
|
DependenceModel: {
|
||||||
|
findAll: async ({ where }) =>
|
||||||
|
docs.filter(
|
||||||
|
(d) =>
|
||||||
|
(where.status === undefined || d.status === where.status) &&
|
||||||
|
(!where.id || where.id.includes(d.id)),
|
||||||
|
),
|
||||||
|
update: async (values, { where }) => {
|
||||||
|
docs
|
||||||
|
.filter((d) => where.id.includes(d.id))
|
||||||
|
.forEach((d) => Object.assign(d, values));
|
||||||
|
},
|
||||||
|
destroy: async ({ where }) => removed.push(...where.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'../config/util': {
|
||||||
|
...util,
|
||||||
|
concurrentRun: (tasks) => Promise.all(tasks.map((fn) => fn())),
|
||||||
|
getGetCommand: (type, name) => {
|
||||||
|
probes.push(name);
|
||||||
|
return util.getGetCommand(type, name);
|
||||||
|
},
|
||||||
|
promiseExecSuccess: async () => '',
|
||||||
|
},
|
||||||
|
'../shared/pLimit': { removeQueuedDependency() {} },
|
||||||
|
'./sock': class {},
|
||||||
|
'../shared/i18n': {},
|
||||||
|
}).default;
|
||||||
|
const service = new Service({}, {});
|
||||||
|
service.installDependenceOneByOne = (dependencies, install, force) => {
|
||||||
|
uninstalls.push({ ids: dependencies.map((d) => d.id), install, force });
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
return { service, probes, removed, uninstalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('listing preserves Python source results and still detects missing named packages', async () => {
|
||||||
|
const docs = [
|
||||||
|
' requirements with spaces/requirements.txt ',
|
||||||
|
'requirements-dev.in',
|
||||||
|
'project/pyproject.toml',
|
||||||
|
'git+https://example.invalid/repo.git',
|
||||||
|
'https://example.invalid/pkg.whl',
|
||||||
|
'missing-package',
|
||||||
|
].map((name, i) => ({ id: i + 1, name, type: 1, status: 1 }));
|
||||||
|
const { service, probes } = serviceFor(docs);
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
await service.dependencies({
|
||||||
|
searchValue: '',
|
||||||
|
type: 'python3',
|
||||||
|
status: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
docs.map((d) => d.status),
|
||||||
|
[1, 1, 1, 1, 1, 2],
|
||||||
|
);
|
||||||
|
assert.deepEqual(probes, ['missing-package']);
|
||||||
|
// Source installs must continue to execute pip rather than treating the source as a package name.
|
||||||
|
assert.equal(util.getGetCommand(1, docs[0].name), 'echo ""');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletion cleans previously misclassified Python sources and only queues intended records', async () => {
|
||||||
|
const docs = [
|
||||||
|
{ id: 1, name: 'requirements.txt', type: 1, status: 2 },
|
||||||
|
{ id: 2, name: 'present-package', type: 0, status: 1 },
|
||||||
|
{ id: 3, name: 'missing-package', type: 0, status: 2 },
|
||||||
|
{ id: 4, name: 'queued/requirements.txt', type: 1, status: 6 },
|
||||||
|
{ id: 5, name: 'cancelled/requirements.txt', type: 1, status: 7 },
|
||||||
|
{ id: 6, name: 'git+https://example.invalid/repo.git', type: 1, status: 2 },
|
||||||
|
];
|
||||||
|
const { service, removed, uninstalls } = serviceFor(docs);
|
||||||
|
await service.remove(docs.map((d) => d.id));
|
||||||
|
assert.deepEqual(removed, [3, 4, 5]);
|
||||||
|
assert.deepEqual(uninstalls, [
|
||||||
|
{ ids: [1, 2, 6], install: false, force: false },
|
||||||
|
]);
|
||||||
|
assert.equal(docs[2].status, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Alpine container mirror command runs without sudo and writes valid repositories', async (t) => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-alpine-mirror-'));
|
||||||
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||||
|
const source =
|
||||||
|
'https://dl-cdn.alpinelinux.org/alpine/v3.23/main\nhttps://dl-cdn.alpinelinux.org/alpine/v3.23/community\n';
|
||||||
|
const api = load('back/config/util.ts', {
|
||||||
|
'fs/promises': { readFile: async () => source },
|
||||||
|
'./index': {},
|
||||||
|
'./const': {},
|
||||||
|
'./share': {},
|
||||||
|
'./container': { isInContainer: () => true },
|
||||||
|
'../loaders/logger': {},
|
||||||
|
'../shared/utils': {},
|
||||||
|
'../shared/fileAccess': {},
|
||||||
|
'../data/dependence': {},
|
||||||
|
});
|
||||||
|
const previous = process.env.QL_OS_TYPE;
|
||||||
|
let command;
|
||||||
|
try {
|
||||||
|
process.env.QL_OS_TYPE = 'alpine';
|
||||||
|
command = await api.updateLinuxMirrorFile('https://mirror.example/alpine');
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.QL_OS_TYPE;
|
||||||
|
else process.env.QL_OS_TYPE = previous;
|
||||||
|
}
|
||||||
|
// Redirect the generated command's paths and executable into the fixture only.
|
||||||
|
const file = path.join(root, 'repositories');
|
||||||
|
const update = path.join(root, 'updated');
|
||||||
|
const sandbox = command
|
||||||
|
.replaceAll('/etc/apk/repositories', file)
|
||||||
|
.replaceAll('/etc/apk', root);
|
||||||
|
execFileSync('/bin/bash', [
|
||||||
|
'-c',
|
||||||
|
`sudo(){ return 127; }; apk(){ test "$1" = update && touch '${update}'; }; ${sandbox}`,
|
||||||
|
]);
|
||||||
|
assert.ok(fs.existsSync(update));
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(file, 'utf8'),
|
||||||
|
source.replaceAll(
|
||||||
|
'https://dl-cdn.alpinelinux.org/alpine',
|
||||||
|
'https://mirror.example/alpine',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
test('notification log column titles resolve in every supported locale', () => {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../src/pages/setting/notifyLog.tsx'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const titles = [...source.matchAll(/title:\s*intl\.get\('([^']+)'\)/g)].map(
|
||||||
|
(m) => m[1],
|
||||||
|
);
|
||||||
|
assert.ok(titles.includes('标题') && titles.includes('内容'));
|
||||||
|
for (const locale of ['zh-CN', 'en-US']) {
|
||||||
|
const messages = require(`../../src/locales/${locale}.json`);
|
||||||
|
for (const title of titles)
|
||||||
|
assert.ok(messages[title]?.trim(), `${locale}: ${title}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user