mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
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
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
import { translate } from '../shared/i18n/index';
|
||||
import { standaloneHelp } from '../internal/help/standalone';
|
||||
import {
|
||||
withCommandCancellation,
|
||||
interruptedCode,
|
||||
} from '../internal/runtime/cancellation';
|
||||
import { CliError, fail } from '../shared/errors';
|
||||
|
||||
export interface CompatibilityRoute {
|
||||
surface: 'local' | 'subscription';
|
||||
args: string[];
|
||||
}
|
||||
|
||||
export function compatibilityRoute(input: string[]): CompatibilityRoute {
|
||||
const args = input[0] === '-l' ? input.slice(1) : [...input];
|
||||
const [action, ...values] = args;
|
||||
if (action === 'repo' || action === 'raw')
|
||||
return { surface: 'subscription', args };
|
||||
if (action === 'update') {
|
||||
if (
|
||||
values.length > 1 ||
|
||||
(values.length && !['true', 'false'].includes(values[0]!))
|
||||
)
|
||||
fail(translate(process.env, '用法:ql-compat update [true|false]'), 2);
|
||||
return {
|
||||
surface: 'local',
|
||||
args: ['update', ...(values[0] === 'false' ? ['--download-only'] : [])],
|
||||
};
|
||||
}
|
||||
if (action === 'reload') {
|
||||
if (
|
||||
values.length > 1 ||
|
||||
(values.length && !['services', 'system', 'data'].includes(values[0]!))
|
||||
)
|
||||
fail(
|
||||
translate(process.env, '用法:ql-compat reload [services|system|data]'),
|
||||
2,
|
||||
);
|
||||
return {
|
||||
surface: 'local',
|
||||
args: ['reload', '--target', values[0] || 'services'],
|
||||
};
|
||||
}
|
||||
if (
|
||||
action &&
|
||||
[
|
||||
'rmlog',
|
||||
'extra',
|
||||
'bot',
|
||||
'check',
|
||||
'resetlet',
|
||||
'resettfa',
|
||||
'resetpwd',
|
||||
'resetname',
|
||||
].includes(action)
|
||||
) {
|
||||
// A legacy positional value beginning with '-' is data, never a CLI option.
|
||||
return { surface: 'local', args: [action, '--', ...values] };
|
||||
}
|
||||
fail(translate(process.env, '未知旧命令,请运行 ql-compat --help。'), 2);
|
||||
}
|
||||
|
||||
export async function compatibilityMain(
|
||||
args = process.argv.slice(2),
|
||||
): Promise<number> {
|
||||
return withCommandCancellation(async (signal) => {
|
||||
try {
|
||||
if (
|
||||
!args.length ||
|
||||
(args.length === 1 && ['--help', '-h'].includes(args[0]!))
|
||||
) {
|
||||
process.stdout.write(standaloneHelp('compat'));
|
||||
return 0;
|
||||
}
|
||||
const route = compatibilityRoute(args);
|
||||
if (route.surface === 'subscription') {
|
||||
const { subscriptionWorker } = await import(
|
||||
'../internal/subscription/worker'
|
||||
);
|
||||
return subscriptionWorker(route.args);
|
||||
}
|
||||
const { parse } = await import('../shared/cli/arguments');
|
||||
const { localContext } = await import('../internal/runtime/context');
|
||||
const { maintenance } = await import(
|
||||
'../internal/maintenance/maintenance'
|
||||
);
|
||||
const { loggedOperation } = await import(
|
||||
'../internal/runtime/commandLog'
|
||||
);
|
||||
const { registry } = await import('../internal/commands/registry');
|
||||
const command = parse(route.args, registry);
|
||||
const context = await localContext(command, { signal });
|
||||
const { result, logPath } = await loggedOperation(
|
||||
context,
|
||||
route.args[0]!,
|
||||
() => maintenance(command, context),
|
||||
signal,
|
||||
);
|
||||
process.stdout.write(
|
||||
JSON.stringify({ code: 200, data: result, logPath }) + '\n',
|
||||
);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const code =
|
||||
interruptedCode(signal) ??
|
||||
(error instanceof CliError ? error.exitCode : 1);
|
||||
process.stderr.write(
|
||||
JSON.stringify({
|
||||
code,
|
||||
message: signal.aborted
|
||||
? translate(process.env, '命令已中断。')
|
||||
: error instanceof CliError
|
||||
? error.message
|
||||
: translate(process.env, '旧命令适配器执行失败。'),
|
||||
}) + '\n',
|
||||
);
|
||||
return code;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module)
|
||||
void compatibilityMain().then((code) => {
|
||||
process.exitCode = code;
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { CommandSurface } from '../shared/cli/registry';
|
||||
// Compatibility for existing programmatic callers; product entries select one surface.
|
||||
export async function main(
|
||||
args = process.argv.slice(2),
|
||||
surface: CommandSurface = 'public',
|
||||
signal?: AbortSignal,
|
||||
): Promise<number> {
|
||||
return surface === 'public'
|
||||
? (await import('../entrypoints/remote')).remoteMain(args)
|
||||
: (await import('../internal/commands/main')).internalMain(args, signal);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
import { standaloneHelp } from '../internal/help/standalone';
|
||||
import {
|
||||
withCommandCancellation,
|
||||
cancellableOperation,
|
||||
interruptedCode,
|
||||
} from '../internal/runtime/cancellation';
|
||||
|
||||
// The legacy qinglong command starts the host installation; reload is its only
|
||||
// positional mode. All remaining validation belongs to the shared registry.
|
||||
export function startupArguments(args: string[]): string[] {
|
||||
return args[0] === 'reload'
|
||||
? ['start', '--reload', ...args.slice(1)]
|
||||
: ['start', ...args];
|
||||
}
|
||||
|
||||
export async function startupMain(
|
||||
args = process.argv.slice(2),
|
||||
): Promise<number> {
|
||||
if (
|
||||
args.length &&
|
||||
args.every((arg) => ['--help', '-h', '--json'].includes(arg)) &&
|
||||
args.some((arg) => arg === '--help' || arg === '-h')
|
||||
) {
|
||||
const help = standaloneHelp('startup');
|
||||
process.stdout.write(
|
||||
(args.includes('--json')
|
||||
? JSON.stringify({ code: 200, data: { help } })
|
||||
: help) + '\n',
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
return withCommandCancellation(async (signal) => {
|
||||
try {
|
||||
const { main } = await import('./main');
|
||||
return await cancellableOperation(signal, () =>
|
||||
main(startupArguments(args), 'local', signal),
|
||||
);
|
||||
} catch (error) {
|
||||
const code = interruptedCode(signal);
|
||||
if (code !== undefined) return code;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module)
|
||||
void startupMain().then((code) => {
|
||||
process.exitCode = code;
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
import { main } from '../compatibility/main';
|
||||
import {
|
||||
withCommandCancellation,
|
||||
cancellableOperation,
|
||||
interruptedCode,
|
||||
} from '../internal/runtime/cancellation';
|
||||
|
||||
void withCommandCancellation(async (signal) => {
|
||||
try {
|
||||
return await cancellableOperation(signal, () =>
|
||||
main(process.argv.slice(2), 'local', signal),
|
||||
);
|
||||
} catch (error) {
|
||||
const code = interruptedCode(signal);
|
||||
if (code !== undefined) return code;
|
||||
throw error;
|
||||
}
|
||||
}).then((code) => {
|
||||
process.exitCode = code;
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
import { translate } from '../shared/i18n/index';
|
||||
import { createContext } from '../internal/runtime/context';
|
||||
import { withCommandCancellation } from '../internal/runtime/cancellation';
|
||||
import { runContainer } from '../internal/runtime/containerRuntime';
|
||||
|
||||
const event = (value: Record<string, unknown>) =>
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
void withCommandCancellation(async (signal) => {
|
||||
if (process.argv.length > 2)
|
||||
throw new Error(translate(process.env, '容器入口仅接受通过环境变量配置。'));
|
||||
return runContainer(
|
||||
createContext({}, { ...process.env, QL_DIR: process.env.QL_DIR || '/ql' }),
|
||||
signal,
|
||||
{ event },
|
||||
);
|
||||
}).then(
|
||||
(code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
(error: unknown) => {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
event: 'error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { main } from '../compatibility/main';
|
||||
|
||||
void main().then((code) => {
|
||||
process.exitCode = code;
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
import { internalMain } from '../internal/commands/main';
|
||||
import { routeCommands } from '../shared/cli/router';
|
||||
import { commands } from '../internal/commands/registry';
|
||||
import { helpText } from '../shared/i18n/help';
|
||||
import { quietCommand } from '../shared/cli/options';
|
||||
import { standaloneHelp } from '../internal/help/standalone';
|
||||
|
||||
// Preserve the old ambiguous vocabulary without importing the remote command tree.
|
||||
const remoteTaskActions = new Set([
|
||||
'view-list',
|
||||
'view-create',
|
||||
'view-update',
|
||||
'view-delete',
|
||||
'view-move',
|
||||
'view-disable',
|
||||
'view-enable',
|
||||
'detail',
|
||||
'create',
|
||||
'labels-delete',
|
||||
'labels-create',
|
||||
'disable',
|
||||
'enable',
|
||||
'update',
|
||||
'delete',
|
||||
'pin',
|
||||
'unpin',
|
||||
'import',
|
||||
'status',
|
||||
'instances',
|
||||
'instance-stop',
|
||||
'log-files',
|
||||
'list',
|
||||
'get',
|
||||
'run',
|
||||
'stop',
|
||||
'logs',
|
||||
]);
|
||||
const operatorActions = new Set(
|
||||
commands
|
||||
.filter((command) => command.local)
|
||||
.map((command) => command.name.slice(6)),
|
||||
);
|
||||
|
||||
// Normalize only the leading legacy positional token. Values belonging to
|
||||
// named options and arguments after -- must never be reinterpreted.
|
||||
export function operatorArguments(args: string[]): string[] {
|
||||
const [action, value, ...rest] = args;
|
||||
if (action === 'update' && (value === 'true' || value === 'false'))
|
||||
return [action, ...(value === 'false' ? ['--download-only'] : []), ...rest];
|
||||
if (
|
||||
action === 'reload' &&
|
||||
['services', 'system', 'data'].includes(value ?? '')
|
||||
)
|
||||
return [action, '--target', value!, ...rest];
|
||||
return args;
|
||||
}
|
||||
|
||||
function splitPrefix(args: string[]): { prefix: string[]; body: string[] } {
|
||||
let offset = 0;
|
||||
while (args[offset] === '--json') offset++;
|
||||
return { prefix: args.slice(0, offset), body: args.slice(offset) };
|
||||
}
|
||||
|
||||
function writeHelp(help: string, json: boolean): number {
|
||||
process.stdout.write(
|
||||
(json ? JSON.stringify({ code: 200, data: { help } }) : help) + '\n',
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function groupHelp(group: 'root' | 'task' | 'local'): string {
|
||||
const english = process.env.QL_LANG === 'en';
|
||||
if (group === 'task') return standaloneHelp('runner');
|
||||
const program = quietCommand(group === 'local' ? 'ql local' : 'ql');
|
||||
program.description(
|
||||
english ? 'QingLong panel-internal tools' : '青龙面板内部工具',
|
||||
);
|
||||
// Operator help and routing both derive from the command registry.
|
||||
for (const spec of commands.filter((command) => command.local)) {
|
||||
const child = quietCommand(spec.name.slice(6));
|
||||
child.description(helpText(spec.summary));
|
||||
if (spec.arguments) child.arguments(spec.arguments);
|
||||
program.addCommand(child);
|
||||
}
|
||||
for (const [name, description] of [
|
||||
...(group === 'root'
|
||||
? [
|
||||
[
|
||||
'task',
|
||||
english
|
||||
? 'Execute local scripts (alias: task)'
|
||||
: '执行本机脚本(简写:task)',
|
||||
],
|
||||
]
|
||||
: []),
|
||||
['repo', english ? 'Synchronize a repository locally' : '本机仓库同步'],
|
||||
['raw', english ? 'Download a script locally' : '本机脚本下载'],
|
||||
])
|
||||
program.addCommand(quietCommand(name!).description(description!));
|
||||
program
|
||||
.option('--json', english ? 'JSON output' : 'JSON 输出')
|
||||
.option('-h, --help', english ? 'Show help' : '显示帮助');
|
||||
return (
|
||||
program
|
||||
.helpInformation()
|
||||
.replace(/^Usage:/m, english ? 'Usage:' : '用法:')
|
||||
.replace(/^Options:/m, english ? 'Options:' : '选项:')
|
||||
.replace(/^Commands:/m, english ? 'Commands:' : '命令:') +
|
||||
(english
|
||||
? '\nLocal tools only. Remote management uses the separate @whyour/qinglong-cli npm entry. ql local remains a maintenance alias.\n'
|
||||
: '\n仅操作本机;远程管理使用独立的 @whyour/qinglong-cli npm 入口。ql local 保留为运维兼容别名。\n')
|
||||
);
|
||||
}
|
||||
|
||||
function internalOnly(args: string[]): number {
|
||||
const message =
|
||||
process.env.QL_LANG === 'en'
|
||||
? 'Unknown or remote command. This entry only operates the local panel; use the separate @whyour/qinglong-cli entry for remote management. Use task exec for scripts named like API actions.'
|
||||
: '未知命令或远程命令。此入口仅操作本机面板;远程管理请使用独立的 @whyour/qinglong-cli 入口。同名脚本请使用 task exec。';
|
||||
process.stderr.write(
|
||||
(args.includes('--json') ? JSON.stringify({ code: 2, message }) : message) +
|
||||
'\n',
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
|
||||
export async function taskMain(args = process.argv.slice(2)): Promise<number> {
|
||||
const { prefix, body } = splitPrefix(args);
|
||||
if (
|
||||
body.some((arg) => ['--help', '-h'].includes(arg)) &&
|
||||
body.every((arg) => ['--help', '-h', '--json'].includes(arg))
|
||||
)
|
||||
return writeHelp(groupHelp('task'), args.includes('--json'));
|
||||
const execute = async (scriptArgs: string[]) => {
|
||||
const { runnerMain } = await import('../internal/execution/runner');
|
||||
return runnerMain([...prefix, ...scriptArgs]);
|
||||
};
|
||||
return routeCommands(
|
||||
'task',
|
||||
body,
|
||||
{
|
||||
...Object.fromEntries(
|
||||
[...remoteTaskActions].map((action) => [
|
||||
action,
|
||||
async () => internalOnly(args),
|
||||
]),
|
||||
),
|
||||
exec: execute,
|
||||
},
|
||||
() => execute(body),
|
||||
);
|
||||
}
|
||||
|
||||
export async function qlMain(args = process.argv.slice(2)): Promise<number> {
|
||||
const { prefix, body } = splitPrefix(args);
|
||||
const [group, ...rest] = body;
|
||||
if (
|
||||
!group ||
|
||||
body.every((arg) => ['help', '--help', '-h', '--json'].includes(arg))
|
||||
)
|
||||
return writeHelp(groupHelp('root'), args.includes('--json'));
|
||||
const local = async (localArgs: string[]): Promise<number> => {
|
||||
if (
|
||||
!localArgs.length ||
|
||||
localArgs.every((arg) => ['--help', '-h', '--json'].includes(arg))
|
||||
)
|
||||
return writeHelp(groupHelp('local'), args.includes('--json'));
|
||||
if (localArgs[0] === 'repo' || localArgs[0] === 'raw') {
|
||||
if (
|
||||
localArgs.slice(1).some((arg) => arg === '--help' || arg === '-h') &&
|
||||
localArgs
|
||||
.slice(1)
|
||||
.every((arg) => ['--help', '-h', '--json'].includes(arg))
|
||||
)
|
||||
return writeHelp(standaloneHelp('worker'), args.includes('--json'));
|
||||
const { subscriptionWorker } = await import(
|
||||
'../internal/subscription/worker'
|
||||
);
|
||||
return subscriptionWorker(localArgs);
|
||||
}
|
||||
const { withCommandCancellation, cancellableOperation, interruptedCode } =
|
||||
await import('../internal/runtime/cancellation');
|
||||
return withCommandCancellation(async (signal) => {
|
||||
try {
|
||||
return await cancellableOperation(signal, () =>
|
||||
internalMain(
|
||||
[...prefix, 'local', ...operatorArguments(localArgs)],
|
||||
signal,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const code = interruptedCode(signal);
|
||||
if (code !== undefined) return code;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
// The legacy -l prefix predates option-based command routing.
|
||||
if (!prefix.length && group === '-l') {
|
||||
const { compatibilityMain } = await import('../compatibility/legacy');
|
||||
return compatibilityMain(body);
|
||||
}
|
||||
return routeCommands(
|
||||
'ql',
|
||||
body,
|
||||
{
|
||||
task: (rest) => taskMain([...prefix, ...rest]),
|
||||
local,
|
||||
...Object.fromEntries(
|
||||
[...operatorActions, 'repo', 'raw'].map((action) => [
|
||||
action,
|
||||
(rest: string[]) => local([action, ...rest]),
|
||||
]),
|
||||
),
|
||||
},
|
||||
async () => internalOnly(args),
|
||||
);
|
||||
}
|
||||
|
||||
if (require.main === module)
|
||||
void qlMain().then((code) => {
|
||||
process.exitCode = code;
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
import { registry } from '../remote/commands/registry';
|
||||
import { invoke } from '../shared/cli/invoke';
|
||||
import { dispatchRemote } from '../remote/commands/dispatch';
|
||||
|
||||
export function remoteMain(args = process.argv.slice(2)): Promise<number> {
|
||||
return invoke(args, registry, dispatchRemote);
|
||||
}
|
||||
|
||||
if (require.main === module)
|
||||
void remoteMain().then((code) => {
|
||||
process.exitCode = code;
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { taskMain } from './ql';
|
||||
export { taskMain } from './ql';
|
||||
|
||||
if (require.main === module)
|
||||
void taskMain().then(code => { process.exitCode = code; });
|
||||
@@ -0,0 +1,42 @@
|
||||
import { fail } from '../../shared/errors';
|
||||
import type { Invocation } from '../../shared/cli/arguments';
|
||||
import type { ApiResponse, LogResponse } from '../../shared/types';
|
||||
|
||||
export async function dispatch(
|
||||
command: Invocation,
|
||||
): Promise<ApiResponse<unknown> | LogResponse> {
|
||||
const { name } = command;
|
||||
if (!name.startsWith('local '))
|
||||
fail('Internal dispatcher requires a local command.', 2);
|
||||
const { maintenance } = await import('../maintenance/maintenance');
|
||||
const action = name.slice('local '.length);
|
||||
if (
|
||||
[
|
||||
'update',
|
||||
'reload',
|
||||
'rmlog',
|
||||
'extra',
|
||||
'bot',
|
||||
'check',
|
||||
'resetlet',
|
||||
'resettfa',
|
||||
'resetpwd',
|
||||
'resetname',
|
||||
].includes(action)
|
||||
) {
|
||||
const { localContext } = await import('../runtime/context');
|
||||
const { loggedOperation } = await import('../runtime/commandLog');
|
||||
const { operationSignal } = await import('../runtime/cancellation');
|
||||
const signal = operationSignal();
|
||||
const context = await localContext(command, { signal });
|
||||
const { result, logPath } = await loggedOperation(
|
||||
context,
|
||||
action,
|
||||
() => maintenance(command, context),
|
||||
signal,
|
||||
);
|
||||
const response = { code: 200 as const, data: result, logPath };
|
||||
return response;
|
||||
}
|
||||
return { code: 200, data: await maintenance(command) };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { invoke } from '../../shared/cli/invoke';
|
||||
import { registry } from './registry';
|
||||
import { dispatch } from './dispatch';
|
||||
export async function internalMain(
|
||||
args: string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<number> {
|
||||
const interrupted = signal
|
||||
? await import('../runtime/cancellation')
|
||||
: undefined;
|
||||
return invoke(args, registry, dispatch, signal, () =>
|
||||
interrupted?.interruptedCode(signal!),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
CommandSpec,
|
||||
CommandRegistry,
|
||||
OptionSpec,
|
||||
} from '../../shared/cli/registry';
|
||||
import { integer } from '../../shared/cli/arguments';
|
||||
export const commands: readonly CommandSpec[] = [
|
||||
{
|
||||
name: 'local rmlog',
|
||||
summary: 'Remove expired logs not referenced by a task',
|
||||
arguments: '<days>',
|
||||
minimum: 1,
|
||||
maximum: 1,
|
||||
local: true,
|
||||
},
|
||||
{
|
||||
name: 'local extra',
|
||||
summary: 'Execute the user extra.sh hook',
|
||||
local: true,
|
||||
},
|
||||
{
|
||||
name: 'local repair-config',
|
||||
summary: 'Restore missing configuration templates and runtime directories',
|
||||
local: true,
|
||||
},
|
||||
{
|
||||
name: 'local check',
|
||||
summary:
|
||||
'Install runtime dependencies, repair files, diagnose and reload services',
|
||||
local: true,
|
||||
},
|
||||
{
|
||||
name: 'local bot',
|
||||
summary: 'Install dependencies and start the optional local Telegram bot',
|
||||
local: true,
|
||||
},
|
||||
{
|
||||
name: 'local start',
|
||||
summary: 'Install runtime prerequisites and start nginx and panel services',
|
||||
local: true,
|
||||
options: {
|
||||
'no-startup': {
|
||||
type: 'boolean',
|
||||
description: 'Skip OS boot registration (for containers)',
|
||||
},
|
||||
reload: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Restart services without installing packages or starting optional hooks',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'local reload',
|
||||
summary: 'Restart local services or apply staged system/data files',
|
||||
local: true,
|
||||
options: {
|
||||
target: {
|
||||
type: 'string',
|
||||
description: 'Reload target',
|
||||
default: 'services',
|
||||
choices: ['services', 'system', 'data'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'local update',
|
||||
summary: 'Stage and apply a local panel upgrade',
|
||||
local: true,
|
||||
options: {
|
||||
mirror: {
|
||||
type: 'string',
|
||||
description: 'Archive source',
|
||||
default: 'github',
|
||||
choices: ['github', 'gitee'],
|
||||
},
|
||||
'download-only': {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Prepare upgrade without replacing files or restarting services',
|
||||
},
|
||||
},
|
||||
},
|
||||
...['resetlet', 'resettfa', 'resetpwd', 'resetname'].map(
|
||||
(action): CommandSpec => ({
|
||||
name: `local ${action}`,
|
||||
summary: `Local panel ${action}`,
|
||||
arguments: ['resetpwd', 'resetname'].includes(action)
|
||||
? '<value>'
|
||||
: undefined,
|
||||
minimum: ['resetpwd', 'resetname'].includes(action) ? 1 : 0,
|
||||
maximum: ['resetpwd', 'resetname'].includes(action) ? 1 : 0,
|
||||
local: true,
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
export const aliases: Record<string, string[]> = {
|
||||
rmlog: ['local', 'rmlog'],
|
||||
extra: ['local', 'extra'],
|
||||
'repair-config': ['local', 'repair-config'],
|
||||
check: ['local', 'check'],
|
||||
bot: ['local', 'bot'],
|
||||
start: ['local', 'start'],
|
||||
reload: ['local', 'reload'],
|
||||
update: ['local', 'update'],
|
||||
resetlet: ['local', 'resetlet'],
|
||||
resettfa: ['local', 'resettfa'],
|
||||
resetpwd: ['local', 'resetpwd'],
|
||||
resetname: ['local', 'resetname'],
|
||||
};
|
||||
|
||||
export const localOptions: Record<string, OptionSpec> = {
|
||||
root: {
|
||||
type: 'string',
|
||||
description: 'Installed panel root (defaults to QL_DIR)',
|
||||
},
|
||||
'data-dir': {
|
||||
type: 'string',
|
||||
description: 'Panel data directory (defaults to QL_DATA_DIR)',
|
||||
},
|
||||
};
|
||||
|
||||
export const registry: CommandRegistry = {
|
||||
commands,
|
||||
aliases,
|
||||
options: localOptions,
|
||||
surface: 'local',
|
||||
validate(command) {
|
||||
if (command.name === 'local rmlog')
|
||||
integer(command.positionals[0]!, 0, 365000);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { createReadStream, writeSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Preserve legacy account-order output while all account processes run concurrently.
|
||||
// Spool to private files instead of retaining output buffers in the parent process.
|
||||
export async function orderedConcurrent<T>(
|
||||
root: string,
|
||||
accounts: number[],
|
||||
invoke: (account: number, output: (chunk: Buffer) => void) => Promise<T>,
|
||||
output: (chunk: Buffer) => void,
|
||||
): Promise<T[]> {
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
const directory = await fs.mkdtemp(path.join(root, '.concurrent-'));
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
accounts.map(async (account, index) => {
|
||||
const file = await fs.open(
|
||||
path.join(directory, `${index}.log`),
|
||||
'wx',
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
return await invoke(account, (chunk) => {
|
||||
let offset = 0;
|
||||
while (offset < chunk.length)
|
||||
offset += writeSync(file.fd, chunk, offset);
|
||||
});
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
// Wait for every writer even if one child could not start, then preserve its
|
||||
// peers' diagnostics before surfacing that failure.
|
||||
for (let index = 0; index < accounts.length; index++) {
|
||||
const filename = path.join(directory, `${index}.log`);
|
||||
if (!(await fs.stat(filename).catch(() => undefined))) continue;
|
||||
for await (const chunk of createReadStream(filename))
|
||||
output(chunk as Buffer);
|
||||
}
|
||||
const failure = results.find((result) => result.status === 'rejected');
|
||||
if (failure?.status === 'rejected') throw failure.reason;
|
||||
return results.map((result) => (result as PromiseFulfilledResult<T>).value);
|
||||
} finally {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { quietCommand, addFlags } from '../../shared/cli/options';
|
||||
|
||||
// The parser and Commander help share the same options. Script modes and
|
||||
// arguments after the script remain the legacy execution adapter's concern.
|
||||
export const runnerOptions = {
|
||||
root: {
|
||||
type: 'string',
|
||||
description: 'Installed panel root',
|
||||
chinese: '面板安装目录',
|
||||
},
|
||||
'data-dir': {
|
||||
type: 'string',
|
||||
description: 'Panel data directory',
|
||||
chinese: '面板数据目录',
|
||||
},
|
||||
timeout: {
|
||||
type: 'string',
|
||||
short: 'm',
|
||||
description: 'Execution timeout',
|
||||
chinese: '执行超时',
|
||||
},
|
||||
log: {
|
||||
type: 'boolean',
|
||||
short: 'l',
|
||||
description: 'Legacy compatibility flag',
|
||||
chinese: '旧命令兼容选项',
|
||||
},
|
||||
json: {
|
||||
type: 'boolean',
|
||||
description: 'JSON result; script output on stderr',
|
||||
chinese: 'JSON 结果;脚本输出写入 stderr',
|
||||
},
|
||||
help: {
|
||||
type: 'boolean',
|
||||
short: 'h',
|
||||
description: 'Show help',
|
||||
chinese: '显示帮助',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function runnerHelp(env: NodeJS.ProcessEnv): string {
|
||||
const english = env.QL_LANG === 'en';
|
||||
const command = quietCommand('ql task exec')
|
||||
.arguments('[script] [args...]')
|
||||
.description(english ? 'QingLong local task runner' : '青龙本机任务执行器');
|
||||
addFlags(
|
||||
command,
|
||||
Object.fromEntries(
|
||||
Object.entries(runnerOptions).map(([name, option]) => [
|
||||
name,
|
||||
{
|
||||
...option,
|
||||
description: english ? option.description : option.chinese,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
const notes = english
|
||||
? 'task is shorthand for ql task. Omit the script to list available JS scripts.\nCLI options precede the script; -- passes script arguments through.\nModes: <script> now; <script> conc|desi <variable> [account-ranges...].\nRemote task actions require the separate @whyour/qinglong-cli npm entry. Use task exec for scripts with reserved API action names.'
|
||||
: 'task 是 ql task 的简写。不指定脚本时列出可用 JS 脚本。\nCLI 选项放在脚本之前;-- 后的参数原样传给脚本。\n模式:<script> now;<script> conc|desi <variable> [account-ranges...]。\n远程任务操作使用独立的 @whyour/qinglong-cli npm 入口;同名脚本使用 task exec。';
|
||||
return (
|
||||
command
|
||||
.helpInformation()
|
||||
.replace(/^Usage:/m, english ? 'Usage:' : '用法:')
|
||||
.replace(/^Options:/m, english ? 'Options:' : '选项:') +
|
||||
'\n' +
|
||||
notes +
|
||||
'\n'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import path from 'node:path';
|
||||
import type { LocalContext } from '../runtime/context';
|
||||
import { runProcess } from '../runtime/process';
|
||||
|
||||
export async function taskDependencyEnvironment(
|
||||
context: LocalContext,
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
const env = { ...context.env };
|
||||
env.PREV_NODE_PATH = env.NODE_PATH || '';
|
||||
const paths = [env.NODE_PATH, context.paths.dir_dep].filter(Boolean);
|
||||
// pnpm is optional for task execution. Bound both probe time and captured output.
|
||||
try {
|
||||
const result = await runProcess('pnpm', ['root', '-g'], {
|
||||
cwd: context.root,
|
||||
env,
|
||||
capture: true,
|
||||
output: () => {},
|
||||
timeoutMs: 5000,
|
||||
graceMs: 100,
|
||||
maxCaptureBytes: 65536,
|
||||
});
|
||||
const global = result.stdout.trim();
|
||||
if (
|
||||
result.code === 0 &&
|
||||
global &&
|
||||
path.isAbsolute(global) &&
|
||||
!/[\r\n\0]/.test(global)
|
||||
) {
|
||||
env.QL_NODE_GLOBAL_PATH = global;
|
||||
paths.push(global);
|
||||
} else delete env.QL_NODE_GLOBAL_PATH;
|
||||
} catch {
|
||||
delete env.QL_NODE_GLOBAL_PATH;
|
||||
}
|
||||
env.NODE_PATH = paths.join(path.delimiter);
|
||||
return env;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
nodePreloadSource,
|
||||
pythonPreloadSource,
|
||||
esmLoaderSource,
|
||||
} from './preloadSource';
|
||||
|
||||
// Keep runtime adapters private to an execution; generated panel modules remain
|
||||
// live links so account/config updates retain the panel's existing semantics.
|
||||
export async function prepareLanguagePreload(source: string): Promise<string> {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-language-'));
|
||||
try {
|
||||
for (const name of await fs.readdir(source)) {
|
||||
if (
|
||||
[
|
||||
'sitecustomize.js',
|
||||
'sitecustomize.py',
|
||||
'esm-loader.mjs',
|
||||
'__pycache__',
|
||||
].includes(name)
|
||||
)
|
||||
continue;
|
||||
await fs.symlink(path.join(source, name), path.join(directory, name));
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(directory, 'sitecustomize.js'),
|
||||
nodePreloadSource,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(directory, 'sitecustomize.py'),
|
||||
pythonPreloadSource,
|
||||
);
|
||||
await fs.writeFile(path.join(directory, 'esm-loader.mjs'), esmLoaderSource);
|
||||
return directory;
|
||||
} catch (error) {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
// Runtime language adapters derived from the retained panel preloaders.
|
||||
// Keep these sources readable: the build embeds them without extra runtime files.
|
||||
export const nodePreloadSource: string = [
|
||||
"const { execFileSync } = require('child_process');",
|
||||
"const Module = require('module');",
|
||||
"const path = require('path');",
|
||||
"const client = require('./client.js');",
|
||||
'require(`./env.js`);',
|
||||
'',
|
||||
'// 注册 ESM loader,使全局安装的包也可通过 import 导入',
|
||||
'try {',
|
||||
" Module.register(require('node:url').pathToFileURL(path.join(__dirname, 'esm-loader.mjs')).href);",
|
||||
'} catch (_) {}',
|
||||
'',
|
||||
'function preferGlobalNodeModules() {',
|
||||
' const { QL_NODE_GLOBAL_PATH } = process.env;',
|
||||
' if (!QL_NODE_GLOBAL_PATH || Module._qlGlobalPathPatched) {',
|
||||
' return;',
|
||||
' }',
|
||||
'',
|
||||
' const originalResolveFilename = Module._resolveFilename;',
|
||||
' Module._resolveFilename = function (request, parent, isMain, options) {',
|
||||
' if (',
|
||||
' !Module.builtinModules.includes(request) &&',
|
||||
" !request.startsWith('node:') &&",
|
||||
" !request.startsWith('.') &&",
|
||||
' !path.isAbsolute(request)',
|
||||
' ) {',
|
||||
' try {',
|
||||
' return originalResolveFilename.call(this, request, parent, isMain, {',
|
||||
' ...options,',
|
||||
' paths: [QL_NODE_GLOBAL_PATH],',
|
||||
' });',
|
||||
' } catch (error) {}',
|
||||
' }',
|
||||
'',
|
||||
' return originalResolveFilename.call(this, request, parent, isMain, options);',
|
||||
' };',
|
||||
' Module._qlGlobalPathPatched = true;',
|
||||
'}',
|
||||
'',
|
||||
'function expandRange(rangeStr, max) {',
|
||||
' const tempRangeStr = rangeStr',
|
||||
' .trim()',
|
||||
' .replace(/-max/g, `-${max}`)',
|
||||
' .replace(/max-/g, `${max}-`);',
|
||||
'',
|
||||
" return tempRangeStr.split(' ').flatMap((part) => {",
|
||||
' const rangeMatch = part.match(/^(\\d+)([-~_])(\\d+)$/);',
|
||||
' if (rangeMatch) {',
|
||||
' const [, start, , end] = rangeMatch.map(Number);',
|
||||
' const step = start < end ? 1 : -1;',
|
||||
' return Array.from(',
|
||||
' { length: Math.abs(end - start) + 1 },',
|
||||
' (_, i) => start + i * step,',
|
||||
' );',
|
||||
' }',
|
||||
' return Number(part);',
|
||||
' });',
|
||||
'}',
|
||||
'',
|
||||
'function run() {',
|
||||
' const {',
|
||||
' envParam,',
|
||||
' numParam,',
|
||||
' file_task_before,',
|
||||
' file_task_before_js,',
|
||||
' dir_scripts,',
|
||||
' task_before,',
|
||||
' PREV_NODE_OPTIONS,',
|
||||
' } = process.env;',
|
||||
'',
|
||||
' try {',
|
||||
' process.env.NODE_OPTIONS = PREV_NODE_OPTIONS;',
|
||||
'',
|
||||
" const splitStr = '__sitecustomize__';",
|
||||
" const fileName = process.argv[1].replace(`${dir_scripts}/`, '');",
|
||||
' const tempFile = `/tmp/env_${process.pid}.json`;',
|
||||
'',
|
||||
' const commands = `file="$1"; output="$2"; inline="$3"; shift 3',
|
||||
'source "$file" "$@" &&',
|
||||
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
|
||||
"printf '%s\\\\n' '__sitecustomize__' &&",
|
||||
'node -e \'require("fs").writeFileSync(process.argv[1], JSON.stringify(process.env))\' "$output"`;',
|
||||
" if (task_before) console.log('执行前置命令\\n');",
|
||||
' const hookArgs = JSON.parse(process.env.QL_CLI_PRELOAD_ARGS || JSON.stringify([fileName]));',
|
||||
" const res = execFileSync('/bin/bash', ['-c', commands, 'ql-before', file_task_before, tempFile, task_before || '', ...hookArgs], {",
|
||||
" encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,",
|
||||
' });',
|
||||
'',
|
||||
' const [output] = res.split(splitStr);',
|
||||
'',
|
||||
' try {',
|
||||
" const envStr = require('fs').readFileSync(tempFile, 'utf-8');",
|
||||
' const newEnvObject = JSON.parse(envStr);',
|
||||
" if (typeof newEnvObject === 'object' && newEnvObject !== null) {",
|
||||
' for (const key in newEnvObject) {',
|
||||
' if (Object.prototype.hasOwnProperty.call(newEnvObject, key)) {',
|
||||
' process.env[key] = newEnvObject[key];',
|
||||
' }',
|
||||
' }',
|
||||
' }',
|
||||
" require('fs').unlinkSync(tempFile);",
|
||||
' } catch (jsonError) {',
|
||||
' console.log(',
|
||||
" '\\ue926 Failed to parse environment variables:',",
|
||||
' jsonError.message,',
|
||||
' );',
|
||||
' try {',
|
||||
" require('fs').unlinkSync(tempFile);",
|
||||
' } catch (e) {}',
|
||||
' }',
|
||||
'',
|
||||
' if (output) {',
|
||||
' console.log(output);',
|
||||
' }',
|
||||
' if (task_before) {',
|
||||
" console.log('执行前置命令结束\\n');",
|
||||
' }',
|
||||
' } catch (error) {',
|
||||
" if (!error.message.includes('spawnSync /bin/bash E2BIG')) {",
|
||||
' console.log(`\\ue926 run task before error: `, error);',
|
||||
' } else {',
|
||||
' // environment variable is too large',
|
||||
' }',
|
||||
' if (task_before) {',
|
||||
" console.log('执行前置命令结束\\n');",
|
||||
' }',
|
||||
' }',
|
||||
'',
|
||||
' require(file_task_before_js);',
|
||||
'',
|
||||
' if (envParam && numParam) {',
|
||||
" const array = (process.env[envParam] || '').split('&');",
|
||||
' const runArr = expandRange(numParam, array.length);',
|
||||
' const arrayRun = runArr.map((i) => array[i - 1]);',
|
||||
" const envStr = arrayRun.join('&');",
|
||||
' process.env[envParam] = envStr;',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'try {',
|
||||
' if (!process.argv[1]) {',
|
||||
' return;',
|
||||
' }',
|
||||
'',
|
||||
' preferGlobalNodeModules();',
|
||||
'',
|
||||
" process.on('SIGTERM', (code) => {",
|
||||
' process.exit(15);',
|
||||
' });',
|
||||
'',
|
||||
' run();',
|
||||
'',
|
||||
" const { sendNotify } = require('./__ql_notify__.js');",
|
||||
' global.QLAPI = {',
|
||||
' notify: sendNotify,',
|
||||
' ...client,',
|
||||
' };',
|
||||
'} catch (error) {',
|
||||
" console.log(`run builtin code error: `, error, '\\n');",
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
export const pythonPreloadSource: string = [
|
||||
'import os',
|
||||
'import re',
|
||||
'import subprocess',
|
||||
'import json',
|
||||
'import builtins',
|
||||
'import sys',
|
||||
'import env',
|
||||
'import signal',
|
||||
'from client import Client',
|
||||
'',
|
||||
'',
|
||||
'def try_parse_int(value):',
|
||||
' try:',
|
||||
' return int(value)',
|
||||
' except ValueError:',
|
||||
' return None',
|
||||
'',
|
||||
'',
|
||||
'def expand_range(range_str, max_value):',
|
||||
' temp_range_str = (',
|
||||
' range_str.strip()',
|
||||
' .replace("-max", f"-{max_value}")',
|
||||
' .replace("max-", f"{max_value}-")',
|
||||
' )',
|
||||
'',
|
||||
' result = []',
|
||||
' for part in temp_range_str.split(" "):',
|
||||
' range_match = re.match(r"^(\\d+)([-~_])(\\d+)$", part)',
|
||||
' if range_match:',
|
||||
' start, _, end = map(try_parse_int, range_match.groups())',
|
||||
' step = 1 if start < end else -1',
|
||||
' result.extend(range(start, end + step, step))',
|
||||
' else:',
|
||||
' result.append(int(part))',
|
||||
'',
|
||||
' return result',
|
||||
'',
|
||||
'',
|
||||
'def run():',
|
||||
' try:',
|
||||
' prev_pythonpath = os.getenv("PREV_PYTHONPATH", "")',
|
||||
' os.environ["PYTHONPATH"] = prev_pythonpath',
|
||||
'',
|
||||
' split_str = "__sitecustomize__"',
|
||||
' file_name = sys.argv[0].replace(f"{os.getenv(\'dir_scripts\')}/", "")',
|
||||
' ',
|
||||
' # 创建临时文件路径',
|
||||
' temp_file = f"/tmp/env_{os.getpid()}.json"',
|
||||
' ',
|
||||
' task_before = os.getenv("task_before", "")',
|
||||
' if task_before:',
|
||||
' print("执行前置命令\\n")',
|
||||
' command = """file="$1"; output="$2"; inline="$3"; shift 3',
|
||||
'source "$file" "$@" &&',
|
||||
'{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&',
|
||||
"printf '%s\\\\n' '__sitecustomize__' &&",
|
||||
'python3 -c \'import os,json,sys; f=open(sys.argv[1],"w"); json.dump(dict(os.environ),f); f.close()\' "$output"',
|
||||
'"""',
|
||||
' hook_args = json.loads(os.getenv("QL_CLI_PRELOAD_ARGS", json.dumps([file_name])))',
|
||||
' res = subprocess.check_output(["/bin/bash", "-c", command, "ql-before",',
|
||||
' os.environ["file_task_before"], temp_file, task_before, *hook_args], encoding="utf-8")',
|
||||
' output = res.split(split_str)[0]',
|
||||
'',
|
||||
' try:',
|
||||
" with open(temp_file, 'r') as f:",
|
||||
' env_json = json.loads(f.read())',
|
||||
'',
|
||||
' for key, value in env_json.items():',
|
||||
' os.environ[key] = value',
|
||||
'',
|
||||
' os.unlink(temp_file)',
|
||||
' except Exception as json_error:',
|
||||
' print(f"\\ue926 Failed to parse environment variables: {json_error}")',
|
||||
' try:',
|
||||
' os.unlink(temp_file)',
|
||||
' except:',
|
||||
' pass',
|
||||
'',
|
||||
' if len(output) > 0:',
|
||||
' print(output)',
|
||||
' if task_before:',
|
||||
' print("执行前置命令结束\\n")',
|
||||
'',
|
||||
' except subprocess.CalledProcessError as error:',
|
||||
' print(f"\\ue926 run task before error: {error}")',
|
||||
' if task_before:',
|
||||
' print("执行前置命令结束\\n")',
|
||||
' except OSError as error:',
|
||||
' error_message = str(error)',
|
||||
' if "Argument list too long" not in error_message:',
|
||||
' print(f"\\ue926 run task before error: {error}")',
|
||||
' # else:',
|
||||
' # environment variable is too large',
|
||||
' if task_before:',
|
||||
' print("执行前置命令结束\\n")',
|
||||
' except Exception as error:',
|
||||
' print(f"\\ue926 run task before error: {error}")',
|
||||
' if task_before:',
|
||||
' print("执行前置命令结束\\n")',
|
||||
'',
|
||||
' import task_before',
|
||||
'',
|
||||
' env_param = os.getenv("envParam")',
|
||||
' num_param = os.getenv("numParam")',
|
||||
'',
|
||||
' if env_param and num_param:',
|
||||
' array = (os.getenv(env_param) or "").split("&")',
|
||||
' run_arr = expand_range(num_param, len(array))',
|
||||
' array_run = [array[i - 1] for i in run_arr if i - 1 < len(array) and i > 0]',
|
||||
' env_str = "&".join(array_run)',
|
||||
' os.environ[env_param] = env_str',
|
||||
'',
|
||||
'',
|
||||
'def handle_sigterm(signum, frame):',
|
||||
' sys.exit(15)',
|
||||
'',
|
||||
'',
|
||||
'try:',
|
||||
' signal.signal(signal.SIGTERM, handle_sigterm)',
|
||||
'',
|
||||
' run()',
|
||||
'',
|
||||
' from __ql_notify__ import send',
|
||||
'',
|
||||
' class BaseApi(Client):',
|
||||
' def notify(self, *args, **kwargs):',
|
||||
' return send(*args, **kwargs)',
|
||||
'',
|
||||
' QLAPI = BaseApi()',
|
||||
' builtins.QLAPI = QLAPI',
|
||||
'except Exception as error:',
|
||||
' print(f"run builtin code error: {error}\\n")',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
// Native ESM resolution with global-first package selection.
|
||||
export const esmLoaderSource: string = [
|
||||
"import { existsSync } from 'node:fs';",
|
||||
"import { isBuiltin } from 'node:module';",
|
||||
"import { join } from 'node:path';",
|
||||
"import { pathToFileURL } from 'node:url';",
|
||||
'',
|
||||
'export function resolve(specifier, context, nextResolve) {',
|
||||
' // Leave builtins, package imports, paths and URL schemes to Node unchanged.',
|
||||
' if (isBuiltin(specifier) || /^[.#/]/.test(specifier) || /^[a-zA-Z][a-zA-Z\\d+.-]*:/.test(specifier))',
|
||||
' return nextResolve(specifier, context);',
|
||||
" const parts = specifier.split('/');",
|
||||
" const name = specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];",
|
||||
" for (const base of [process.env.QL_NODE_GLOBAL_PATH, '/usr/local/lib/node_modules'].filter(Boolean)) {",
|
||||
' const directory = join(base, name);',
|
||||
' if (!existsSync(directory)) continue;',
|
||||
" // Resolve from inside the package, retaining Node's exports, import conditions",
|
||||
' // and package-boundary checks. A virtual file need not exist for resolution.',
|
||||
' return nextResolve(specifier, {',
|
||||
' ...context,',
|
||||
" parentURL: pathToFileURL(join(directory, '__ql_resolve__.mjs')).href,",
|
||||
' });',
|
||||
' }',
|
||||
' return nextResolve(specifier, context);',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
import { runnerOptions } from './definition';
|
||||
import { standaloneHelp } from '../help/standalone';
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import { parseFlags, isArgumentError } from '../../shared/cli/options';
|
||||
import { createContext, localContext } from '../runtime/context';
|
||||
import { executeTask, type ExecutionOptions } from './taskRunner';
|
||||
import { CliError, fail } from '../../shared/errors';
|
||||
import { cancellationExitCode } from '../runtime/process';
|
||||
import { commandSignals } from '../runtime/cancellation';
|
||||
|
||||
export function parseExecution(args: string[]): {
|
||||
values: Record<string, string | boolean | undefined>;
|
||||
execution?: ExecutionOptions;
|
||||
} {
|
||||
const schema = runnerOptions;
|
||||
let parsed: ReturnType<typeof parseFlags>;
|
||||
try {
|
||||
// Commander stops parsing at the first positional argument. Everything
|
||||
// from the script onward belongs to the legacy runner, including flags.
|
||||
parsed = parseFlags(args, schema, {
|
||||
passThrough: true,
|
||||
rejectDuplicates: false,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isArgumentError(error)) throw error;
|
||||
fail(translate(process.env, '任务执行器选项无效。'), 2);
|
||||
}
|
||||
const { values, positionals: body } = parsed;
|
||||
if (values.help || !body.length) return { values };
|
||||
const separator = body.indexOf('--');
|
||||
const positional = separator >= 0 ? body.slice(0, separator) : body;
|
||||
const scriptArgs = separator >= 0 ? body.slice(separator + 1) : [];
|
||||
const mode = ['now', 'conc', 'desi'].includes(positional[1] ?? '')
|
||||
? (positional[1] as 'now' | 'conc' | 'desi')
|
||||
: 'normal';
|
||||
return {
|
||||
values,
|
||||
execution: {
|
||||
argv: mode === 'normal' ? positional : [positional[0]!],
|
||||
mode,
|
||||
scriptArgs,
|
||||
variable: mode === 'conc' || mode === 'desi' ? positional[2] : undefined,
|
||||
selection: positional.slice(3).join(' '),
|
||||
timeout: values.timeout as string | undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runnerMain(
|
||||
args = process.argv.slice(2),
|
||||
): Promise<number> {
|
||||
// Parsing can fail before returning its values. Preserve an explicit JSON
|
||||
// request in that case; successful parsing still owns the script boundary.
|
||||
let json = args
|
||||
.slice(0, args.indexOf('--') < 0 ? undefined : args.indexOf('--'))
|
||||
.includes('--json');
|
||||
const controller = new AbortController();
|
||||
const cancel = (signal: NodeJS.Signals) => controller.abort(signal);
|
||||
try {
|
||||
const parsed = parseExecution(args);
|
||||
json = parsed.values.json === true;
|
||||
if (!parsed.execution) {
|
||||
const usage = standaloneHelp('runner');
|
||||
const scripts =
|
||||
!parsed.values.help && (parsed.values.root || process.env.QL_DIR)
|
||||
? await (
|
||||
await import('./scriptInventory')
|
||||
).listTaskScripts(createContext(parsed.values))
|
||||
: undefined;
|
||||
const listing =
|
||||
scripts === undefined
|
||||
? ''
|
||||
: '\n' +
|
||||
(scripts.length
|
||||
? translate(process.env, '当前有以下脚本可以运行:') +
|
||||
'\n' +
|
||||
scripts
|
||||
.map(
|
||||
(script, index) =>
|
||||
`${index + 1}. ${script.name ?? script.file}:${
|
||||
script.file
|
||||
}`,
|
||||
)
|
||||
.join('\n')
|
||||
: translate(process.env, '暂无脚本可以执行')) +
|
||||
'\n';
|
||||
process.stdout.write(
|
||||
json
|
||||
? JSON.stringify({ code: 200, data: { help: usage, scripts } }) + '\n'
|
||||
: usage + listing,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
for (const signal of commandSignals) process.on(signal, cancel);
|
||||
const context = await localContext(
|
||||
{
|
||||
values: parsed.values,
|
||||
positionals: parsed.execution.argv,
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const result = await executeTask(context, {
|
||||
...parsed.execution,
|
||||
signal: controller.signal,
|
||||
output: (chunk) => {
|
||||
(json ? process.stderr : process.stdout).write(chunk);
|
||||
},
|
||||
});
|
||||
if (json)
|
||||
process.stdout.write(JSON.stringify({ code: 200, data: result }) + '\n');
|
||||
return result.exitCode;
|
||||
} catch (error) {
|
||||
const cancelled =
|
||||
controller.signal.aborted &&
|
||||
error instanceof Error &&
|
||||
error.name === 'AbortError' &&
|
||||
(error as NodeJS.ErrnoException).code === 'ABORT_ERR';
|
||||
const code = cancelled
|
||||
? cancellationExitCode(controller.signal)
|
||||
: error instanceof CliError
|
||||
? error.exitCode
|
||||
: 1;
|
||||
const message = cancelled
|
||||
? translate(process.env, '本机任务在加载配置时已取消。')
|
||||
: error instanceof CliError
|
||||
? error.message
|
||||
: translate(process.env, '本机任务执行失败,请检查面板环境和任务日志。');
|
||||
process.stderr.write(
|
||||
(json ? JSON.stringify({ code, message }) : message) + '\n',
|
||||
);
|
||||
return code;
|
||||
} finally {
|
||||
for (const signal of commandSignals) process.removeListener(signal, cancel);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module)
|
||||
void runnerMain().then((code) => {
|
||||
process.exitCode = code;
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createInterface } from 'node:readline';
|
||||
import type { LocalContext } from '../runtime/context';
|
||||
|
||||
// Inspect legacy top-level JS candidates without importing or executing them.
|
||||
export async function listTaskScripts(
|
||||
context: LocalContext,
|
||||
): Promise<{ file: string; name: string | null }[]> {
|
||||
const directory = context.paths.dir_scripts!;
|
||||
const entries = await fs
|
||||
.readdir(directory)
|
||||
.catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return [];
|
||||
throw error;
|
||||
});
|
||||
const scripts: { file: string; name: string | null }[] = [];
|
||||
for (const file of entries.sort()) {
|
||||
if (!file.endsWith('.js') || file === 'sendNotify.js') continue;
|
||||
const target = path.join(directory, file);
|
||||
if (!(await fs.stat(target)).isFile()) continue;
|
||||
const input = createReadStream(target, { encoding: 'utf8' });
|
||||
const lines = createInterface({ input, crlfDelay: Infinity });
|
||||
let name: string | null = null;
|
||||
try {
|
||||
for await (const line of lines) {
|
||||
const match = /\bnew\s+Env\s*\(\s*(['"])(.*?)\1/.exec(line);
|
||||
if (match) {
|
||||
name = match[2]!;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
lines.close();
|
||||
input.destroy();
|
||||
}
|
||||
scripts.push({ file, name });
|
||||
}
|
||||
return scripts;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Restore the captured state, including Bash 3.x without native BASHOPTS.
|
||||
export const shellOptionPrelude = `
|
||||
if [ "\${QL_CLI_SHELLOPTS+x}" = x ]; then
|
||||
while read -r __ql_option __ql_state; do
|
||||
case ":$QL_CLI_SHELLOPTS:" in
|
||||
*:"$__ql_option":*) ;;
|
||||
*) set +o "$__ql_option" ;;
|
||||
esac
|
||||
done < <(set -o)
|
||||
fi
|
||||
if [ "\${QL_CLI_BASHOPTS+x}" = x ]; then
|
||||
while read -r __ql_builtin __ql_state __ql_option; do
|
||||
case ":$QL_CLI_BASHOPTS:" in
|
||||
*:"$__ql_option":*) shopt -s "$__ql_option" ;;
|
||||
*) shopt -u "$__ql_option" ;;
|
||||
esac
|
||||
done < <(shopt -p)
|
||||
fi
|
||||
unset __ql_option __ql_state __ql_builtin
|
||||
`;
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { LocalContext } from '../runtime/context';
|
||||
|
||||
// Bash is the interpreter for user-owned files. The CLI supplies paths/argv as
|
||||
// separate arguments; no user argument is interpolated into this fixed bridge.
|
||||
export function shellSessionArguments(
|
||||
context: LocalContext,
|
||||
argv: string[],
|
||||
scriptArgs: string[],
|
||||
command = false,
|
||||
timeoutMarker = '',
|
||||
): string[] {
|
||||
const scriptIndex = command
|
||||
? argv.findIndex((arg) => /\.(?:js|mjs|py|pyc|sh|ts)$/.test(arg))
|
||||
: 0;
|
||||
const bridge = `
|
||||
__ql_env=$1; __ql_before=$2; __ql_after=$3; __ql_command=$4; __ql_index=$5; __ql_count=$6; __ql_timeout=$7; shift 7
|
||||
__ql_hook_args=( "\${@:1:__ql_count}" ); shift "$__ql_count"
|
||||
__ql_script_args=( "$@" )
|
||||
# Reserve an internal descriptor before sourcing user code. In particular, fd 3
|
||||
# and fd 9 remain available for user redirections and locks. Bash 3 also supports
|
||||
# this numeric descriptor, unlike Bash 4's dynamic descriptor syntax.
|
||||
if [ -n "$__ql_timeout" ]; then exec 19>&3 3>&-; fi
|
||||
__ql_after_started=false
|
||||
__ql_run_after() {
|
||||
__ql_after_started=true
|
||||
if [ -n "$__ql_timeout" ]; then printf A >&19; fi
|
||||
if [ "$__ql_nounset" = true ]; then set -u; fi
|
||||
export NODE_PATH="\${PREV_NODE_PATH:-}"
|
||||
unset QL_NODE_GLOBAL_PATH
|
||||
if [ -f "$__ql_after" ]; then . "$__ql_after" "\${__ql_hook_args[@]}"; fi
|
||||
if [ -n "\${task_after:-}" ]; then eval "\${task_after%;}"; fi
|
||||
}
|
||||
__ql_timeout_exit() {
|
||||
if [ -f "$__ql_timeout" ] && [ "$__ql_after_started" = false ]; then
|
||||
_task_exit_code=124
|
||||
__ql_run_after
|
||||
exit 124
|
||||
fi
|
||||
}
|
||||
if [ -f "$__ql_env" ]; then . "$__ql_env"; fi
|
||||
if [ -f "$__ql_before" ]; then . "$__ql_before" "\${__ql_hook_args[@]}"; fi
|
||||
if [ -n "\${task_before:-}" ]; then eval "\${task_before%;}"; fi
|
||||
__ql_nounset=false
|
||||
case $- in *u*) __ql_nounset=true; set +u;; esac
|
||||
if [ -n "\${__ql_selected_name:-}" ]; then
|
||||
printf -v "$__ql_selected_name" '%s' "$__ql_selected_value"
|
||||
export "$__ql_selected_name"
|
||||
fi
|
||||
unset __ql_selected_name __ql_selected_value
|
||||
__ql_args=( "\${__ql_hook_args[@]}" )
|
||||
if [ "$__ql_index" -lt 0 ]; then __ql_index=0; fi
|
||||
__ql_file=\${__ql_args[__ql_index]}
|
||||
__ql_resolve=false
|
||||
cd "$dir_scripts" || exit 1
|
||||
if [ -n "\${work_dir:-}" ] && [ -d "$work_dir" ]; then
|
||||
cd "$work_dir" || exit 1
|
||||
__ql_resolve=true
|
||||
elif [[ "$__ql_file" == */* ]] && [ -d "\${__ql_file%/*}" ]; then
|
||||
cd "\${__ql_file%/*}" || exit 1
|
||||
__ql_resolve=true
|
||||
fi
|
||||
if [ "$__ql_resolve" = true ] && [[ "$__ql_file" == */* ]]; then
|
||||
__ql_args[__ql_index]="./\${__ql_file##*/}"
|
||||
fi
|
||||
if [ -n "$__ql_timeout" ]; then
|
||||
# Preserve a user-owned EXIT trap. If it prevents our cleanup, the parent
|
||||
# performs the fallback after the process group has stopped.
|
||||
if [ -z "$(trap -p EXIT)" ]; then trap '__ql_timeout_exit' EXIT; fi
|
||||
printf T >&19
|
||||
fi
|
||||
if [ "$__ql_command" = true ]; then
|
||||
"\${__ql_args[@]}" "\${__ql_script_args[@]}"
|
||||
else
|
||||
. "\${__ql_args[0]}" "\${__ql_script_args[@]}"
|
||||
fi
|
||||
_task_exit_code=$?
|
||||
if [ -n "$__ql_timeout" ] && [ -f "$__ql_timeout" ]; then _task_exit_code=124; fi
|
||||
__ql_run_after
|
||||
exit "$_task_exit_code"
|
||||
`;
|
||||
return [
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
'-c',
|
||||
bridge,
|
||||
'ql-shell-session',
|
||||
context.paths.file_env!,
|
||||
context.paths.file_task_before!,
|
||||
context.paths.file_task_after!,
|
||||
String(command),
|
||||
String(scriptIndex),
|
||||
String(argv.length),
|
||||
timeoutMarker,
|
||||
...argv,
|
||||
...(command ? [] : argv.slice(1)),
|
||||
...scriptArgs,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runProcess } from '../runtime/process';
|
||||
import { fail } from '../../shared/errors';
|
||||
|
||||
// Legacy random_delay substitutes literal spaces with ERE alternatives.
|
||||
// Preserve the host grep engine and locale rather than translating ERE to JS.
|
||||
export async function matchesDelayExtension(
|
||||
filename: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const extensions = env.RandomDelayFileExtensions ?? 'js';
|
||||
if (!extensions) return true;
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-delay-'));
|
||||
try {
|
||||
const listing = path.join(directory, 'filename');
|
||||
await fs.writeFile(listing, filename + '\n', { mode: 0o600 });
|
||||
const pattern = `\\.${extensions.replace(/ /g, '$|\\.')}$`;
|
||||
const result = await runProcess(
|
||||
'grep',
|
||||
['-qE', '-e', pattern, '--', listing],
|
||||
{
|
||||
env,
|
||||
signal,
|
||||
timeoutMs: 10000,
|
||||
},
|
||||
);
|
||||
if (signal?.aborted)
|
||||
throw Object.assign(new Error(translate(env, '任务延迟过滤已取消。')), {
|
||||
name: 'AbortError',
|
||||
code: 'ABORT_ERR',
|
||||
});
|
||||
// Original `if ! grep` skips delay for both no match and invalid patterns.
|
||||
if (result.code === 0) return true;
|
||||
if (result.code === 1 || result.code === 2) return false;
|
||||
fail(translate(env, '任务延迟过滤失败(退出码 %s)。', result.code));
|
||||
} finally {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import { extendedLifecycle } from '../runtime/lifecycle';
|
||||
import fs from 'node:fs/promises';
|
||||
import { writeSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import type { LocalContext } from '../runtime/context';
|
||||
import { sourceEnvironment } from '../runtime/context';
|
||||
import { LocalApi } from '../runtime/api';
|
||||
import { cancellationExitCode, runProcess } from '../runtime/process';
|
||||
import { within } from '../runtime/files';
|
||||
import { fail } from '../../shared/errors';
|
||||
import { orderedConcurrent } from './concurrent';
|
||||
import { shellSessionArguments } from './shellSession';
|
||||
import { taskDependencyEnvironment } from './dependencies';
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import { matchesDelayExtension } from './taskDelay';
|
||||
import { prepareLanguagePreload } from './languagePreload';
|
||||
|
||||
export interface ExecutionOptions {
|
||||
argv: string[];
|
||||
scriptArgs?: string[];
|
||||
mode?: 'normal' | 'now' | 'conc' | 'desi';
|
||||
variable?: string;
|
||||
selection?: string;
|
||||
timeout?: string;
|
||||
output?: (chunk: Buffer) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
export interface ExecutionResult {
|
||||
exitCode: number;
|
||||
logPath: string;
|
||||
durationSeconds: number;
|
||||
executionId?: string;
|
||||
taskId?: number;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
export function selectedAccounts(
|
||||
selection: string,
|
||||
count: number,
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
): number[] {
|
||||
const result = new Set<number>();
|
||||
for (const item of (selection || '1-max').trim().split(/\s+/)) {
|
||||
const expanded = item.replace(/max/g, String(count));
|
||||
const match = expanded.match(/^(\d+)(?:[-~_](\d+))?$/);
|
||||
if (!match) fail(translate(environment, '账号选择格式无效。'), 2);
|
||||
const first = Number(match[1]),
|
||||
last = Number(match[2] ?? match[1]);
|
||||
if (
|
||||
![first, last].every(
|
||||
(value) => Number.isSafeInteger(value) && value >= 1 && value <= count,
|
||||
)
|
||||
)
|
||||
fail(translate(environment, '账号选择超出环境变量的账号范围。'), 2);
|
||||
const step = first <= last ? 1 : -1;
|
||||
for (let value = first; ; value += step) {
|
||||
result.add(value);
|
||||
if (value === last) break;
|
||||
}
|
||||
}
|
||||
return [...result];
|
||||
}
|
||||
|
||||
export function durationMs(
|
||||
value?: string,
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const match = value.match(/^(\d+(?:\.\d+)?)(s|m|h|d)?$/);
|
||||
if (!match)
|
||||
fail(translate(environment, '超时必须为正时长,例如 30s 或 5m。'), 2);
|
||||
const result =
|
||||
Number(match[1]) *
|
||||
{ s: 1000, m: 60000, h: 3600000, d: 86400000 }[match[2] || 's']!;
|
||||
if (!Number.isFinite(result) || result <= 0 || result > 2147483647)
|
||||
fail(translate(environment, '超时时长超出支持范围。'), 2);
|
||||
return result;
|
||||
}
|
||||
|
||||
function timestamp(now: Date): string {
|
||||
const pad = (value: number, width = 2) => String(value).padStart(width, '0');
|
||||
return [
|
||||
now.getFullYear(),
|
||||
pad(now.getMonth() + 1),
|
||||
pad(now.getDate()),
|
||||
pad(now.getHours()),
|
||||
pad(now.getMinutes()),
|
||||
pad(now.getSeconds()),
|
||||
pad(now.getMilliseconds(), 3),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
async function exists(file: string): Promise<boolean> {
|
||||
return (await fs.stat(file).catch(() => undefined))?.isFile() ?? false;
|
||||
}
|
||||
|
||||
async function taskIdentity(
|
||||
context: LocalContext,
|
||||
first: string,
|
||||
): Promise<number | undefined> {
|
||||
if (/^[1-9]\d*$/.test(context.env.ID ?? '')) return Number(context.env.ID);
|
||||
const list = await fs
|
||||
.readFile(context.paths.list_crontab_user!, 'utf8')
|
||||
.catch(() => '');
|
||||
for (const line of list.split('\n')) {
|
||||
if (!line.includes(` ${first}`)) continue;
|
||||
const id = line.match(/\bID=(?:["']?)(\d+)/)?.[1];
|
||||
if (id && Number(id) > 0) return Number(id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function prepareProgram(
|
||||
context: LocalContext,
|
||||
argv: string[],
|
||||
): Promise<{
|
||||
program: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
languagePreload: boolean;
|
||||
shellScript: boolean;
|
||||
shellCommand: boolean;
|
||||
}> {
|
||||
const extensions = /\.(?:js|mjs|py|pyc|sh|ts)$/;
|
||||
const first = argv[0]!;
|
||||
const index = argv.findIndex((arg) => extensions.test(arg));
|
||||
const candidate = index >= 0 ? argv[index]! : first;
|
||||
let cwd = context.paths.dir_scripts!;
|
||||
const workdir = context.env.work_dir;
|
||||
const explicit = workdir ? path.resolve(cwd, workdir) : undefined;
|
||||
let resolveBasename = false;
|
||||
if (
|
||||
explicit &&
|
||||
(await fs.stat(explicit).catch(() => undefined))?.isDirectory()
|
||||
) {
|
||||
cwd = explicit;
|
||||
resolveBasename = true;
|
||||
} else if (candidate.includes('/')) {
|
||||
const directory = path.dirname(path.resolve(cwd, candidate));
|
||||
if ((await fs.stat(directory).catch(() => undefined))?.isDirectory()) {
|
||||
cwd = directory;
|
||||
resolveBasename = true;
|
||||
}
|
||||
}
|
||||
const adjusted = [...argv];
|
||||
if (index >= 0 && candidate.includes('/') && resolveBasename)
|
||||
adjusted[index] = path.basename(candidate);
|
||||
const extension = path.extname(first);
|
||||
const program = (
|
||||
{
|
||||
'.js': process.execPath,
|
||||
'.mjs': process.execPath,
|
||||
'.py': 'python3',
|
||||
'.pyc': 'python3',
|
||||
'.ts': 'ts-node-transpile-only',
|
||||
'.sh': 'bash',
|
||||
} as Record<string, string>
|
||||
)[extension];
|
||||
const languagePreload =
|
||||
!!program && extension !== '.sh' && (await exists(context.paths.file_env!));
|
||||
const shellScript = extension === '.sh';
|
||||
if (shellScript)
|
||||
return {
|
||||
program: 'bash',
|
||||
args: [
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
'-c',
|
||||
'file="$1"; shift; . "$file" "$@"',
|
||||
'ql-script',
|
||||
path.resolve(cwd, adjusted[0]!),
|
||||
...adjusted.slice(1),
|
||||
],
|
||||
cwd,
|
||||
languagePreload,
|
||||
shellScript,
|
||||
shellCommand: false,
|
||||
};
|
||||
const executable =
|
||||
index < 0 && resolveBasename && first.includes('/')
|
||||
? path.resolve(cwd, path.basename(first))
|
||||
: first;
|
||||
return {
|
||||
program: program ?? 'bash',
|
||||
// Bash resolves exported user functions and builtins as well as PATH commands.
|
||||
// Keep command/arguments as argv data, never interpolate or eval them.
|
||||
args: program
|
||||
? adjusted
|
||||
: ['--noprofile', '--norc', '-c', '"$@"', 'ql-command', executable, ...adjusted.slice(1)],
|
||||
cwd,
|
||||
languagePreload,
|
||||
shellScript,
|
||||
shellCommand: !program,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeTask(
|
||||
context: LocalContext,
|
||||
options: ExecutionOptions,
|
||||
): Promise<ExecutionResult> {
|
||||
if (!options.argv.length)
|
||||
fail(translate(context.env, '必须指定脚本或可执行程序。'), 2);
|
||||
const start = Date.now();
|
||||
const started = Math.floor(start / 1000);
|
||||
const first = options.argv[0]!;
|
||||
const taskId = await taskIdentity(context, first);
|
||||
const extended = await extendedLifecycle(context);
|
||||
const executionId =
|
||||
extended && context.env.QL_EXECUTION_ORIGIN === 'scheduled_system'
|
||||
? `legacy-system:${started}:${randomUUID()}`
|
||||
: undefined;
|
||||
const fileName = path.basename(first, path.extname(first));
|
||||
const parent = first.includes('/') ? path.basename(path.dirname(first)) : '';
|
||||
const folder =
|
||||
context.env.log_name ||
|
||||
`${parent ? parent + '_' : ''}${fileName}${taskId ? '_' + taskId : ''}`;
|
||||
const logPath =
|
||||
folder === '/dev/null'
|
||||
? '/dev/null'
|
||||
: context.env.real_log_path ||
|
||||
path.join(folder, `${timestamp(new Date(start))}.log`);
|
||||
const fullLog =
|
||||
logPath === '/dev/null'
|
||||
? logPath
|
||||
: within(context.paths.dir_log!, logPath, context.env);
|
||||
const discardOutput = folder === '/dev/null';
|
||||
const persistLog = context.env.real_time !== 'true' && !discardOutput;
|
||||
if (persistLog) await fs.mkdir(path.dirname(fullLog), { recursive: true });
|
||||
const log = persistLog ? await fs.open(fullLog, 'a', 0o600) : undefined;
|
||||
const output = (chunk: Buffer) => {
|
||||
if (discardOutput) return;
|
||||
// Regular-file writes apply backpressure without retaining task output in memory.
|
||||
let offset = 0;
|
||||
while (log && offset < chunk.length)
|
||||
offset += writeSync(log.fd, chunk, offset);
|
||||
if (context.env.no_tee !== 'true' || context.env.real_time === 'true')
|
||||
(options.output ?? ((value) => process.stderr.write(value)))(chunk);
|
||||
};
|
||||
const stderrOutput = discardOutput
|
||||
? options.output ??
|
||||
((value: Buffer) => {
|
||||
process.stderr.write(value);
|
||||
})
|
||||
: undefined;
|
||||
const api = new LocalApi(context);
|
||||
const report = async (final: boolean, code = 0, duration = 0) => {
|
||||
if (!taskId) return;
|
||||
try {
|
||||
await api.call('crons/status', 'PUT', {
|
||||
ids: [taskId],
|
||||
status: final ? '1' : '0',
|
||||
pid: String(process.pid),
|
||||
log_path: logPath,
|
||||
last_execution_time: started,
|
||||
last_running_time: duration,
|
||||
...(final && extended ? { exit_code: code } : {}),
|
||||
...(executionId ? { execution_id: executionId } : {}),
|
||||
});
|
||||
} catch {
|
||||
output(
|
||||
Buffer.from(
|
||||
translate(context.env, '任务状态上报失败,请检查本机面板。\n'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (final && extended) {
|
||||
try {
|
||||
await api.call('dashboard/record', 'POST', {
|
||||
ref_id: taskId,
|
||||
code,
|
||||
elapsed: duration,
|
||||
});
|
||||
} catch {
|
||||
output(
|
||||
Buffer.from(
|
||||
translate(context.env, '任务统计上报失败,请检查本机面板。\n'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
let exitCode = 1,
|
||||
timedOut = false;
|
||||
let languageDirectory: string | undefined;
|
||||
try {
|
||||
await report(false);
|
||||
output(
|
||||
Buffer.from(
|
||||
translate(
|
||||
context.env,
|
||||
'## 开始执行... %s\n',
|
||||
new Date(start).toISOString(),
|
||||
),
|
||||
),
|
||||
);
|
||||
let program = await prepareProgram(context, options.argv);
|
||||
const sharedShell =
|
||||
(program.shellScript || program.shellCommand) &&
|
||||
(!options.mode || ['normal', 'now', 'desi'].includes(options.mode));
|
||||
let env = await taskDependencyEnvironment(context);
|
||||
// The panel preloads own generated environment variables and language hooks.
|
||||
if (program.languagePreload) {
|
||||
languageDirectory = await prepareLanguagePreload(
|
||||
context.paths.dir_preload!,
|
||||
);
|
||||
env.QL_CLI_PRELOAD_ARGS = JSON.stringify([
|
||||
...options.argv,
|
||||
...(options.scriptArgs ?? []),
|
||||
]);
|
||||
env.PREV_NODE_OPTIONS = env.NODE_OPTIONS || '';
|
||||
env.PREV_PYTHONPATH = env.PYTHONPATH || '';
|
||||
if (/\.(?:js|mjs|ts)$/.test(first))
|
||||
env.NODE_OPTIONS = `--require ${JSON.stringify(
|
||||
path.join(languageDirectory, 'sitecustomize.js'),
|
||||
)} ${env.NODE_OPTIONS || ''}`;
|
||||
else
|
||||
env.PYTHONPATH = [
|
||||
languageDirectory,
|
||||
context.paths.dir_config,
|
||||
env.PYTHONPATH,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(path.delimiter);
|
||||
} else if (!sharedShell) {
|
||||
env = await sourceEnvironment(
|
||||
env,
|
||||
[context.paths.file_env!, context.paths.file_task_before!],
|
||||
options.argv,
|
||||
{
|
||||
command:
|
||||
'if [ -n "${task_before:-}" ]; then eval "${task_before%;}"; fi',
|
||||
signal: options.signal,
|
||||
output,
|
||||
stderrOutput,
|
||||
},
|
||||
);
|
||||
program = await prepareProgram({ ...context, env }, options.argv);
|
||||
}
|
||||
const mode = options.mode ?? 'normal';
|
||||
let accounts: number[] = [];
|
||||
let allAccounts: string[] = [];
|
||||
if (mode === 'conc' || mode === 'desi') {
|
||||
if (
|
||||
!options.variable ||
|
||||
!/^[A-Za-z_][A-Za-z0-9_]*$/.test(options.variable)
|
||||
)
|
||||
fail(translate(env, '必须指定有效的账号环境变量名。'), 2);
|
||||
const accountEnv =
|
||||
program.languagePreload || sharedShell
|
||||
? await sourceEnvironment(
|
||||
context.env,
|
||||
[context.paths.file_env!],
|
||||
[],
|
||||
{ signal: options.signal },
|
||||
)
|
||||
: env;
|
||||
allAccounts = (accountEnv[options.variable] ?? '').split('&');
|
||||
accounts = selectedAccounts(
|
||||
options.selection ?? '1-max',
|
||||
allAccounts.length,
|
||||
env,
|
||||
);
|
||||
}
|
||||
const maxDelay = Number(env.RandomDelay || 0);
|
||||
const ignored = (env.RandomDelayIgnoredMinutes ?? '0 30')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map(Number);
|
||||
if (
|
||||
mode === 'normal' &&
|
||||
options.argv.length === 1 &&
|
||||
/\.(?:js|mjs|py|pyc|sh|ts)$/.test(first) &&
|
||||
context.env.real_time !== 'true' &&
|
||||
context.env.no_delay !== 'true' &&
|
||||
Number.isSafeInteger(maxDelay) &&
|
||||
maxDelay > 0 &&
|
||||
!ignored.includes(new Date().getMinutes()) &&
|
||||
(await matchesDelayExtension(first, env, options.signal))
|
||||
) {
|
||||
const seconds = randomInt(1, maxDelay + 1);
|
||||
output(
|
||||
Buffer.from(
|
||||
translate(
|
||||
env,
|
||||
'任务随机延迟 %s 秒,将于 %s 开始,配置文件参数 RandomDelay 置空可取消延迟\n',
|
||||
seconds,
|
||||
new Date(Date.now() + seconds * 1000).toISOString(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await delay(seconds * 1000, undefined, { signal: options.signal });
|
||||
}
|
||||
const invoke = async (selection?: number[], sink = output) => {
|
||||
const childEnv = { ...env };
|
||||
delete childEnv.__ql_selected_name;
|
||||
delete childEnv.__ql_selected_value;
|
||||
if (selection && options.variable) {
|
||||
if (program.languagePreload) {
|
||||
childEnv.envParam = options.variable;
|
||||
childEnv.numParam = selection.join(' ');
|
||||
} else {
|
||||
childEnv[options.variable] = selection
|
||||
.map((number) => allAccounts[number - 1]!)
|
||||
.join('&');
|
||||
if (sharedShell) {
|
||||
childEnv.__ql_selected_name = options.variable;
|
||||
childEnv.__ql_selected_value = childEnv[options.variable];
|
||||
}
|
||||
}
|
||||
}
|
||||
const timeoutMs = durationMs(options.timeout ?? env.CommandTimeoutTime, env);
|
||||
const timeoutDirectory = sharedShell && timeoutMs
|
||||
? await fs.mkdtemp(path.join(os.tmpdir(), 'ql-task-timeout-'))
|
||||
: undefined;
|
||||
const timeoutMarker = timeoutDirectory ? path.join(timeoutDirectory, 'expired') : '';
|
||||
try {
|
||||
return await runProcess(
|
||||
program.program,
|
||||
sharedShell
|
||||
? shellSessionArguments(
|
||||
context,
|
||||
options.argv,
|
||||
options.scriptArgs ?? [],
|
||||
program.shellCommand,
|
||||
timeoutMarker,
|
||||
)
|
||||
: [...program.args, ...(options.scriptArgs ?? [])],
|
||||
{
|
||||
cwd: sharedShell ? context.root : program.cwd,
|
||||
env: childEnv,
|
||||
// Preserve the legacy task's pipe/file input in every execution mode.
|
||||
stdin: 'inherit',
|
||||
output: sink,
|
||||
// Legacy concurrent children merge stderr into their per-account log.
|
||||
stderrOutput: mode === 'conc' ? undefined : stderrOutput,
|
||||
timeoutMs,
|
||||
...(timeoutMarker ? { timeoutControl: { marker: timeoutMarker } } : {}),
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (timeoutDirectory) await fs.rm(timeoutDirectory, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
const results =
|
||||
mode === 'conc'
|
||||
? await orderedConcurrent(
|
||||
context.paths.dir_list_tmp!,
|
||||
accounts,
|
||||
(account, sink) => invoke([account], sink),
|
||||
output,
|
||||
)
|
||||
: [await invoke(mode === 'desi' ? accounts : undefined)];
|
||||
exitCode = results.find((result) => result.code !== 0)?.code ?? 0;
|
||||
timedOut = results.some((result) => result.timedOut);
|
||||
if (!sharedShell || results.some((result) => result.timedOut && !result.cleanupStarted)) {
|
||||
// JS/Python pre-task hooks run inside the preloader; after hooks run once per task.
|
||||
const afterEnv: NodeJS.ProcessEnv = {
|
||||
...env,
|
||||
_task_exit_code: String(exitCode),
|
||||
};
|
||||
afterEnv.NODE_PATH = env.PREV_NODE_PATH;
|
||||
delete afterEnv.QL_NODE_GLOBAL_PATH;
|
||||
if (program.languagePreload) {
|
||||
afterEnv.NODE_OPTIONS = env.PREV_NODE_OPTIONS;
|
||||
afterEnv.PYTHONPATH = env.PREV_PYTHONPATH;
|
||||
}
|
||||
await runProcess(
|
||||
'bash',
|
||||
[
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
'-c',
|
||||
'file="$1"; shift; if [ -f "$file" ]; then . "$file" "$@"; fi; if [ -n "${task_after:-}" ]; then eval "${task_after%;}"; fi',
|
||||
'ql-after',
|
||||
context.paths.file_task_after!,
|
||||
...options.argv,
|
||||
],
|
||||
{
|
||||
cwd: program.cwd,
|
||||
env: afterEnv,
|
||||
output,
|
||||
stderrOutput,
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
if (options.signal?.aborted)
|
||||
exitCode = cancellationExitCode(options.signal);
|
||||
}
|
||||
} catch (error) {
|
||||
// The timer rejects before any task subprocess exists. Preserve the same
|
||||
// cancellation result and lifecycle reporting as an interrupted process.
|
||||
if (
|
||||
options.signal?.aborted &&
|
||||
error instanceof Error &&
|
||||
error.name === 'AbortError' &&
|
||||
(error as NodeJS.ErrnoException).code === 'ABORT_ERR'
|
||||
) {
|
||||
exitCode = cancellationExitCode(options.signal);
|
||||
} else throw error;
|
||||
} finally {
|
||||
if (languageDirectory)
|
||||
await fs.rm(languageDirectory, { recursive: true, force: true });
|
||||
const durationSeconds = Math.max(
|
||||
1,
|
||||
Math.floor((Date.now() - start) / 1000),
|
||||
);
|
||||
await report(true, exitCode, durationSeconds);
|
||||
output(
|
||||
Buffer.from(
|
||||
exitCode === 0
|
||||
? translate(
|
||||
context.env,
|
||||
'\n## 完成 ✅... %s 耗时 %s 秒%s',
|
||||
new Date().toISOString(),
|
||||
durationSeconds,
|
||||
' \n',
|
||||
)
|
||||
: translate(
|
||||
context.env,
|
||||
'\n## 失败 ❌(退出码 %s)... %s 耗时 %s 秒%s',
|
||||
exitCode,
|
||||
new Date().toISOString(),
|
||||
durationSeconds,
|
||||
' \n',
|
||||
),
|
||||
),
|
||||
);
|
||||
await log?.close();
|
||||
}
|
||||
return {
|
||||
exitCode,
|
||||
logPath,
|
||||
durationSeconds: Math.max(1, Math.floor((Date.now() - start) / 1000)),
|
||||
executionId,
|
||||
taskId,
|
||||
timedOut,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { runnerHelp } from '../execution/definition';
|
||||
type Entry = 'runner' | 'compat' | 'worker' | 'startup';
|
||||
const english: Record<Exclude<Entry, 'runner'>, string> = {
|
||||
startup:
|
||||
'Usage: qinglong-cli [reload] [--root PATH] [--data-dir PATH] [--no-startup] [--json]\nCompatibility entry for the legacy qinglong host startup command.\nNo mode: install prerequisites and start services. reload: restart without installing dependencies or registering host startup.\nQL_DIR and QL_DATA_DIR provide environment defaults. Use ql start --help for all options.\n',
|
||||
compat:
|
||||
'Usage: ql-compat [-l] <update|reload|repo|raw|rmlog|extra|bot|check|resetlet|resettfa|resetpwd|resetname> [legacy arguments]\nLocal compatibility adapter; requires QL_DIR. Select it for installed ql via QL_CLI_ROOT.\nUse the separate @whyour/qinglong-cli npm entry for authenticated panel management, ql task exec for local task execution.\n',
|
||||
worker:
|
||||
'Usage: ql repo|raw <url> [legacy subscription arguments]\nInternal local executor; manage panel subscriptions with the separate @whyour/qinglong-cli npm entry.\nRequires QL_DIR and optional QL_DATA_DIR/SUB_ID.\n',
|
||||
};
|
||||
const chinese: Record<Exclude<Entry, 'runner'>, string> = {
|
||||
startup:
|
||||
'用法:qinglong-cli [reload] [--root PATH] [--data-dir PATH] [--no-startup] [--json]\n兼容原 qinglong 宿主机启动命令。\n无模式参数:安装依赖并启动服务;reload:跳过依赖安装及开机注册后重载。\n可通过 QL_DIR、QL_DATA_DIR 设置目录。完整选项见 ql start --help。\n',
|
||||
compat:
|
||||
'用法:ql-compat [-l] <update|reload|repo|raw|rmlog|extra|bot|check|resetlet|resettfa|resetpwd|resetname> [legacy arguments]\n本机兼容适配器,需要 QL_DIR。可通过 QL_CLI_ROOT 将其选为已安装的 ql 入口。\n面板认证管理使用独立的 @whyour/qinglong-cli npm 入口,本机任务执行使用 ql task exec。\n',
|
||||
worker:
|
||||
'用法:ql repo|raw <url> [legacy subscription arguments]\n内部本机执行器;面板订阅管理使用独立的 @whyour/qinglong-cli npm 入口。\n需要 QL_DIR,可选 QL_DATA_DIR/SUB_ID。\n',
|
||||
};
|
||||
export function standaloneHelp(
|
||||
entry: Entry,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
if (entry === 'runner') return runnerHelp(env);
|
||||
return (env.QL_LANG === 'en' ? english : chinese)[entry];
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import fs from 'node:fs/promises';
|
||||
import syncFs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const begin = '# BEGIN QINGLONG CLI ENVIRONMENT\n';
|
||||
const end = '# END QINGLONG CLI ENVIRONMENT\n';
|
||||
const schedule =
|
||||
/^(\s*(?:@[a-z]+|(?:[\w*\/,?#-]+[ \t]+){4}[\w*\/,?#-]+)[ \t]+)(.*)$/gm;
|
||||
const marker = '// QingLong CLI crontab bridge';
|
||||
interface CronInstallation {
|
||||
language?: string;
|
||||
executable: string;
|
||||
sourceFile: string;
|
||||
environment: Record<string, string>;
|
||||
}
|
||||
|
||||
export function runCrontab(
|
||||
args: string[],
|
||||
installation: CronInstallation,
|
||||
): number {
|
||||
const language = { QL_LANG: process.env.QL_LANG ?? installation.language };
|
||||
let directory: string | undefined;
|
||||
try {
|
||||
let forwarded = args;
|
||||
if (
|
||||
args.length === 1 &&
|
||||
path.resolve(args[0]!) === installation.sourceFile
|
||||
) {
|
||||
const prefix =
|
||||
'export ' +
|
||||
Object.entries(installation.environment)
|
||||
.map(([key, value]) => {
|
||||
if (!/^[A-Z_]+$/.test(key) || /[\r\n\0%]/.test(value))
|
||||
throw new Error(translate(language, 'cron 环境变量值无效。'));
|
||||
return `${key}='${value.replace(/'/g, "'\\''")}'`;
|
||||
})
|
||||
.join(' ') +
|
||||
'; ';
|
||||
const source = syncFs.readFileSync(installation.sourceFile, 'utf8');
|
||||
const header =
|
||||
begin + '# ' + Buffer.from(prefix).toString('base64') + '\n' + end;
|
||||
directory = syncFs.mkdtempSync(path.join(os.tmpdir(), 'ql-crontab-'));
|
||||
const file = path.join(directory, 'table');
|
||||
syncFs.writeFileSync(
|
||||
file,
|
||||
header +
|
||||
source.replace(schedule, (line, timing, command) =>
|
||||
line.trimStart().startsWith('#')
|
||||
? line
|
||||
: `${timing}${prefix}${command}`,
|
||||
),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
forwarded = [file];
|
||||
}
|
||||
const child = spawnSync(installation.executable, forwarded, {
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
timeout: 15000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
let stdout = child.stdout || Buffer.alloc(0);
|
||||
if (args.length === 1 && args[0] === '-l') {
|
||||
const contents = stdout.toString('utf8');
|
||||
if (contents.startsWith(begin) && contents.includes(end)) {
|
||||
const headerEnd = contents.indexOf(end);
|
||||
const encoded = contents.slice(begin.length, headerEnd).trim();
|
||||
if (encoded.startsWith('# ')) {
|
||||
const prefix = Buffer.from(encoded.slice(2), 'base64').toString(
|
||||
'utf8',
|
||||
);
|
||||
stdout = Buffer.from(
|
||||
contents
|
||||
.slice(headerEnd + end.length)
|
||||
.replace(schedule, (line, timing, command) =>
|
||||
prefix && command.startsWith(prefix)
|
||||
? timing + command.slice(prefix.length)
|
||||
: line,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
process.stdout.write(stdout);
|
||||
if (child.stderr) process.stderr.write(child.stderr);
|
||||
if (child.error) throw child.error;
|
||||
return (
|
||||
child.status ??
|
||||
(child.signal ? 128 + os.constants.signals[child.signal] : 1)
|
||||
);
|
||||
} catch {
|
||||
process.stderr.write(
|
||||
translate(
|
||||
language,
|
||||
'无法安装或读取面板 crontab,请检查环境路径和系统 cron 工具。',
|
||||
) + '\n',
|
||||
);
|
||||
return 1;
|
||||
} finally {
|
||||
if (directory) syncFs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function installCronEntrypoint(
|
||||
commandDir: string,
|
||||
cliRoot: string,
|
||||
sourceFile: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): Promise<void> {
|
||||
if (![commandDir, cliRoot, sourceFile].every(path.isAbsolute))
|
||||
throw new Error(translate(environment, 'cron 安装路径必须为绝对路径。'));
|
||||
let executable: string | undefined;
|
||||
for (const directory of (environment.PATH || '').split(path.delimiter)) {
|
||||
if (!directory || path.resolve(directory) === path.resolve(commandDir))
|
||||
continue;
|
||||
const candidate = path.join(directory, 'crontab');
|
||||
try {
|
||||
await fs.access(candidate, syncFs.constants.X_OK);
|
||||
executable = path.resolve(candidate);
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
// Node-only installations need not have a system cron utility.
|
||||
if (!executable) return;
|
||||
const target = path.join(commandDir, 'crontab');
|
||||
try {
|
||||
if (!(await fs.readFile(target, 'utf8')).includes(marker))
|
||||
throw new Error(
|
||||
translate(environment, '拒绝覆盖不属于此 CLI 的用户 crontab 命令。'),
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
const selected: Record<string, string> = {};
|
||||
for (const key of [
|
||||
'QL_DIR',
|
||||
'QL_DATA_DIR',
|
||||
'QL_CLI_ROOT',
|
||||
'PYTHONPATH',
|
||||
'NODE_PATH',
|
||||
])
|
||||
if (environment[key] !== undefined) selected[key] = environment[key]!;
|
||||
selected.PATH = [
|
||||
commandDir,
|
||||
...(environment.PATH || '')
|
||||
.split(path.delimiter)
|
||||
.filter((value) => value && value !== commandDir),
|
||||
].join(path.delimiter);
|
||||
const installation: CronInstallation = {
|
||||
language: environment.QL_LANG,
|
||||
executable,
|
||||
sourceFile: path.resolve(sourceFile),
|
||||
environment: selected,
|
||||
};
|
||||
const module = path.join(cliRoot, 'dist/local/cronEntrypoint.js');
|
||||
await fs.access(module);
|
||||
await fs.mkdir(commandDir, { recursive: true });
|
||||
const temporary = `${target}.${process.pid}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(
|
||||
temporary,
|
||||
`#!${
|
||||
process.execPath
|
||||
}\n${marker}\nprocess.exitCode=require(${JSON.stringify(
|
||||
module,
|
||||
)}).runCrontab(process.argv.slice(2),${JSON.stringify(installation)});\n`,
|
||||
{ flag: 'wx', mode: 0o755 },
|
||||
);
|
||||
await fs.rename(temporary, target);
|
||||
} finally {
|
||||
await fs.rm(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
// Called by the 2.x startup loader only after explicit QL_CLI_ROOT selection.
|
||||
export async function installCliEntrypoints(
|
||||
commandDir: string,
|
||||
cliRoot: string,
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<void> {
|
||||
if (!path.isAbsolute(commandDir) || !path.isAbsolute(cliRoot))
|
||||
throw new Error(translate(environment, 'CLI 安装路径必须为绝对路径。'));
|
||||
const entries = [
|
||||
{ name: 'ql', module: 'ql.js', method: 'qlMain' },
|
||||
{ name: 'task', module: 'task.js', method: 'taskMain' },
|
||||
];
|
||||
// Validate the entire selected installation before replacing either command.
|
||||
for (const entry of entries) {
|
||||
const filename = path.join(cliRoot, 'dist', entry.module);
|
||||
if (!(await fs.stat(filename)).isFile())
|
||||
throw new Error(translate(environment, 'CLI 入口必须为普通文件。'));
|
||||
}
|
||||
await fs.mkdir(commandDir, { recursive: true });
|
||||
for (const entry of entries) {
|
||||
const target = path.join(commandDir, entry.name);
|
||||
const temp = `${target}.${randomUUID()}.tmp`;
|
||||
const modulePath = path.join(cliRoot, 'dist', entry.module);
|
||||
// Base64 transports the path as data; it cannot terminate a JS string.
|
||||
const encodedPath = Buffer.from(modulePath, 'utf8').toString('base64');
|
||||
const source = `#!${process.execPath}\n'use strict';\nrequire(Buffer.from('${encodedPath}', 'base64').toString('utf8')).${entry.method}().then(code => { process.exitCode = code; }).catch(() => { process.stderr.write(process.env.QL_LANG === 'en' ? 'CLI invocation failed.\\n' : 'CLI 调用失败。\\n'); process.exitCode = 1; });\n`;
|
||||
try {
|
||||
await fs.writeFile(temp, source, { flag: 'wx', mode: 0o755 });
|
||||
await fs.rename(temp, target);
|
||||
} finally {
|
||||
await fs.rm(temp, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { LocalContext } from '../runtime/context';
|
||||
import { checkedProcess, runProcess } from '../runtime/process';
|
||||
import { repairConfiguration, startPanel } from './operator';
|
||||
import { operatingSystem } from './bot';
|
||||
import { fail } from '../../shared/errors';
|
||||
import { registerHostServices } from '../runtime/hostServices';
|
||||
|
||||
export function bootstrapPackages(
|
||||
os: string,
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
): {
|
||||
program: string;
|
||||
calls: string[][];
|
||||
} {
|
||||
if (os === 'alpine')
|
||||
return {
|
||||
program: 'apk',
|
||||
calls: [
|
||||
['update'],
|
||||
[
|
||||
'add',
|
||||
'-f',
|
||||
'bash',
|
||||
'coreutils',
|
||||
'git',
|
||||
'curl',
|
||||
'wget',
|
||||
'tzdata',
|
||||
'perl',
|
||||
'openssl',
|
||||
'jq',
|
||||
'nginx',
|
||||
'openssh',
|
||||
'procps',
|
||||
'netcat-openbsd',
|
||||
],
|
||||
],
|
||||
};
|
||||
if (os === 'debian' || os === 'ubuntu')
|
||||
return {
|
||||
program: 'apt-get',
|
||||
calls: [
|
||||
['update'],
|
||||
[
|
||||
'install',
|
||||
'-y',
|
||||
'git',
|
||||
'curl',
|
||||
'wget',
|
||||
'tzdata',
|
||||
'perl',
|
||||
'openssl',
|
||||
'jq',
|
||||
'nginx',
|
||||
'procps',
|
||||
'netcat-openbsd',
|
||||
'openssh-client',
|
||||
],
|
||||
],
|
||||
};
|
||||
fail(
|
||||
translate(environment, '不支持此部署操作系统:%s。', os || 'unknown'),
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export function runtimeEnvironment(
|
||||
context: LocalContext,
|
||||
pythonVersion: string,
|
||||
): NodeJS.ProcessEnv {
|
||||
if (!/^\d+\.\d+$/.test(pythonVersion))
|
||||
fail(translate(context.env, 'Python 返回了无效版本。'));
|
||||
const node = path.join(context.data, 'dep_cache/node');
|
||||
const python = path.join(context.data, 'dep_cache/python3');
|
||||
return {
|
||||
...context.env,
|
||||
PYTHON_SHORT_VERSION: pythonVersion,
|
||||
PNPM_HOME: node,
|
||||
PYTHON_HOME: python,
|
||||
PYTHONUSERBASE: python,
|
||||
PIP_CACHE_DIR: path.join(python, 'pip'),
|
||||
PATH: [
|
||||
context.env.PATH,
|
||||
'/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
node,
|
||||
path.join(python, 'bin'),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(path.delimiter),
|
||||
NODE_PATH: [
|
||||
'/usr/local/bin',
|
||||
'/usr/local/lib/node_modules',
|
||||
path.join(node, 'global/5/node_modules'),
|
||||
].join(path.delimiter),
|
||||
PYTHONPATH: [
|
||||
python,
|
||||
path.join(python, `lib/python${pythonVersion}`),
|
||||
path.join(python, `lib/python${pythonVersion}/site-packages`),
|
||||
].join(path.delimiter),
|
||||
};
|
||||
}
|
||||
|
||||
export async function launchStartupHook(
|
||||
context: LocalContext,
|
||||
action: 'bot' | 'extra',
|
||||
): Promise<number> {
|
||||
const log = await fs.open(
|
||||
path.join(context.paths.dir_log!, `${action}.log`),
|
||||
'w',
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
path.resolve(__dirname, '../../entrypoints/admin.js'),
|
||||
action,
|
||||
'--root',
|
||||
context.root,
|
||||
'--data-dir',
|
||||
context.data,
|
||||
'--json',
|
||||
],
|
||||
{
|
||||
cwd: context.root,
|
||||
env: context.env,
|
||||
detached: true,
|
||||
stdio: ['ignore', log.fd, log.fd],
|
||||
},
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once('spawn', resolve);
|
||||
child.once('error', reject);
|
||||
});
|
||||
child.unref();
|
||||
return child.pid!;
|
||||
} finally {
|
||||
await log.close();
|
||||
}
|
||||
}
|
||||
|
||||
export interface BootstrapSystem {
|
||||
nginxConfig: string;
|
||||
nginxIncludes: string;
|
||||
nginxRun: string;
|
||||
background: typeof launchStartupHook;
|
||||
registerServices?: typeof registerHostServices;
|
||||
}
|
||||
const systemDefaults: BootstrapSystem = {
|
||||
nginxConfig: '/etc/nginx/nginx.conf',
|
||||
nginxIncludes: '/etc/nginx/conf.d',
|
||||
nginxRun: '/run/nginx',
|
||||
background: launchStartupHook,
|
||||
};
|
||||
|
||||
export async function bootstrapPanel(
|
||||
context: LocalContext,
|
||||
reload = false,
|
||||
system: BootstrapSystem = systemDefaults,
|
||||
startupOptions: { registerStartup?: boolean } = {},
|
||||
): Promise<unknown> {
|
||||
const registerStartup = startupOptions.registerStartup !== false;
|
||||
if (path.basename(context.data) !== 'data')
|
||||
fail(translate(context.env, '启动需要以 /data 结尾的绝对数据目录。'), 2);
|
||||
const os = await operatingSystem(context);
|
||||
const packages = bootstrapPackages(os, context.env);
|
||||
if (!reload) {
|
||||
const root = process.getuid?.() === 0;
|
||||
for (const args of packages.calls)
|
||||
await checkedProcess(
|
||||
root ? packages.program : 'sudo',
|
||||
root ? args : [packages.program, ...args],
|
||||
{ cwd: context.root, env: context.env },
|
||||
);
|
||||
await checkedProcess(
|
||||
'npm',
|
||||
['install', '-g', 'pnpm@8.3.1', 'pm2', 'ts-node', 'typescript@5'],
|
||||
{ cwd: context.root, env: context.env },
|
||||
);
|
||||
}
|
||||
const python = await checkedProcess(
|
||||
'python3',
|
||||
[
|
||||
'-c',
|
||||
'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")',
|
||||
],
|
||||
{ cwd: context.root, env: context.env, capture: true },
|
||||
);
|
||||
const runtime = {
|
||||
...context,
|
||||
env: runtimeEnvironment(context, python.stdout.trim()),
|
||||
};
|
||||
if (!reload)
|
||||
await checkedProcess(
|
||||
'pip3',
|
||||
['install', '--prefix', runtime.env.PYTHON_HOME!, 'requests'],
|
||||
{ cwd: runtime.root, env: runtime.env },
|
||||
);
|
||||
try {
|
||||
await fs.copyFile(
|
||||
path.join(runtime.root, '.env.example'),
|
||||
path.join(runtime.root, '.env'),
|
||||
fs.constants.COPYFILE_EXCL,
|
||||
);
|
||||
await fs.chmod(path.join(runtime.root, '.env'), 0o600);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
||||
}
|
||||
const restored = await repairConfiguration(runtime);
|
||||
await fs.mkdir(system.nginxIncludes, { recursive: true });
|
||||
await fs.mkdir(system.nginxRun, { recursive: true });
|
||||
const options = { cwd: runtime.root, env: runtime.env };
|
||||
await checkedProcess('pm2', ['l'], options);
|
||||
const nginx = await runProcess(
|
||||
'nginx',
|
||||
['-c', system.nginxConfig, '-s', 'reload'],
|
||||
options,
|
||||
);
|
||||
if (nginx.code !== 0)
|
||||
await checkedProcess('nginx', ['-c', system.nginxConfig], options);
|
||||
const service = await startPanel(runtime);
|
||||
const background: { action: string; pid: number }[] = [];
|
||||
if (!reload) {
|
||||
if (runtime.env.AutoStartBot === 'true')
|
||||
background.push({
|
||||
action: 'bot',
|
||||
pid: await system.background(runtime, 'bot'),
|
||||
});
|
||||
if (runtime.env.EnableExtraShell === 'true')
|
||||
background.push({
|
||||
action: 'extra',
|
||||
pid: await system.background(runtime, 'extra'),
|
||||
});
|
||||
if (service.manager === 'pm2') {
|
||||
if (registerStartup) {
|
||||
await (system.registerServices ?? registerHostServices)(runtime, os);
|
||||
await checkedProcess('pm2', ['startup'], options);
|
||||
}
|
||||
await checkedProcess('pm2', ['save'], options);
|
||||
}
|
||||
}
|
||||
return {
|
||||
startup:
|
||||
reload || service.manager !== 'pm2'
|
||||
? 'not-requested'
|
||||
: registerStartup
|
||||
? 'registered'
|
||||
: 'skipped',
|
||||
mode: reload ? 'reload' : 'install',
|
||||
os,
|
||||
restored,
|
||||
service,
|
||||
background,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { LocalContext } from '../runtime/context';
|
||||
import { checkedProcess } from '../runtime/process';
|
||||
import { replaceAndReload } from './upgrade';
|
||||
|
||||
export function botSystemPackages(
|
||||
os: string,
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
): {
|
||||
program: string;
|
||||
args: string[];
|
||||
} {
|
||||
if (os === 'alpine')
|
||||
return {
|
||||
program: 'apk',
|
||||
args: [
|
||||
'--no-cache',
|
||||
'add',
|
||||
'-f',
|
||||
'zlib-dev',
|
||||
'gcc',
|
||||
'jpeg-dev',
|
||||
'python3-dev',
|
||||
'musl-dev',
|
||||
'freetype-dev',
|
||||
],
|
||||
};
|
||||
if (os === 'debian' || os === 'ubuntu')
|
||||
return {
|
||||
program: 'apt-get',
|
||||
args: [
|
||||
'install',
|
||||
'-y',
|
||||
'gcc',
|
||||
'python3-dev',
|
||||
'musl-dev',
|
||||
'zlib1g-dev',
|
||||
'libjpeg-dev',
|
||||
'libfreetype-dev',
|
||||
],
|
||||
};
|
||||
throw new Error(
|
||||
translate(environment, 'Bot 安装不支持此操作系统:%s。', os || 'unknown'),
|
||||
);
|
||||
}
|
||||
|
||||
export async function operatingSystem(context: LocalContext): Promise<string> {
|
||||
if (context.env.QL_OS_TYPE) return context.env.QL_OS_TYPE;
|
||||
const release = await fs.readFile('/etc/os-release', 'utf8').catch(() => '');
|
||||
return release.match(/^ID=["']?([a-zA-Z0-9_-]+)["']?\s*$/m)?.[1] ?? '';
|
||||
}
|
||||
|
||||
// Validate source links before copying: bot-owned links must stay inside its tree.
|
||||
async function validateBotTree(
|
||||
root: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
boundary = root,
|
||||
): Promise<void> {
|
||||
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
|
||||
const file = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) await validateBotTree(file, environment, boundary);
|
||||
else if (entry.isSymbolicLink()) {
|
||||
const relative = path.relative(
|
||||
await fs.realpath(boundary),
|
||||
await fs.realpath(file),
|
||||
);
|
||||
if (
|
||||
relative === '..' ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
)
|
||||
throw new Error(
|
||||
translate(environment, 'Bot 仓库包含指向目录外部的符号链接。'),
|
||||
);
|
||||
} else if (!entry.isFile())
|
||||
throw new Error(translate(environment, 'Bot 仓库包含不支持的文件类型。'));
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareBot(context: LocalContext): Promise<{
|
||||
repository: string;
|
||||
staged: string;
|
||||
requirements: string;
|
||||
config: string;
|
||||
}> {
|
||||
const packages = botSystemPackages(
|
||||
await operatingSystem(context),
|
||||
context.env,
|
||||
);
|
||||
const privileged = process.getuid?.() === 0;
|
||||
await checkedProcess(
|
||||
privileged ? packages.program : 'sudo',
|
||||
privileged ? packages.args : [packages.program, ...packages.args],
|
||||
{ cwd: context.root, env: context.env },
|
||||
);
|
||||
await fs.mkdir(context.paths.dir_repo!, { recursive: true });
|
||||
await fs.mkdir(context.data, { recursive: true });
|
||||
await fs.mkdir(context.paths.dir_config!, { recursive: true });
|
||||
const repository = path.join(
|
||||
context.paths.dir_repo!,
|
||||
context.env.BotRepoUrl ? 'diybot' : 'dockerbot',
|
||||
);
|
||||
const url = context.env.BotRepoUrl || 'https://github.com/SuMaiKaDe/bot.git';
|
||||
const git = await fs
|
||||
.stat(path.join(repository, '.git'))
|
||||
.catch(() => undefined);
|
||||
if (!git?.isDirectory()) {
|
||||
const temp = await fs.mkdtemp(
|
||||
path.join(context.paths.dir_repo!, '.bot-clone-'),
|
||||
);
|
||||
try {
|
||||
await checkedProcess(
|
||||
'git',
|
||||
[
|
||||
'clone',
|
||||
'--depth=1',
|
||||
'--branch',
|
||||
'main',
|
||||
'--',
|
||||
url,
|
||||
path.join(temp, 'repository'),
|
||||
],
|
||||
{ cwd: context.root, env: context.env },
|
||||
);
|
||||
await fs.access(path.join(temp, 'repository/jbot/requirements.txt'));
|
||||
const backup = `${repository}.previous-${randomUUID()}`;
|
||||
const exists = !!(await fs.lstat(repository).catch(() => undefined));
|
||||
if (exists) await fs.rename(repository, backup);
|
||||
try {
|
||||
await fs.rename(path.join(temp, 'repository'), repository);
|
||||
} catch (error) {
|
||||
if (exists) await fs.rename(backup, repository);
|
||||
throw error;
|
||||
}
|
||||
if (exists) await fs.rm(backup, { recursive: true, force: true });
|
||||
} finally {
|
||||
await fs.rm(temp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
const source = path.join(repository, 'jbot');
|
||||
if (!(await fs.lstat(source)).isDirectory())
|
||||
throw new Error(translate(context.env, 'Bot 源必须为普通目录。'));
|
||||
await validateBotTree(source, context.env);
|
||||
const staged = await fs.mkdtemp(path.join(context.data, '.bot-stage-'));
|
||||
try {
|
||||
await fs.cp(source, path.join(staged, 'jbot'), {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
const requirements = path.join(staged, 'jbot/requirements.txt');
|
||||
const config = path.join(repository, 'config/bot.json');
|
||||
if (!(await fs.lstat(config)).isFile())
|
||||
throw new Error(translate(context.env, 'Bot 配置模板必须为普通文件。'));
|
||||
await checkedProcess(
|
||||
'pip3',
|
||||
['--default-timeout=100', 'install', '-r', requirements],
|
||||
{ cwd: context.data, env: context.env },
|
||||
);
|
||||
return { repository, staged, requirements, config };
|
||||
} catch (error) {
|
||||
await fs.rm(staged, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopBot(context: LocalContext): Promise<void> {
|
||||
// Supported installations are Linux. Match cwd as well as argv so another
|
||||
// QingLong instance's Telegram bot is never selected by a global pkill.
|
||||
if (process.platform !== 'linux')
|
||||
throw new Error(translate(context.env, 'Bot 进程管理需要 Linux。'));
|
||||
const cwd = await fs.realpath(context.data);
|
||||
for (const pid of await fs.readdir('/proc')) {
|
||||
if (!/^\d+$/.test(pid) || Number(pid) === process.pid) continue;
|
||||
try {
|
||||
const args = (await fs.readFile(`/proc/${pid}/cmdline`, 'utf8'))
|
||||
.split('\0')
|
||||
.filter(Boolean);
|
||||
if (
|
||||
!/^python3(?:\.\d+)?$/.test(path.basename(args[0] ?? '')) ||
|
||||
args[1] !== '-m' ||
|
||||
args[2] !== 'jbot'
|
||||
)
|
||||
continue;
|
||||
if ((await fs.realpath(`/proc/${pid}/cwd`)) !== cwd) continue;
|
||||
// Legacy script used SIGKILL; retain deterministic stop before replacement.
|
||||
process.kill(Number(pid), 'SIGKILL');
|
||||
} catch (error) {
|
||||
if (
|
||||
!['ENOENT', 'ESRCH', 'EACCES'].includes(
|
||||
(error as NodeJS.ErrnoException).code ?? '',
|
||||
)
|
||||
)
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function launchBot(
|
||||
context: LocalContext,
|
||||
): Promise<{ pid: number }> {
|
||||
const directory = path.join(context.paths.dir_log!, 'bot');
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
const log = await fs.open(path.join(directory, 'nohup.log'), 'w', 0o600);
|
||||
try {
|
||||
const child = spawn('python3', ['-m', 'jbot'], {
|
||||
cwd: context.data,
|
||||
env: context.env,
|
||||
detached: true,
|
||||
stdio: ['ignore', log.fd, log.fd],
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const failed = (code: number | null) => {
|
||||
clearTimeout(timer);
|
||||
reject(
|
||||
new Error(translate(context.env, 'Bot 在启动期间退出(%s)。', code)),
|
||||
);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
child.removeListener('exit', failed);
|
||||
resolve();
|
||||
}, 1000);
|
||||
child.once('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.once('exit', failed);
|
||||
});
|
||||
child.unref();
|
||||
return { pid: child.pid! };
|
||||
} finally {
|
||||
await log.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function installAndStartBot(
|
||||
context: LocalContext,
|
||||
lifecycle = { stop: stopBot, start: launchBot },
|
||||
): Promise<unknown> {
|
||||
if (lifecycle.stop === stopBot && process.platform !== 'linux')
|
||||
throw new Error(translate(context.env, 'Bot 安装和进程管理需要 Linux。'));
|
||||
const prepared = await prepareBot(context);
|
||||
try {
|
||||
const config = path.join(context.paths.dir_config!, 'bot.json');
|
||||
try {
|
||||
await fs.copyFile(prepared.config, config, fs.constants.COPYFILE_EXCL);
|
||||
await fs.chmod(config, 0o600);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
||||
}
|
||||
const result = await replaceAndReload(
|
||||
context,
|
||||
[
|
||||
{
|
||||
source: path.join(prepared.staged, 'jbot'),
|
||||
target: path.join(context.data, 'jbot'),
|
||||
},
|
||||
{
|
||||
source: prepared.requirements,
|
||||
target: path.join(context.data, 'requirements.txt'),
|
||||
},
|
||||
],
|
||||
lifecycle,
|
||||
);
|
||||
return { repository: prepared.repository, result };
|
||||
} finally {
|
||||
await fs.rm(prepared.staged, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import type { LocalContext } from '../runtime/context';
|
||||
import { atomicWrite } from '../runtime/files';
|
||||
import { checkedProcess } from '../runtime/process';
|
||||
import {
|
||||
repairConfiguration,
|
||||
installPanelDependencies,
|
||||
startPanel,
|
||||
} from './operator';
|
||||
|
||||
export interface HealthObservation {
|
||||
healthy: boolean;
|
||||
status?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Probe loopback directly: no inherited HTTP proxy, redirects or app credentials.
|
||||
async function probe(
|
||||
port: number,
|
||||
route: string,
|
||||
matches: (body: string) => boolean,
|
||||
): Promise<HealthObservation> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: HealthObservation) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(deadline);
|
||||
resolve(result);
|
||||
};
|
||||
const request = http.get(
|
||||
{ hostname: '127.0.0.1', port, path: route, headers: { Accept: '*/*' } },
|
||||
(response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
response.on('data', (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > 1024 * 1024) {
|
||||
finish({
|
||||
healthy: false,
|
||||
status: response.statusCode,
|
||||
error: 'Response exceeds 1 MiB.',
|
||||
});
|
||||
request.destroy();
|
||||
} else chunks.push(chunk);
|
||||
});
|
||||
response.on('end', () => {
|
||||
const status = response.statusCode ?? 0;
|
||||
finish({
|
||||
healthy:
|
||||
status >= 200 &&
|
||||
status < 300 &&
|
||||
matches(Buffer.concat(chunks).toString('utf8')),
|
||||
status,
|
||||
});
|
||||
});
|
||||
response.on('error', () =>
|
||||
finish({ healthy: false, error: 'Response interrupted.' }),
|
||||
);
|
||||
},
|
||||
);
|
||||
const deadline = setTimeout(() => {
|
||||
finish({ healthy: false, error: 'Probe timed out.' });
|
||||
request.destroy();
|
||||
}, 5000);
|
||||
request.on('error', () =>
|
||||
finish({ healthy: false, error: 'Loopback request failed.' }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function inspectPanel(
|
||||
context: LocalContext,
|
||||
): Promise<{ panel: HealthObservation; backend: HealthObservation }> {
|
||||
const portText = context.env.QlPort || '5700';
|
||||
if (
|
||||
!/^\d+$/.test(portText) ||
|
||||
Number(portText) < 1 ||
|
||||
Number(portText) > 65535
|
||||
)
|
||||
throw new Error(translate(context.env, 'QlPort 必须在 1 到 65535 之间。'));
|
||||
const port = Number(portText);
|
||||
const basePath = (context.env.QlBaseUrl || '/').replace(/\/+$/, '');
|
||||
const [panel, backend] = await Promise.all([
|
||||
probe(port, `${basePath}/`, (body) =>
|
||||
/<div\s+id=["']root["']\s*>\s*<\/div>/.test(body),
|
||||
),
|
||||
probe(port, `${basePath}/api/health?t=${Math.floor(Date.now() / 1000)}`, (body) => {
|
||||
try {
|
||||
const response: unknown = JSON.parse(body);
|
||||
return (
|
||||
!!response &&
|
||||
typeof response === 'object' &&
|
||||
'code' in response &&
|
||||
response.code === 200 &&
|
||||
'data' in response && !!response.data &&
|
||||
typeof response.data === 'object' &&
|
||||
'status' in response.data && response.data.status === 'ok'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
]);
|
||||
return { panel, backend };
|
||||
}
|
||||
|
||||
export async function diagnosticLog(file: string): Promise<{
|
||||
file: string;
|
||||
text?: string;
|
||||
truncated?: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(file, 'r');
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) return { file, error: 'Not a regular log file.' };
|
||||
const length = Math.min(stat.size, 256 * 1024);
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
length,
|
||||
stat.size - length,
|
||||
);
|
||||
let text = buffer.subarray(0, bytesRead).toString('utf8');
|
||||
// Drop a partial UTF-8/line prefix when reading only the end of a file.
|
||||
if (stat.size > length)
|
||||
text = text.includes('\n') ? text.slice(text.indexOf('\n') + 1) : '';
|
||||
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
||||
if (lines.at(-1) === '') lines.pop();
|
||||
return {
|
||||
file,
|
||||
text: lines.slice(-300).join('\n'),
|
||||
truncated: stat.size > length || lines.length > 300,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
file,
|
||||
error:
|
||||
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
? 'Log is absent.'
|
||||
: 'Cannot read log.',
|
||||
};
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAndRepair(context: LocalContext): Promise<unknown> {
|
||||
await checkedProcess(
|
||||
'npm',
|
||||
['i', '-g', 'pnpm@8.3.1', 'pm2', 'ts-node', 'typescript@5'],
|
||||
{ cwd: context.root, env: context.env },
|
||||
);
|
||||
const restored = await repairConfiguration(context);
|
||||
await installPanelDependencies(context);
|
||||
const copied: string[] = [];
|
||||
for (const language of ['py', 'js']) {
|
||||
const destination = context.paths[`file_notify_${language}`]!;
|
||||
await atomicWrite(
|
||||
destination,
|
||||
await fs.readFile(context.paths[`file_notify_${language}_sample`]!),
|
||||
);
|
||||
copied.push(destination);
|
||||
}
|
||||
const before = await inspectPanel(context);
|
||||
const pm2Home =
|
||||
context.env.PM2_HOME || path.join(context.env.HOME || '/root', '.pm2');
|
||||
const logs = await Promise.all(
|
||||
['qinglong-out.log', 'qinglong-error.log'].map((name) =>
|
||||
diagnosticLog(path.join(pm2Home, 'logs', name)),
|
||||
),
|
||||
);
|
||||
const service = await startPanel(context);
|
||||
const after = await inspectPanel(context);
|
||||
return { restored, copied, before, service, after, logs };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Invocation } from '../../shared/cli/arguments';
|
||||
import { localContext, type LocalContext } from '../runtime/context';
|
||||
import { LocalApi } from '../runtime/api';
|
||||
import { regularFiles } from '../runtime/files';
|
||||
import { checkedProcess } from '../runtime/process';
|
||||
|
||||
export async function pruneLogs(
|
||||
context: LocalContext,
|
||||
days: number,
|
||||
): Promise<{ removed: string[]; retained: string[] }> {
|
||||
const api = new LocalApi(context);
|
||||
const root = context.paths.dir_log!;
|
||||
const removed: string[] = [],
|
||||
retained: string[] = [];
|
||||
for (const relative of await regularFiles(root)) {
|
||||
if (!relative.endsWith('.log')) continue;
|
||||
const file = path.join(root, relative);
|
||||
const datePart = path.basename(file).match(/^(\d{4}-\d{2}-\d{2})/)?.[1];
|
||||
const modified = datePart
|
||||
? new Date(`${datePart}T00:00:00`)
|
||||
: (await fs.stat(file)).mtime;
|
||||
// Legacy Linux retention compares calendar dates, including undated logs.
|
||||
const age = modified.setHours(0, 0, 0, 0);
|
||||
if (!Number.isFinite(age) || Date.now() - age <= days * 86400000) continue;
|
||||
const response = await api.call(
|
||||
`crons/detail?${new URLSearchParams({ log_path: relative })}`,
|
||||
);
|
||||
if (response.data) {
|
||||
retained.push(relative);
|
||||
continue;
|
||||
}
|
||||
await fs.unlink(file);
|
||||
removed.push(relative);
|
||||
}
|
||||
for (const entry of await fs
|
||||
.readdir(root, { withFileTypes: true })
|
||||
.catch(() => [])) {
|
||||
if (entry.isDirectory())
|
||||
await fs
|
||||
.rmdir(path.join(root, entry.name))
|
||||
.catch((error: NodeJS.ErrnoException) => {
|
||||
if (!['ENOTEMPTY', 'ENOENT'].includes(error.code ?? '')) throw error;
|
||||
});
|
||||
}
|
||||
return { removed, retained };
|
||||
}
|
||||
|
||||
export async function maintenance(
|
||||
command: Invocation,
|
||||
prepared?: LocalContext,
|
||||
): Promise<unknown> {
|
||||
const context = prepared ?? (await localContext(command));
|
||||
const action = command.name.split(' ')[1];
|
||||
if (action === 'start') {
|
||||
const { bootstrapPanel } = await import('./bootstrap');
|
||||
return bootstrapPanel(context, command.values.reload === true, undefined, {
|
||||
registerStartup: command.values['no-startup'] !== true,
|
||||
});
|
||||
}
|
||||
if (action === 'bot') {
|
||||
const { installAndStartBot } = await import('./bot');
|
||||
return installAndStartBot(context);
|
||||
}
|
||||
if (action === 'check') {
|
||||
const { checkAndRepair } = await import('./check');
|
||||
return checkAndRepair(context);
|
||||
}
|
||||
if (action === 'repair-config') {
|
||||
const { repairConfiguration } = await import('./operator');
|
||||
return { restored: await repairConfiguration(context) };
|
||||
}
|
||||
if (action === 'reload' || action === 'update') {
|
||||
const { reloadPanel, stageUpgrade } = await import('./upgrade');
|
||||
if (action === 'reload')
|
||||
return reloadPanel(
|
||||
context,
|
||||
command.values.target as 'services' | 'system' | 'data',
|
||||
);
|
||||
const { repairConfiguration } = await import('./operator');
|
||||
await repairConfiguration(context);
|
||||
const staged = await stageUpgrade(
|
||||
context,
|
||||
command.values.mirror as 'github' | 'gitee',
|
||||
);
|
||||
return command.values['download-only']
|
||||
? { staged }
|
||||
: { staged, result: await reloadPanel(context, 'system', staged) };
|
||||
}
|
||||
if (action === 'rmlog')
|
||||
return pruneLogs(context, Number(command.positionals[0]));
|
||||
if (action === 'extra') {
|
||||
const file = context.paths.file_extra_shell!;
|
||||
if (!(await fs.stat(file).catch(() => undefined))?.isFile())
|
||||
return { skipped: true, reason: 'extra.sh is absent' };
|
||||
await checkedProcess(
|
||||
'bash',
|
||||
['--noprofile', '--norc', '-c', '. "$1"', 'ql-extra', file],
|
||||
{ cwd: context.root, env: context.env },
|
||||
);
|
||||
return { completed: true };
|
||||
}
|
||||
const payload =
|
||||
action === 'resetlet'
|
||||
? { retries: 0 }
|
||||
: action === 'resettfa'
|
||||
? { twoFactorActivated: false }
|
||||
: action === 'resetpwd'
|
||||
? { password: command.positionals[0] }
|
||||
: { username: command.positionals[0] };
|
||||
await new LocalApi(context).call('system/auth/reset', 'PUT', payload);
|
||||
return { action, completed: true };
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import type { LocalContext } from '../runtime/context';
|
||||
import { atomicWrite } from '../runtime/files';
|
||||
import { runProcess, checkedProcess } from '../runtime/process';
|
||||
import { findDirectBackendPids } from '../runtime/backendProcesses';
|
||||
|
||||
export async function repairConfiguration(
|
||||
context: LocalContext,
|
||||
): Promise<string[]> {
|
||||
for (const name of [
|
||||
'tmp',
|
||||
'static',
|
||||
'data',
|
||||
'config',
|
||||
'log',
|
||||
'db',
|
||||
'scripts',
|
||||
'list_tmp',
|
||||
'repo',
|
||||
'raw',
|
||||
'update_log',
|
||||
'dep',
|
||||
])
|
||||
await fs.mkdir(context.paths[`dir_${name}`]!, { recursive: true });
|
||||
const restored: string[] = [];
|
||||
const templates: [string, string, boolean][] = [
|
||||
['file_config_user', 'file_config_sample', true],
|
||||
['file_task_before', 'file_task_sample', false],
|
||||
['file_task_after', 'file_task_sample', false],
|
||||
['file_extra_shell', 'file_extra_sample', false],
|
||||
['file_notify_py', 'file_notify_py_sample', true],
|
||||
['file_notify_js', 'file_notify_js_sample', true],
|
||||
['file_test_js', 'file_test_js_sample', true],
|
||||
['file_test_py', 'file_test_py_sample', true],
|
||||
['dep_notify_js', 'file_notify_js_sample', true],
|
||||
['dep_notify_py', 'file_notify_py_sample', true],
|
||||
];
|
||||
for (const [destination, source, fillEmpty] of templates) {
|
||||
const target = context.paths[destination]!;
|
||||
const stat = await fs.stat(target).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return undefined;
|
||||
});
|
||||
if (stat && (!fillEmpty || stat.size > 0)) continue;
|
||||
await atomicWrite(target, await fs.readFile(context.paths[source]!));
|
||||
restored.push(target);
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
|
||||
export async function executableAvailable(
|
||||
program: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<boolean> {
|
||||
for (const directory of (env.PATH ?? '').split(path.delimiter)) {
|
||||
if (!directory) continue;
|
||||
try {
|
||||
await fs.access(path.join(directory, program), fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
/* Try the next PATH entry. */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function installPanelDependencies(
|
||||
context: LocalContext,
|
||||
cwd = context.root,
|
||||
): Promise<void> {
|
||||
const termux = context.env.is_termux === '1';
|
||||
const pnpm = !termux && (await executableAvailable('pnpm', context.env));
|
||||
await checkedProcess(
|
||||
pnpm ? 'pnpm' : 'npm',
|
||||
pnpm
|
||||
? ['install', '--loglevel', 'error', '--production']
|
||||
: ['install', '--production', ...(termux ? ['--no-bin-links'] : [])],
|
||||
{ cwd, env: context.env },
|
||||
);
|
||||
}
|
||||
|
||||
// Match only this installation's backend; the legacy broad pkill pattern could
|
||||
// terminate another panel running on the same host.
|
||||
async function stopDirectBackend(context: LocalContext): Promise<void> {
|
||||
const entry = path.join(context.paths.dir_static!, 'build/app.js');
|
||||
if (process.platform === 'linux') {
|
||||
const stopping = new Set(await findDirectBackendPids(entry));
|
||||
for (const pid of stopping) {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
|
||||
}
|
||||
}
|
||||
const deadline = Date.now() + 5000;
|
||||
while (
|
||||
(await findDirectBackendPids(entry)).some((pid) => stopping.has(pid))
|
||||
) {
|
||||
if (Date.now() >= deadline)
|
||||
throw new Error(
|
||||
translate(context.env, '后端进程未在 5 秒内停止,已取消重启。'),
|
||||
);
|
||||
await delay(50);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const result = await runProcess('ps', ['-eo', 'pid=,args='], {
|
||||
capture: true,
|
||||
env: context.env,
|
||||
});
|
||||
if (result.code !== 0)
|
||||
throw new Error(translate(context.env, '无法检查运行中的后端进程。'));
|
||||
for (const line of result.stdout.split('\n')) {
|
||||
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
|
||||
if (!match || !/^node(?:\d+)?$/.test(path.basename(match[2]!))) continue;
|
||||
if (match[3] !== entry) continue;
|
||||
const pid = Number(match[1]);
|
||||
if (pid === process.pid) continue;
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopPanel(context: LocalContext): Promise<void> {
|
||||
if (await executableAvailable('pm2', context.env))
|
||||
await runProcess('pm2', ['delete', 'ecosystem.config.js'], {
|
||||
cwd: context.root,
|
||||
env: context.env,
|
||||
});
|
||||
await stopDirectBackend(context);
|
||||
}
|
||||
|
||||
export async function startPanel(
|
||||
context: LocalContext,
|
||||
): Promise<{ manager: 'pm2' | 'node'; pid?: number }> {
|
||||
// Match container startup even when invoked from a fresh administrative CLI
|
||||
// process. Legacy dotenv files can still contain BACK_PORT=5600.
|
||||
const options = {
|
||||
cwd: context.root,
|
||||
env: {
|
||||
...context.env,
|
||||
BACK_PORT: context.env.QlPort || '5700',
|
||||
GRPC_PORT: context.env.QlGrpcPort || '5500',
|
||||
},
|
||||
};
|
||||
if (await executableAvailable('pm2', context.env)) {
|
||||
const flushed = await runProcess('pm2', ['flush'], options);
|
||||
if (flushed.code === 0) {
|
||||
const started = await runProcess(
|
||||
'pm2',
|
||||
['startOrGracefulReload', 'ecosystem.config.js', '--update-env'],
|
||||
options,
|
||||
);
|
||||
if (started.code === 0) return { manager: 'pm2' };
|
||||
}
|
||||
}
|
||||
await stopDirectBackend(context);
|
||||
const entry = path.join(context.paths.dir_static!, 'build/app.js');
|
||||
await fs.access(entry);
|
||||
await fs.mkdir(context.paths.dir_log!, { recursive: true });
|
||||
const log = await fs.open(
|
||||
path.join(context.paths.dir_log!, 'qinglong.log'),
|
||||
'a',
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
const child = spawn(process.execPath, [entry], {
|
||||
...options,
|
||||
detached: true,
|
||||
stdio: ['ignore', log.fd, log.fd],
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
const failed = (code: number | null) => {
|
||||
clearTimeout(timer);
|
||||
reject(
|
||||
new Error(
|
||||
translate(
|
||||
context.env,
|
||||
'直接运行的后端在启动期间退出(%s)。',
|
||||
code,
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
child.removeListener('exit', failed);
|
||||
resolve();
|
||||
}, 1000);
|
||||
child.once('exit', failed);
|
||||
});
|
||||
child.unref();
|
||||
return { manager: 'node', pid: child.pid };
|
||||
} finally {
|
||||
await log.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { prepareUpgradeSelection } from './upgradeSelection';
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { LocalContext } from '../runtime/context';
|
||||
import { checkedProcess } from '../runtime/process';
|
||||
import { operationSignal, withoutCancellation } from '../runtime/cancellation';
|
||||
import { installPanelDependencies, startPanel, stopPanel } from './operator';
|
||||
|
||||
export function releaseBranch(context: LocalContext): string {
|
||||
return ['develop', 'debian', 'debian-dev'].includes(
|
||||
context.env.QL_BRANCH ?? '',
|
||||
)
|
||||
? context.env.QL_BRANCH!
|
||||
: 'master';
|
||||
}
|
||||
|
||||
async function validateTree(
|
||||
root: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
boundary = root,
|
||||
): Promise<void> {
|
||||
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
|
||||
const filename = path.join(root, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
const resolved = await fs.realpath(filename);
|
||||
const relation = path.relative(await fs.realpath(boundary), resolved);
|
||||
if (
|
||||
!relation ||
|
||||
relation === '..' ||
|
||||
relation.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relation)
|
||||
)
|
||||
throw new Error(
|
||||
translate(environment, '升级文件包含指向目录外部的符号链接。'),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() && !entry.isDirectory())
|
||||
throw new Error(translate(environment, '升级文件包含不支持的文件类型。'));
|
||||
if (entry.isDirectory())
|
||||
await validateTree(filename, environment, boundary);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stageUpgrade(
|
||||
context: LocalContext,
|
||||
mirror: 'github' | 'gitee',
|
||||
): Promise<{ source: string; static: string }> {
|
||||
await fs.mkdir(context.paths.dir_tmp!, { recursive: true });
|
||||
const workspace = await fs.mkdtemp(
|
||||
path.join(context.paths.dir_tmp!, 'upgrade-'),
|
||||
);
|
||||
const branch = releaseBranch(context);
|
||||
try {
|
||||
for (const repository of ['qinglong', 'qinglong-static']) {
|
||||
const url =
|
||||
mirror === 'github'
|
||||
? `https://github.com/whyour/${repository}/archive/refs/heads/${branch}.zip`
|
||||
: `https://gitee.com/whyour/${repository}/repository/archive/${branch}.zip`;
|
||||
const archive = path.join(workspace, `${repository}.zip`);
|
||||
await checkedProcess(
|
||||
'curl',
|
||||
[
|
||||
'--fail',
|
||||
'--location',
|
||||
'--proto',
|
||||
'=https',
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--output',
|
||||
archive,
|
||||
url,
|
||||
],
|
||||
{ env: context.env },
|
||||
);
|
||||
const listing = await checkedProcess('unzip', ['-Z1', archive], {
|
||||
env: context.env,
|
||||
capture: true,
|
||||
});
|
||||
const expected = `${repository}-${branch}`;
|
||||
for (const name of listing.stdout.split('\n').filter(Boolean)) {
|
||||
if (
|
||||
name.includes('\\') ||
|
||||
name.includes('\r') ||
|
||||
name.startsWith('/') ||
|
||||
name.split('/').includes('..') ||
|
||||
name.split('/')[0] !== expected
|
||||
)
|
||||
throw new Error(translate(context.env, '升级归档包含无效路径。'));
|
||||
}
|
||||
// Reject Unix symlinks before extraction, including links that could redirect
|
||||
// a later archive entry outside the staging directory.
|
||||
const modes = await checkedProcess('unzip', ['-Z', '-l', archive], {
|
||||
env: context.env,
|
||||
capture: true,
|
||||
});
|
||||
if (modes.stdout.split('\n').some((line) => /^l[rwx-]{9}\s/.test(line)))
|
||||
throw new Error(translate(context.env, '升级归档包含符号链接。'));
|
||||
await checkedProcess('unzip', ['-oq', archive, '-d', workspace], {
|
||||
env: context.env,
|
||||
});
|
||||
await validateTree(path.join(workspace, expected), context.env);
|
||||
}
|
||||
const source = path.join(workspace, `qinglong-${branch}`);
|
||||
const staticRoot = path.join(workspace, `qinglong-static-${branch}`);
|
||||
await fs.access(path.join(staticRoot, 'build/app.js'));
|
||||
const manifest = await fs.readFile(path.join(source, 'package.json'));
|
||||
const existing = await fs.readFile(path.join(context.root, 'package.json'));
|
||||
if (!manifest.equals(existing))
|
||||
await installPanelDependencies(context, source);
|
||||
// Publish a marker only after both archives and dependency installation succeed.
|
||||
await fs.writeFile(
|
||||
path.join(workspace, 'ready.json'),
|
||||
JSON.stringify({ source, static: staticRoot }),
|
||||
{ flag: 'wx', mode: 0o600 },
|
||||
);
|
||||
const pointer = path.join(workspace, 'pointer.json');
|
||||
await fs.writeFile(
|
||||
pointer,
|
||||
JSON.stringify({ directory: path.basename(workspace) }),
|
||||
{
|
||||
flag: 'wx',
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
await fs.rename(
|
||||
pointer,
|
||||
path.join(context.paths.dir_tmp!, `upgrade-ready-${branch}.json`),
|
||||
);
|
||||
return { source, static: staticRoot };
|
||||
} catch (error) {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectedUpgrade(
|
||||
context: LocalContext,
|
||||
): Promise<{ source: string; static: string }> {
|
||||
const branch = releaseBranch(context);
|
||||
const fallback = {
|
||||
source: path.join(context.paths.dir_tmp!, `qinglong-${branch}`),
|
||||
static: path.join(context.paths.dir_tmp!, `qinglong-static-${branch}`),
|
||||
};
|
||||
const pointer = await fs
|
||||
.readFile(
|
||||
path.join(context.paths.dir_tmp!, `upgrade-ready-${branch}.json`),
|
||||
'utf8',
|
||||
)
|
||||
.catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return undefined;
|
||||
throw error;
|
||||
});
|
||||
if (pointer === undefined) return fallback;
|
||||
const directory: unknown = JSON.parse(pointer)?.directory;
|
||||
if (
|
||||
typeof directory !== 'string' ||
|
||||
!/^upgrade-[A-Za-z0-9_-]+$/.test(directory)
|
||||
)
|
||||
throw new Error(translate(context.env, '暂存升级指针无效。'));
|
||||
const workspace = path.join(context.paths.dir_tmp!, directory);
|
||||
const selected = {
|
||||
source: path.join(workspace, `qinglong-${branch}`),
|
||||
static: path.join(workspace, `qinglong-static-${branch}`),
|
||||
};
|
||||
for (const location of [workspace, selected.source, selected.static]) {
|
||||
const stat = await fs.lstat(location);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink())
|
||||
throw new Error(translate(context.env, '暂存升级目录无效。'));
|
||||
}
|
||||
const ready = JSON.parse(
|
||||
await fs.readFile(path.join(workspace, 'ready.json'), 'utf8'),
|
||||
);
|
||||
if (ready?.source !== selected.source || ready?.static !== selected.static)
|
||||
throw new Error(translate(context.env, '暂存升级就绪记录与目录不匹配。'));
|
||||
return selected;
|
||||
}
|
||||
|
||||
export interface ReloadLifecycle {
|
||||
stop(context: LocalContext): Promise<void>;
|
||||
start(context: LocalContext): Promise<unknown>;
|
||||
}
|
||||
|
||||
// All old entries stay on the same filesystem until service start succeeds.
|
||||
// A failed replacement rolls back in reverse order before restarting the old app.
|
||||
export async function replaceAndReload(
|
||||
context: LocalContext,
|
||||
replacements: { source?: string; target: string }[],
|
||||
lifecycle: ReloadLifecycle = { stop: stopPanel, start: startPanel },
|
||||
): Promise<unknown> {
|
||||
for (const item of replacements) {
|
||||
// Missing source denotes deletion, backed up and rolled back like replacement.
|
||||
if (item.source === undefined) continue;
|
||||
await fs.lstat(item.source);
|
||||
if (path.resolve(item.source) === path.resolve(item.target))
|
||||
throw new Error(translate(context.env, '替换源与目标相同。'));
|
||||
const relation = path.relative(
|
||||
path.resolve(item.target),
|
||||
path.resolve(item.source),
|
||||
);
|
||||
if (
|
||||
!relation.startsWith(`..${path.sep}`) &&
|
||||
relation !== '..' &&
|
||||
!path.isAbsolute(relation)
|
||||
)
|
||||
throw new Error(translate(context.env, '替换源位于目标目录内部。'));
|
||||
}
|
||||
const changed: { target: string; backup: string; existed: boolean }[] = [];
|
||||
const signal = operationSignal();
|
||||
signal?.throwIfAborted();
|
||||
let result: unknown;
|
||||
let attemptedStart = false;
|
||||
try {
|
||||
await lifecycle.stop(context);
|
||||
signal?.throwIfAborted();
|
||||
for (const { source, target } of replacements) {
|
||||
signal?.throwIfAborted();
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
const backup = `${target}.ql-backup-${randomUUID()}`;
|
||||
const existed = !!(await fs
|
||||
.lstat(target)
|
||||
.catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return undefined;
|
||||
}));
|
||||
let recorded = false;
|
||||
if (existed) {
|
||||
try {
|
||||
await fs.rename(target, backup);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EXDEV') throw error;
|
||||
// OverlayFS can reject renaming a lower-layer directory even within
|
||||
// one mount. Finish its backup before removing any original entry.
|
||||
try {
|
||||
await fs.cp(target, backup, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
dereference: false,
|
||||
errorOnExist: true,
|
||||
force: false,
|
||||
});
|
||||
} catch (copyError) {
|
||||
await fs.rm(backup, { recursive: true, force: true });
|
||||
throw copyError;
|
||||
}
|
||||
changed.push({ target, backup, existed });
|
||||
recorded = true;
|
||||
await fs.rm(target, { recursive: true });
|
||||
}
|
||||
}
|
||||
if (!recorded) changed.push({ target, backup, existed });
|
||||
// Copy supports an external QL_DATA_DIR on a different filesystem.
|
||||
if (source !== undefined)
|
||||
await fs.cp(source, target, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
dereference: false,
|
||||
errorOnExist: true,
|
||||
force: false,
|
||||
});
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
attemptedStart = true;
|
||||
result = await lifecycle.start(context);
|
||||
signal?.throwIfAborted();
|
||||
} catch (error) {
|
||||
await withoutCancellation(async () => {
|
||||
// A failed or interrupted start can leave a partially started service.
|
||||
// Stop it before restoring files; retain backups if it cannot be stopped.
|
||||
if (attemptedStart) await lifecycle.stop(context);
|
||||
for (const item of changed.reverse()) {
|
||||
await fs.rm(item.target, { recursive: true, force: true });
|
||||
if (item.existed) await fs.rename(item.backup, item.target);
|
||||
}
|
||||
try {
|
||||
await lifecycle.start(context);
|
||||
} catch {
|
||||
throw new Error(
|
||||
translate(
|
||||
context.env,
|
||||
'重载失败;文件已恢复,但原服务无法重新启动。',
|
||||
),
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
// Cleanup failure must never initiate rollback after backups have been removed.
|
||||
const retainedBackups: string[] = [];
|
||||
for (const item of changed)
|
||||
if (item.existed) {
|
||||
try {
|
||||
await fs.rm(item.backup, { recursive: true, force: true });
|
||||
} catch {
|
||||
retainedBackups.push(item.backup);
|
||||
}
|
||||
}
|
||||
return { service: result, retainedBackups };
|
||||
}
|
||||
|
||||
export async function reloadPanel(
|
||||
context: LocalContext,
|
||||
target: 'services' | 'system' | 'data',
|
||||
staged?: { source: string; static: string },
|
||||
): Promise<unknown> {
|
||||
if (target === 'services') {
|
||||
await stopPanel(context);
|
||||
return startPanel(context);
|
||||
}
|
||||
if (target === 'data') {
|
||||
if (
|
||||
context.data === path.parse(context.data).root ||
|
||||
context.data === context.root ||
|
||||
context.root.startsWith(`${context.data}${path.sep}`)
|
||||
)
|
||||
throw new Error(translate(context.env, '数据重载需要独立的数据目录。'));
|
||||
const source = path.join(context.paths.dir_tmp!, 'data');
|
||||
if (
|
||||
source === context.data ||
|
||||
source.startsWith(`${context.data}${path.sep}`) ||
|
||||
context.data.startsWith(`${source}${path.sep}`)
|
||||
)
|
||||
throw new Error(
|
||||
translate(context.env, '暂存数据与已安装的数据目录必须分离。'),
|
||||
);
|
||||
await validateTree(source, context.env);
|
||||
await fs.mkdir(context.data, { recursive: true });
|
||||
const incoming = new Set(await fs.readdir(source));
|
||||
const entries = new Set([...(await fs.readdir(context.data)), ...incoming]);
|
||||
// A Docker volume's root cannot be renamed. Keep it in place and transact
|
||||
// its children, including removals, with backups on the same filesystem.
|
||||
return replaceAndReload(
|
||||
context,
|
||||
[...entries].map((name) => ({
|
||||
source: incoming.has(name) ? path.join(source, name) : undefined,
|
||||
target: path.join(context.data, name),
|
||||
})),
|
||||
);
|
||||
}
|
||||
const selected = staged ?? (await selectedUpgrade(context));
|
||||
const source = selected.source;
|
||||
const staticRoot = selected.static;
|
||||
await validateTree(source, context.env);
|
||||
await validateTree(staticRoot, context.env);
|
||||
await fs.access(path.join(source, 'package.json'));
|
||||
await fs.access(path.join(staticRoot, 'build/app.js'));
|
||||
const entries = await fs.readdir(source);
|
||||
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
|
||||
const relativeData = path.relative(context.root, context.data);
|
||||
if (
|
||||
relativeData &&
|
||||
!relativeData.startsWith('..') &&
|
||||
!path.isAbsolute(relativeData)
|
||||
)
|
||||
reserved.add(relativeData.split(path.sep)[0]!);
|
||||
const replacements = entries
|
||||
.filter((name) => !reserved.has(name))
|
||||
.map((name) => ({
|
||||
source: path.join(source, name),
|
||||
target: path.join(context.root, name),
|
||||
}));
|
||||
replacements.push({ source: staticRoot, target: context.paths.dir_static! });
|
||||
replacements.push({
|
||||
source: path.join(source, 'sample/config.sample.sh'),
|
||||
target: path.join(context.paths.dir_config!, 'config.sample.sh'),
|
||||
});
|
||||
const selection = await prepareUpgradeSelection(context, source, staticRoot);
|
||||
if (selection)
|
||||
replacements.push({ source: selection.source, target: selection.target });
|
||||
try {
|
||||
return await replaceAndReload(context, replacements);
|
||||
} finally {
|
||||
if (selection)
|
||||
await fs.rm(selection.directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { LocalContext } from '../runtime/context';
|
||||
import { fail } from '../../shared/errors';
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
|
||||
/** Preserve only the explicitly installed 2.x command-selection integration. */
|
||||
export async function prepareUpgradeSelection(
|
||||
context: LocalContext,
|
||||
sourceRoot: string,
|
||||
staticRoot: string,
|
||||
): Promise<{ source: string; target: string; directory: string } | undefined> {
|
||||
const cliRoot = context.env.QL_CLI_ROOT;
|
||||
if (!cliRoot) return undefined;
|
||||
if (!path.isAbsolute(cliRoot))
|
||||
fail(translate(context.env, '已选择的 CLI 安装路径必须为绝对路径。'), 2);
|
||||
for (const entry of ['entrypoints.js', 'cronEntrypoint.js'])
|
||||
await fs.access(path.join(cliRoot, 'dist/local', entry));
|
||||
const manifest = JSON.parse(
|
||||
await fs.readFile(path.join(sourceRoot, 'package.json'), 'utf8'),
|
||||
);
|
||||
const version =
|
||||
manifest.version ||
|
||||
(await fs.readFile(path.join(sourceRoot, 'version.yaml'), 'utf8')).match(
|
||||
/^\uFEFF?version:\s*["']?([^\s"']+)/m,
|
||||
)?.[1];
|
||||
if (typeof version !== 'string' || !/^2\./.test(version))
|
||||
fail(
|
||||
translate(context.env, '保留 CLI 入口仅支持已确认的 2.x 升级载荷。'),
|
||||
2,
|
||||
);
|
||||
const relative = 'build/loaders/deps.js';
|
||||
const target = path.join(context.paths.dir_static!, relative);
|
||||
const incoming = path.join(staticRoot, relative);
|
||||
if (
|
||||
!(await fs.lstat(target)).isFile() ||
|
||||
!(await fs.lstat(incoming)).isFile()
|
||||
)
|
||||
fail(
|
||||
translate(context.env, '无法确认已选择的 CLI 命令加载器,请检查安装。'),
|
||||
2,
|
||||
);
|
||||
const loader = await fs.readFile(target, 'utf8');
|
||||
if (
|
||||
!loader.includes('dist/local/entrypoints.js') ||
|
||||
!loader.includes('dist/local/cronEntrypoint.js') ||
|
||||
!loader.includes('QL_CLI_ROOT')
|
||||
)
|
||||
fail(
|
||||
translate(context.env, '无法确认已选择的 CLI 命令加载器,请检查安装。'),
|
||||
2,
|
||||
);
|
||||
await fs.mkdir(context.paths.dir_tmp!, { recursive: true });
|
||||
const directory = await fs.mkdtemp(
|
||||
path.join(context.paths.dir_tmp!, 'selected-loader-'),
|
||||
);
|
||||
try {
|
||||
const source = path.join(directory, 'deps.js');
|
||||
await fs.writeFile(source, loader, { mode: 0o600, flag: 'wx' });
|
||||
return { source, target, directory };
|
||||
} catch (error) {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { LocalContext } from './context';
|
||||
import { checkedProcess } from './process';
|
||||
import { fail } from '../../shared/errors';
|
||||
import { isRecord } from '../../shared/record';
|
||||
import { operationSignal, requestCancellation } from './cancellation';
|
||||
|
||||
export class LocalApi {
|
||||
private token?: string;
|
||||
constructor(private readonly context: LocalContext) {}
|
||||
|
||||
private async credential(): Promise<string> {
|
||||
if (this.token) return this.token;
|
||||
const read = async () => {
|
||||
try {
|
||||
const value: unknown = JSON.parse(
|
||||
await fs.readFile(this.context.paths.file_auth_token!, 'utf8'),
|
||||
);
|
||||
if (
|
||||
isRecord(value) &&
|
||||
typeof value.value === 'string' &&
|
||||
typeof value.expiration === 'number' &&
|
||||
value.expiration > Date.now() / 1000
|
||||
)
|
||||
return value.value;
|
||||
} catch {
|
||||
/* Existing local token generator owns missing/expired credentials. */
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
this.token = await read();
|
||||
if (!this.token) {
|
||||
const compiled = path.join(this.context.root, 'static/build/token.js');
|
||||
const exists = await fs.stat(compiled).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
await checkedProcess(
|
||||
exists ? process.execPath : 'ts-node-transpile-only',
|
||||
[exists ? compiled : path.join(this.context.root, 'back/token.ts')],
|
||||
{
|
||||
cwd: this.context.root,
|
||||
env: this.context.env,
|
||||
capture: true,
|
||||
output: () => {},
|
||||
timeoutMs: 30000,
|
||||
},
|
||||
);
|
||||
this.token = await read();
|
||||
}
|
||||
if (!this.token)
|
||||
fail(
|
||||
translate(
|
||||
this.context.env,
|
||||
'无法获取本机系统令牌,请检查运行中的面板。',
|
||||
),
|
||||
3,
|
||||
);
|
||||
return this.token;
|
||||
}
|
||||
|
||||
async call(
|
||||
endpoint: string,
|
||||
method = 'GET',
|
||||
body?: unknown,
|
||||
): Promise<Record<string, unknown>> {
|
||||
operationSignal()?.throwIfAborted();
|
||||
const token = await this.credential();
|
||||
const port = this.context.env.QlPort || '5700';
|
||||
if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535)
|
||||
fail(translate(this.context.env, '本机面板端口无效。'), 2);
|
||||
const request = requestCancellation(30000);
|
||||
let result: unknown;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/open/${endpoint}`,
|
||||
{
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(body !== undefined
|
||||
? { 'Content-Type': 'application/json' }
|
||||
: {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
redirect: 'error',
|
||||
signal: request.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
fail(
|
||||
translate(
|
||||
this.context.env,
|
||||
'本机 API 拒绝请求(HTTP %s)。',
|
||||
response.status,
|
||||
),
|
||||
);
|
||||
}
|
||||
result = await response.json();
|
||||
} catch {
|
||||
fail(
|
||||
translate(
|
||||
this.context.env,
|
||||
'本机 API 请求失败;请先检查操作结果,不要直接重试写入。',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
request.dispose();
|
||||
}
|
||||
if (!isRecord(result) || result.code !== 200)
|
||||
fail(
|
||||
translate(
|
||||
this.context.env,
|
||||
'本机 API 拒绝请求,请检查面板日志和权限。',
|
||||
),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
// Linux preserves argv boundaries in cmdline. Resolve relative entrypoints from
|
||||
// each process's cwd, not from the operator's cwd or a broad ps substring match.
|
||||
export async function findDirectBackendPids(
|
||||
entry: string,
|
||||
procRoot = '/proc',
|
||||
): Promise<number[]> {
|
||||
const expected = await fs
|
||||
.realpath(entry)
|
||||
.catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return undefined;
|
||||
throw error;
|
||||
});
|
||||
if (!expected) return [];
|
||||
const matches: number[] = [];
|
||||
for (const name of await fs.readdir(procRoot)) {
|
||||
if (!/^[1-9]\d*$/.test(name)) continue;
|
||||
const pid = Number(name);
|
||||
if (!Number.isSafeInteger(pid) || pid === process.pid) continue;
|
||||
const directory = path.join(procRoot, name);
|
||||
try {
|
||||
const command = await fs.readFile(
|
||||
path.join(directory, 'cmdline'),
|
||||
'utf8',
|
||||
);
|
||||
const args = command.split('\0');
|
||||
if (args.at(-1) === '') args.pop();
|
||||
// Both the legacy fallback and the TS fallback launch one script without
|
||||
// Node flags or application arguments. Reject other command shapes.
|
||||
if (args.length !== 2 || !/^node(?:\d+)?$/.test(path.basename(args[0]!)))
|
||||
continue;
|
||||
const script = args[1]!;
|
||||
if (!script) continue;
|
||||
const absolute = path.isAbsolute(script)
|
||||
? script
|
||||
: path.resolve(await fs.realpath(path.join(directory, 'cwd')), script);
|
||||
if ((await fs.realpath(absolute)) === expected) matches.push(pid);
|
||||
} catch (error) {
|
||||
// Processes may exit while enumerating; foreign users can deny inspection.
|
||||
if (
|
||||
!['ENOENT', 'ESRCH', 'EACCES', 'EPERM'].includes(
|
||||
(error as NodeJS.ErrnoException).code ?? '',
|
||||
)
|
||||
)
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { constants } from 'node:os';
|
||||
|
||||
const storage = new AsyncLocalStorage<AbortSignal>();
|
||||
// Match the six signals handled by the retained 2.x task.sh entrypoint.
|
||||
export const commandSignals = [
|
||||
'SIGINT',
|
||||
'SIGTERM',
|
||||
'SIGHUP',
|
||||
'SIGQUIT',
|
||||
'SIGALRM',
|
||||
'SIGTSTP',
|
||||
] as const;
|
||||
|
||||
export function commandSignal(
|
||||
reason: unknown,
|
||||
): (typeof commandSignals)[number] {
|
||||
return commandSignals.find((name) => name === reason) ?? 'SIGTERM';
|
||||
}
|
||||
|
||||
export const operationSignal = (): AbortSignal | undefined =>
|
||||
storage.getStore();
|
||||
|
||||
export function interruptedCode(signal?: AbortSignal): number | undefined {
|
||||
if (!signal?.aborted) return undefined;
|
||||
const reason: unknown = signal.reason;
|
||||
const name = commandSignal(reason);
|
||||
return 128 + constants.signals[name];
|
||||
}
|
||||
|
||||
export async function cancellableOperation<T>(
|
||||
signal: AbortSignal | undefined,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!signal) return operation();
|
||||
signal.throwIfAborted();
|
||||
const result = await storage.run(signal, operation);
|
||||
signal.throwIfAborted();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function withCommandCancellation(
|
||||
operation: (signal: AbortSignal) => Promise<number>,
|
||||
): Promise<number> {
|
||||
const controller = new AbortController();
|
||||
const listeners = commandSignals.map((name) => {
|
||||
const listener = () => controller.abort(name);
|
||||
process.on(name, listener);
|
||||
return { name, listener };
|
||||
});
|
||||
try {
|
||||
return await operation(controller.signal);
|
||||
} finally {
|
||||
for (const { name, listener } of listeners) process.off(name, listener);
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery must finish even after the operation that triggered it was cancelled.
|
||||
export function withoutCancellation<T>(
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return storage.exit(operation);
|
||||
}
|
||||
|
||||
export function requestCancellation(timeoutMs: number): {
|
||||
signal: AbortSignal;
|
||||
dispose: () => void;
|
||||
} {
|
||||
const parent = operationSignal();
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(parent?.reason);
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(new Error('Request timed out.')),
|
||||
timeoutMs,
|
||||
);
|
||||
timeout.unref();
|
||||
parent?.addEventListener('abort', abort, { once: true });
|
||||
if (parent?.aborted) abort();
|
||||
return {
|
||||
signal: controller.signal,
|
||||
dispose: () => {
|
||||
clearTimeout(timeout);
|
||||
parent?.removeEventListener('abort', abort);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import { extendedLifecycle } from './lifecycle';
|
||||
import { within } from './files';
|
||||
import fs from 'node:fs/promises';
|
||||
import { writeSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { LocalContext } from './context';
|
||||
import { LocalApi } from './api';
|
||||
import { CliError } from '../../shared/errors';
|
||||
import { withOperationOutput } from './output';
|
||||
import { cancellableOperation, interruptedCode } from './cancellation';
|
||||
|
||||
export async function loggedOperation<T>(
|
||||
context: LocalContext,
|
||||
action: string,
|
||||
operation: () => Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ result: T; logPath: string }> {
|
||||
if (!/^[a-z]+$/.test(action))
|
||||
throw new Error(translate(context.env, '命令日志目录无效。'));
|
||||
const start = Date.now(),
|
||||
started = Math.floor(start / 1000);
|
||||
const date = new Date(start);
|
||||
const stamp = [
|
||||
date.getFullYear(),
|
||||
date.getMonth() + 1,
|
||||
date.getDate(),
|
||||
date.getHours(),
|
||||
date.getMinutes(),
|
||||
date.getSeconds(),
|
||||
]
|
||||
.map((value) => String(value).padStart(2, '0'))
|
||||
.join('-');
|
||||
const logPath = context.env.real_log_path || `${action}/${stamp}.log`;
|
||||
const file = within(context.paths.dir_log!, logPath, context.env);
|
||||
const persist = context.env.real_time !== 'true';
|
||||
if (persist) await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
const log = persist ? await fs.open(file, 'a', 0o600) : undefined;
|
||||
const output = (chunk: Buffer) => {
|
||||
let offset = 0;
|
||||
while (log && offset < chunk.length)
|
||||
offset += writeSync(log.fd, chunk, offset);
|
||||
if (context.env.real_time === 'true' || context.env.no_tee !== 'true')
|
||||
process.stderr.write(chunk);
|
||||
};
|
||||
const id = Number(context.env.ID);
|
||||
const extended = await extendedLifecycle(context);
|
||||
const executionId =
|
||||
extended && context.env.QL_EXECUTION_ORIGIN === 'scheduled_system'
|
||||
? `legacy-system:${started}:${randomUUID()}`
|
||||
: undefined;
|
||||
const api = new LocalApi(context);
|
||||
const report = async (final: boolean, code: number) => {
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return;
|
||||
try {
|
||||
await api.call('crons/status', 'PUT', {
|
||||
ids: [id],
|
||||
status: final ? '1' : '0',
|
||||
pid: String(process.pid),
|
||||
log_path: logPath,
|
||||
last_execution_time: started,
|
||||
last_running_time: final ? Math.floor((Date.now() - start) / 1000) : 0,
|
||||
...(final && extended ? { exit_code: code } : {}),
|
||||
...(executionId ? { execution_id: executionId } : {}),
|
||||
});
|
||||
} catch {
|
||||
output(
|
||||
Buffer.from(
|
||||
translate(context.env, '命令状态上报失败,请检查本机面板。\n'),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
let code = 1;
|
||||
try {
|
||||
await report(false, 0);
|
||||
if (!['repo', 'raw'].includes(action))
|
||||
output(
|
||||
Buffer.from(
|
||||
translate(context.env, '## 开始执行... %s\n', date.toISOString()),
|
||||
),
|
||||
);
|
||||
const result = await withOperationOutput(output, () =>
|
||||
cancellableOperation(signal, operation),
|
||||
);
|
||||
code = 0;
|
||||
return { result, logPath };
|
||||
} catch (error) {
|
||||
code =
|
||||
interruptedCode(signal) ??
|
||||
(error instanceof CliError ? error.exitCode : 1);
|
||||
output(
|
||||
Buffer.from(
|
||||
`Command failed (exit ${code}); inspect the command result.\n`,
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await report(true, code);
|
||||
if (!['repo', 'raw'].includes(action))
|
||||
output(
|
||||
Buffer.from(
|
||||
translate(
|
||||
context.env,
|
||||
'\n## 执行结束... %s 退出码 %s\n',
|
||||
new Date().toISOString(),
|
||||
code,
|
||||
),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await log?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { runProcess } from './process';
|
||||
import { fail } from '../../shared/errors';
|
||||
|
||||
export interface ContainerEnvironmentOptions {
|
||||
root: string;
|
||||
data: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
systemDirectory?: string;
|
||||
}
|
||||
|
||||
async function writableDirectory(directory: string): Promise<boolean> {
|
||||
try {
|
||||
if (!(await fs.stat(directory)).isDirectory()) return false;
|
||||
await fs.access(directory, fs.constants.W_OK | fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function requireContainerDirectory(
|
||||
directory: string,
|
||||
recursive: boolean,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<void> {
|
||||
if (await writableDirectory(directory)) return;
|
||||
const uid = process.getuid?.();
|
||||
const gid = process.getgid?.();
|
||||
if (uid !== undefined && gid !== undefined && uid !== 0) {
|
||||
await runProcess(
|
||||
'chown',
|
||||
[...(recursive ? ['-R'] : []), `${uid}:${gid}`, directory],
|
||||
{ env, capture: true, stderrOutput: () => {} },
|
||||
).catch(() => undefined);
|
||||
if (await writableDirectory(directory)) return;
|
||||
}
|
||||
fail(
|
||||
translate(
|
||||
env,
|
||||
'容器目录不可写或不可访问:%s(UID %s)。请检查挂载目录的所有者和权限。',
|
||||
directory,
|
||||
uid ?? 'unknown',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function appendNetworkEntries(
|
||||
filename: string,
|
||||
entries: { matches: (line: string) => boolean; value: string }[],
|
||||
warnings: string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<void> {
|
||||
try {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(filename, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
content = '';
|
||||
}
|
||||
const lines = content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.split('#', 1)[0]!.trim());
|
||||
const missing = entries.filter((entry) => !lines.some(entry.matches));
|
||||
if (!missing.length) return;
|
||||
// Append in place: Docker may mount these files individually, so rename
|
||||
// based replacement would fail with EBUSY. Keep existing administrator data.
|
||||
await fs.appendFile(
|
||||
filename,
|
||||
`${content && !content.endsWith('\n') ? '\n' : ''}${missing
|
||||
.map((entry) => entry.value)
|
||||
.join('\n')}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
translate(
|
||||
env,
|
||||
'无法初始化 %s:%s',
|
||||
filename,
|
||||
(error as NodeJS.ErrnoException).code ?? 'IO_ERROR',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Container-only preparation; no package installation or service startup. */
|
||||
export async function prepareContainerEnvironment(
|
||||
options: ContainerEnvironmentOptions,
|
||||
): Promise<{ env: NodeJS.ProcessEnv; warnings: string[] }> {
|
||||
const { root, data } = options;
|
||||
if (!path.isAbsolute(root) || !path.isAbsolute(data))
|
||||
fail(translate(options.env, '容器安装目录和数据目录必须为绝对路径。'), 2);
|
||||
const env = { ...options.env };
|
||||
await requireContainerDirectory(root, true, env);
|
||||
await requireContainerDirectory(data, false, env);
|
||||
// Do not create probes inside a user volume, or remove an existing .tmp.
|
||||
if (!env.HOME || !(await writableDirectory(env.HOME))) {
|
||||
env.HOME = path.join(root, '.tmp');
|
||||
await fs.mkdir(env.HOME, { recursive: true });
|
||||
await requireContainerDirectory(env.HOME, false, env);
|
||||
}
|
||||
const warnings: string[] = [];
|
||||
const system = options.systemDirectory ?? '/etc';
|
||||
const alpine = await fs.stat(path.join(system, 'alpine-release')).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (alpine)
|
||||
await appendNetworkEntries(
|
||||
path.join(system, 'resolv.conf'),
|
||||
[
|
||||
{
|
||||
matches: (line) =>
|
||||
/^options\s/.test(line) && line.split(/\s+/).includes('ndots:0'),
|
||||
value: 'options ndots:0',
|
||||
},
|
||||
],
|
||||
warnings,
|
||||
env,
|
||||
);
|
||||
await appendNetworkEntries(
|
||||
path.join(system, 'hosts'),
|
||||
[
|
||||
{
|
||||
matches: (line) =>
|
||||
/^127\.0\.0\.1\s/.test(line) &&
|
||||
line.split(/\s+/).slice(1).includes('localhost'),
|
||||
value: '127.0.0.1 localhost',
|
||||
},
|
||||
{
|
||||
matches: (line) =>
|
||||
/^::1\s/.test(line) &&
|
||||
line.split(/\s+/).slice(1).includes('localhost'),
|
||||
value: '::1 localhost ip6-localhost ip6-loopback',
|
||||
},
|
||||
],
|
||||
warnings,
|
||||
env,
|
||||
);
|
||||
return { env, warnings };
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { type LocalContext, sourceEnvironment } from './context';
|
||||
import { prepareContainerEnvironment } from './containerEnvironment';
|
||||
import {
|
||||
executableAvailable,
|
||||
repairConfiguration,
|
||||
startPanel,
|
||||
stopPanel,
|
||||
} from '../maintenance/operator';
|
||||
import { launchStartupHook } from '../maintenance/bootstrap';
|
||||
import { stopBot } from '../maintenance/bot';
|
||||
import { runProcess } from './process';
|
||||
import {
|
||||
cancellableOperation,
|
||||
interruptedCode,
|
||||
withoutCancellation,
|
||||
} from './cancellation';
|
||||
|
||||
export interface ContainerServices {
|
||||
start: typeof startPanel;
|
||||
stop: typeof stopPanel;
|
||||
hook: typeof launchStartupHook;
|
||||
}
|
||||
|
||||
async function stopStartupGroups(pids: number[]): Promise<void> {
|
||||
const alive = new Set(pids);
|
||||
const send = (pid: number, signal: NodeJS.Signals | 0): boolean => {
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
for (const pid of alive) if (!send(pid, 'SIGTERM')) alive.delete(pid);
|
||||
// The hook CLI gives its subprocess groups ten seconds before SIGKILL.
|
||||
// Let that owner finish cleanup before terminating the owner itself.
|
||||
const deadline = Date.now() + 15000;
|
||||
while (alive.size && Date.now() < deadline) {
|
||||
await delay(50);
|
||||
for (const pid of alive) if (!send(pid, 0)) alive.delete(pid);
|
||||
}
|
||||
for (const pid of alive) send(pid, 'SIGKILL');
|
||||
}
|
||||
|
||||
/** Owns a dedicated container, with an external init responsible for orphan reaping. */
|
||||
export async function runContainer(
|
||||
context: LocalContext,
|
||||
signal: AbortSignal,
|
||||
options: {
|
||||
systemDirectory?: string;
|
||||
services?: ContainerServices;
|
||||
event?: (event: Record<string, unknown>) => void;
|
||||
} = {},
|
||||
): Promise<number> {
|
||||
const services = options.services ?? {
|
||||
start: startPanel,
|
||||
stop: stopPanel,
|
||||
hook: launchStartupHook,
|
||||
};
|
||||
let runtime = context;
|
||||
let serviceAttempted = false;
|
||||
let botAttempted = false;
|
||||
const hooks: number[] = [];
|
||||
try {
|
||||
return await cancellableOperation(signal, async () => {
|
||||
const prepared = await prepareContainerEnvironment({
|
||||
root: context.root,
|
||||
data: context.data,
|
||||
env: context.env,
|
||||
systemDirectory: options.systemDirectory,
|
||||
});
|
||||
runtime = { ...context, env: prepared.env };
|
||||
for (const warning of prepared.warnings)
|
||||
options.event?.({ event: 'warning', message: warning });
|
||||
await repairConfiguration(runtime);
|
||||
runtime.env = await sourceEnvironment(
|
||||
runtime.env,
|
||||
[
|
||||
runtime.paths.file_config_user!,
|
||||
path.join(runtime.paths.dir_preload!, 'lang_env.sh'),
|
||||
],
|
||||
[],
|
||||
{ signal },
|
||||
);
|
||||
runtime.env.BACK_PORT = runtime.env.QlPort || '5700';
|
||||
runtime.env.GRPC_PORT = runtime.env.QlGrpcPort || '5500';
|
||||
const scheduler =
|
||||
runtime.env.QL_SCHEDULER ||
|
||||
((await executableAvailable('crond', runtime.env)) ? 'system' : 'node');
|
||||
if (scheduler !== 'node' && scheduler !== 'system')
|
||||
throw new Error(
|
||||
translate(runtime.env, 'QL_SCHEDULER 必须为 node 或 system。'),
|
||||
);
|
||||
runtime.env.QL_SCHEDULER = scheduler;
|
||||
// Set scheduler mode before the backend starts, so both agree on ownership.
|
||||
signal.throwIfAborted();
|
||||
serviceAttempted = true;
|
||||
const service = await services.start(runtime);
|
||||
signal.throwIfAborted();
|
||||
for (const [setting, action] of [
|
||||
['AutoStartBot', 'bot'],
|
||||
['EnableExtraShell', 'extra'],
|
||||
] as const) {
|
||||
if (runtime.env[setting] === 'true') {
|
||||
if (action === 'bot') botAttempted = true;
|
||||
hooks.push(await services.hook(runtime, action));
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
options.event?.({
|
||||
event: 'started',
|
||||
scheduler,
|
||||
manager: service.manager,
|
||||
});
|
||||
if (scheduler === 'system') {
|
||||
const result = await runProcess('crond', ['-f'], {
|
||||
cwd: runtime.root,
|
||||
env: runtime.env,
|
||||
signal,
|
||||
graceMs: 3000,
|
||||
});
|
||||
if (signal.aborted) return interruptedCode(signal)!;
|
||||
// A foreground scheduler exiting, even with zero, means supervision failed.
|
||||
throw new Error(
|
||||
translate(runtime.env, '容器调度器意外退出(%s)。', result.code),
|
||||
);
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal.aborted) return resolve();
|
||||
const keepAlive = setInterval(() => {}, 60000);
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearInterval(keepAlive);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
return interruptedCode(signal)!;
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal.aborted) return interruptedCode(signal)!;
|
||||
throw error;
|
||||
} finally {
|
||||
await withoutCancellation(async () => {
|
||||
try {
|
||||
await stopStartupGroups(hooks);
|
||||
} finally {
|
||||
try {
|
||||
// The installer can exit after detaching Python into a new session.
|
||||
// Stop it only after installation/recovery has finished, scoped to
|
||||
// this container's data directory rather than the installer PID.
|
||||
if (botAttempted) await stopBot(runtime);
|
||||
} finally {
|
||||
if (serviceAttempted) await services.stop(runtime);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import path from 'node:path';
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import fs from 'node:fs';
|
||||
import { runProcess } from './process';
|
||||
import { fail } from '../../shared/errors';
|
||||
import type { Invocation } from '../../shared/cli/arguments';
|
||||
|
||||
export interface LocalContext {
|
||||
root: string;
|
||||
data: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
paths: Record<string, string>;
|
||||
}
|
||||
|
||||
export function createContext(
|
||||
options: Record<string, string | boolean | undefined> = {},
|
||||
inherited: NodeJS.ProcessEnv = process.env,
|
||||
): LocalContext {
|
||||
const configuredRoot =
|
||||
typeof options.root === 'string' ? options.root : inherited.QL_DIR;
|
||||
if (!configuredRoot || !path.isAbsolute(configuredRoot))
|
||||
fail(
|
||||
translate(inherited, '本机命令需要通过 --root 或 QL_DIR 指定绝对路径。'),
|
||||
2,
|
||||
);
|
||||
const root = path.resolve(configuredRoot);
|
||||
const data = path.resolve(
|
||||
typeof options['data-dir'] === 'string'
|
||||
? options['data-dir']
|
||||
: inherited.QL_DATA_DIR || path.join(root, 'data'),
|
||||
);
|
||||
if (!fs.statSync(root, { throwIfNoEntry: false })?.isDirectory())
|
||||
fail(translate(inherited, '面板安装目录不存在或不是目录。'), 2);
|
||||
const paths: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries({
|
||||
root,
|
||||
data,
|
||||
tmp: path.join(root, '.tmp'),
|
||||
static: path.join(root, 'static'),
|
||||
shell: path.join(root, 'shell'),
|
||||
preload: path.join(root, 'shell/preload'),
|
||||
sample: path.join(root, 'sample'),
|
||||
config: path.join(data, 'config'),
|
||||
scripts: path.join(data, 'scripts'),
|
||||
repo: path.join(data, 'repo'),
|
||||
raw: path.join(data, 'raw'),
|
||||
log: path.join(data, 'log'),
|
||||
db: path.join(data, 'db'),
|
||||
dep: path.join(data, 'deps'),
|
||||
list_tmp: path.join(data, 'log/.tmp'),
|
||||
update_log: path.join(data, 'log/update'),
|
||||
}))
|
||||
paths[`dir_${name}`] = value;
|
||||
for (const [name, value] of Object.entries({
|
||||
config_sample: 'sample/config.sample.sh',
|
||||
auth_sample: 'sample/auth.sample.json',
|
||||
task_sample: 'sample/task.sample.sh',
|
||||
extra_sample: 'sample/extra.sample.sh',
|
||||
notify_py_sample: 'sample/notify.py',
|
||||
notify_js_sample: 'sample/notify.js',
|
||||
test_js_sample: 'sample/ql_sample.js',
|
||||
test_py_sample: 'sample/ql_sample.py',
|
||||
env: 'shell/preload/env.sh',
|
||||
preload_js: 'shell/preload/sitecustomize.js',
|
||||
}))
|
||||
paths[`file_${name}`] = path.join(root, value);
|
||||
for (const [name, value] of Object.entries({
|
||||
config_user: 'config/config.sh',
|
||||
auth_user: 'config/auth.json',
|
||||
auth_token: 'config/token.json',
|
||||
extra_shell: 'config/extra.sh',
|
||||
task_before: 'config/task_before.sh',
|
||||
task_before_js: 'config/task_before.js',
|
||||
task_before_py: 'config/task_before.py',
|
||||
task_after: 'config/task_after.sh',
|
||||
notify_py: 'scripts/notify.py',
|
||||
notify_js: 'scripts/sendNotify.js',
|
||||
test_js: 'scripts/ql_sample.js',
|
||||
test_py: 'scripts/ql_sample.py',
|
||||
sharecode: 'config/sharecode.sh',
|
||||
}))
|
||||
paths[`file_${name}`] = path.join(data, value);
|
||||
paths.dep_notify_py = path.join(data, 'deps/notify.py');
|
||||
paths.dep_notify_js = path.join(data, 'deps/sendNotify.js');
|
||||
paths.list_crontab_user = path.join(data, 'config/crontab.list');
|
||||
return {
|
||||
root,
|
||||
data,
|
||||
paths,
|
||||
env: {
|
||||
...inherited,
|
||||
...paths,
|
||||
QL_DIR: root,
|
||||
QL_DATA_DIR: data,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
cmd_task: 'task',
|
||||
cmd_ql: 'ql',
|
||||
is_macos: process.platform === 'darwin' ? '1' : '0',
|
||||
is_termux: inherited.PATH?.includes('com.termux') ? '1' : '0',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// This bridge interprets user configuration only; it never sources product shell/*.sh.
|
||||
// Move the parent transport from fd 3 to an internal descriptor before user
|
||||
// configuration can reuse fd 3 (or fd 9 for a lock).
|
||||
export async function sourceEnvironment(
|
||||
env: NodeJS.ProcessEnv,
|
||||
files: string[],
|
||||
args: string[] = [],
|
||||
options: {
|
||||
command?: string;
|
||||
signal?: AbortSignal;
|
||||
output?: (chunk: Buffer) => void;
|
||||
stderrOutput?: (chunk: Buffer) => void;
|
||||
} = {},
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
const script = `exec 19>&3 3>&-
|
||||
__ql_env="$1"; __ql_files="$2"; __ql_command="$3"; shift 3
|
||||
set -a
|
||||
while IFS= read -r __ql_file; do
|
||||
if [ -f "$__ql_file" ]; then . "$__ql_file" "$@"; fi
|
||||
done <<< "$__ql_files"
|
||||
if [ -n "$__ql_command" ]; then eval "$__ql_command"; fi
|
||||
__ql_shopts=""
|
||||
while read -r __ql_builtin __ql_state __ql_option; do
|
||||
if [ "$__ql_state" = '-s' ]; then
|
||||
__ql_shopts="\${__ql_shopts:+$__ql_shopts:}$__ql_option"
|
||||
fi
|
||||
done < <(shopt -p)
|
||||
__ql_shellopts="$SHELLOPTS" __ql_bashopts="$__ql_shopts" "$__ql_env" -0 >&19
|
||||
`;
|
||||
const execution = await runProcess(
|
||||
'bash',
|
||||
[
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
'-c',
|
||||
script,
|
||||
'ql-config',
|
||||
'/usr/bin/env',
|
||||
files.join('\n'),
|
||||
options.command ?? '',
|
||||
...args,
|
||||
],
|
||||
{
|
||||
env,
|
||||
capture: true,
|
||||
captureFd: 3,
|
||||
maxCaptureBytes: 4 * 1024 * 1024,
|
||||
timeoutMs: 30000,
|
||||
graceMs: 1000,
|
||||
output: options.output,
|
||||
stderrOutput: options.stderrOutput,
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
if (options.signal?.aborted) {
|
||||
const error = new Error(
|
||||
translate(env, '用户配置加载已取消。'),
|
||||
) as NodeJS.ErrnoException;
|
||||
error.name = 'AbortError';
|
||||
error.code = 'ABORT_ERR';
|
||||
throw error;
|
||||
}
|
||||
if (execution.code !== 0)
|
||||
throw new Error(translate(env, '用户配置加载失败。'));
|
||||
try {
|
||||
// NUL framing preserves newlines, '=' characters and exported Bash
|
||||
// functions without launching another Node process to serialize JSON.
|
||||
if (!execution.stdout.endsWith('\0')) throw new Error();
|
||||
const result: NodeJS.ProcessEnv = Object.create(null);
|
||||
for (const entry of execution.stdout.slice(0, -1).split('\0')) {
|
||||
const separator = entry.indexOf('=');
|
||||
if (separator <= 0) throw new Error();
|
||||
result[entry.slice(0, separator)] = entry.slice(separator + 1);
|
||||
}
|
||||
if (
|
||||
typeof result.__ql_shellopts !== 'string' ||
|
||||
typeof result.__ql_bashopts !== 'string'
|
||||
)
|
||||
throw new Error();
|
||||
result.SHELLOPTS = result.__ql_shellopts
|
||||
.split(':')
|
||||
.filter((value) => value !== 'allexport')
|
||||
.join(':');
|
||||
result.BASHOPTS = result.__ql_bashopts;
|
||||
result.QL_CLI_SHELLOPTS = result.SHELLOPTS;
|
||||
result.QL_CLI_BASHOPTS = result.BASHOPTS;
|
||||
for (const key of Object.keys(result))
|
||||
if (key.startsWith('__ql_')) delete result[key];
|
||||
return result;
|
||||
} catch {
|
||||
throw new Error(translate(env, '用户配置返回了无效的环境数据。'));
|
||||
}
|
||||
}
|
||||
|
||||
export async function localContext(
|
||||
command: Pick<Invocation, 'values' | 'positionals'>,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<LocalContext> {
|
||||
const context = createContext(command.values);
|
||||
context.env = await sourceEnvironment(
|
||||
context.env,
|
||||
[
|
||||
context.paths.file_config_user!,
|
||||
path.join(context.paths.dir_preload!, 'lang_env.sh'),
|
||||
],
|
||||
command.positionals,
|
||||
options,
|
||||
);
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { fail } from '../../shared/errors';
|
||||
|
||||
export function within(
|
||||
root: string,
|
||||
relative: string,
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const target = path.resolve(root, relative);
|
||||
const relation = path.relative(root, target);
|
||||
if (
|
||||
!relation ||
|
||||
relation === '..' ||
|
||||
relation.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relation)
|
||||
)
|
||||
fail(translate(environment, '路径必须位于其管理目录内部。'), 2);
|
||||
return target;
|
||||
}
|
||||
|
||||
export async function regularFiles(root: string): Promise<string[]> {
|
||||
const pending = [''];
|
||||
const result: string[] = [];
|
||||
while (pending.length) {
|
||||
const relative = pending.pop()!;
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(path.join(root, relative), {
|
||||
withFileTypes: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
||||
throw error;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const name = path.join(relative, entry.name);
|
||||
if (entry.isDirectory()) pending.push(name);
|
||||
else if (entry.isFile()) result.push(name);
|
||||
}
|
||||
}
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
export async function atomicWrite(
|
||||
target: string,
|
||||
contents: string | Buffer,
|
||||
mode = 0o600,
|
||||
): Promise<void> {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
const temp = `${target}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(temp, contents, { mode, flag: 'wx' });
|
||||
await fs.rename(temp, target);
|
||||
} finally {
|
||||
await fs.rm(temp, { force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { LocalContext } from './context';
|
||||
import { executableAvailable } from '../maintenance/operator';
|
||||
import { checkedProcess } from './process';
|
||||
import { fail } from '../../shared/errors';
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
|
||||
export async function registerHostServices(
|
||||
context: LocalContext,
|
||||
os: string,
|
||||
): Promise<void> {
|
||||
const options = { cwd: context.root, env: context.env };
|
||||
const run = (program: string, args: string[]) =>
|
||||
checkedProcess(
|
||||
process.getuid?.() === 0 ? program : 'sudo',
|
||||
process.getuid?.() === 0 ? args : [program, ...args],
|
||||
options,
|
||||
);
|
||||
if (await executableAvailable('rc-update', context.env)) {
|
||||
for (const name of ['nginx', 'crond']) {
|
||||
await run('rc-update', ['add', name, 'default']);
|
||||
// Bootstrap already started/reloaded nginx directly. Starting it again
|
||||
// through init cannot adopt that PID and can fail or compete for its port.
|
||||
if (name === 'crond') await run('rc-service', [name, 'start']);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
['debian', 'ubuntu'].includes(os) &&
|
||||
(await executableAvailable('systemctl', context.env))
|
||||
) {
|
||||
// Minimal Debian/Ubuntu installations need not contain a cron daemon.
|
||||
await run('apt-get', ['install', '-y', 'cron']);
|
||||
await run('systemctl', ['enable', 'nginx']);
|
||||
await run('systemctl', ['enable', '--now', 'cron']);
|
||||
return;
|
||||
}
|
||||
fail(
|
||||
translate(
|
||||
context.env,
|
||||
'主机开机注册需要 OpenRC 或 systemd;仅在其他管理器负责服务时使用 --no-startup。',
|
||||
),
|
||||
2,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { LocalContext } from './context';
|
||||
|
||||
// The released 2.20 API rejects unknown lifecycle fields and has no dashboard record API.
|
||||
// Read the installed panel version, never the independently versioned CLI package.
|
||||
export async function extendedLifecycle(
|
||||
context: LocalContext,
|
||||
): Promise<boolean> {
|
||||
if (context.env.QL_CLI_LIFECYCLE === 'legacy') return false;
|
||||
if (context.env.QL_CLI_LIFECYCLE === 'extended') return true;
|
||||
let version: string | undefined;
|
||||
try {
|
||||
const manifest: unknown = JSON.parse(
|
||||
await fs.readFile(path.join(context.root, 'package.json'), 'utf8'),
|
||||
);
|
||||
if (
|
||||
manifest &&
|
||||
typeof manifest === 'object' &&
|
||||
'version' in manifest &&
|
||||
typeof manifest.version === 'string'
|
||||
)
|
||||
version = manifest.version;
|
||||
} catch {}
|
||||
if (version === undefined) {
|
||||
try {
|
||||
// Official 2.x images may omit package.version. Read only the top-level
|
||||
// scalar release field; no YAML dependency or expression evaluation.
|
||||
const release = await fs.readFile(
|
||||
path.join(context.root, 'version.yaml'),
|
||||
'utf8',
|
||||
);
|
||||
const entries = release
|
||||
.replace(/^\uFEFF/, '')
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => /^version:/.test(line));
|
||||
if (entries.length !== 1) return false;
|
||||
const scalar =
|
||||
/^version:[ \t]*(?:"([^"]+)"|'([^']+)'|([^\s#]+))[ \t]*(?:#.*)?$/.exec(
|
||||
entries[0]!,
|
||||
);
|
||||
version = scalar?.[1] ?? scalar?.[2] ?? scalar?.[3];
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const match = /^2\.(\d+)\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.exec(
|
||||
version ?? '',
|
||||
);
|
||||
return !!match && Number(match[1]) >= 21;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
type Sink = (chunk: Buffer) => void;
|
||||
const storage = new AsyncLocalStorage<Sink>();
|
||||
|
||||
export function operationOutput(chunk: Buffer): void {
|
||||
const sink = storage.getStore();
|
||||
if (sink) sink(chunk);
|
||||
else process.stderr.write(chunk);
|
||||
}
|
||||
|
||||
export function withOperationOutput<T>(
|
||||
sink: Sink,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return storage.run(sink, operation);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { constants } from 'node:os';
|
||||
import { fail } from '../../shared/errors';
|
||||
import { operationOutput } from './output';
|
||||
import { commandSignal, operationSignal } from './cancellation';
|
||||
import { shellOptionPrelude } from '../execution/shellOptions';
|
||||
|
||||
export interface ProcessOptions {
|
||||
stdin?: 'inherit' | 'ignore';
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
// A private fd reports T(ask)/A(fter). Only the task phase owns the timer.
|
||||
timeoutControl?: { marker: string };
|
||||
graceMs?: number;
|
||||
capture?: boolean;
|
||||
captureFd?: 1 | 3;
|
||||
output?: (chunk: Buffer) => void;
|
||||
stderrOutput?: (chunk: Buffer) => void;
|
||||
maxCaptureBytes?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
export interface ProcessResult {
|
||||
code: number;
|
||||
stdout: string;
|
||||
signal: NodeJS.Signals | null;
|
||||
timedOut: boolean;
|
||||
cleanupStarted?: boolean;
|
||||
}
|
||||
|
||||
function cancellationSignal(signal?: AbortSignal): NodeJS.Signals {
|
||||
// Only runner-supported termination signals may cross the abort boundary.
|
||||
// Generic AbortController callers retain the default SIGTERM contract.
|
||||
const reason: unknown = signal?.reason;
|
||||
return commandSignal(reason);
|
||||
}
|
||||
|
||||
export function cancellationExitCode(signal?: AbortSignal): number {
|
||||
return 128 + constants.signals[cancellationSignal(signal)];
|
||||
}
|
||||
|
||||
function signalGroup(child: ChildProcess, signal: NodeJS.Signals): void {
|
||||
if (!child.pid) return;
|
||||
// A POSIX process group can outlive its leader while descendants still hold
|
||||
// stdout/stderr. Keep signalling the group until runProcess receives close.
|
||||
if (
|
||||
process.platform === 'win32' &&
|
||||
(child.exitCode !== null || child.signalCode !== null)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
process.kill(process.platform === 'win32' ? child.pid : -child.pid, signal);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return;
|
||||
if ((error as NodeJS.ErrnoException).code === 'EPERM') {
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runProcess(
|
||||
program: string,
|
||||
args: readonly string[],
|
||||
options: ProcessOptions = {},
|
||||
): Promise<ProcessResult> {
|
||||
options = { ...options, signal: options.signal ?? operationSignal() };
|
||||
// Recognize only the command forms used by our fixed Bash bridges. A -c
|
||||
// after a script name or option terminator belongs to the script's argv.
|
||||
let commandIndex = 0;
|
||||
while (['--noprofile', '--norc'].includes(args[commandIndex] ?? ''))
|
||||
commandIndex++;
|
||||
if (
|
||||
/(?:^|\/)bash$/.test(program) &&
|
||||
args[commandIndex] === '-c' &&
|
||||
typeof args[commandIndex + 1] === 'string' &&
|
||||
typeof (options.env ?? process.env).QL_CLI_SHELLOPTS === 'string'
|
||||
) {
|
||||
args = [...args];
|
||||
(args as string[])[commandIndex + 1] =
|
||||
shellOptionPrelude + args[commandIndex + 1];
|
||||
}
|
||||
if (options.signal?.aborted)
|
||||
return {
|
||||
code: cancellationExitCode(options.signal),
|
||||
stdout: '',
|
||||
signal: cancellationSignal(options.signal),
|
||||
timedOut: false,
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(program, [...args], {
|
||||
cwd: options.cwd,
|
||||
env: options.env ?? process.env,
|
||||
detached: process.platform !== 'win32',
|
||||
stdio:
|
||||
options.captureFd === 3 || options.timeoutControl
|
||||
? [options.stdin ?? 'ignore', 'pipe', 'pipe', 'pipe']
|
||||
: [options.stdin ?? 'ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
let timedOut = false;
|
||||
let overflow = false;
|
||||
let outputFailure: unknown;
|
||||
let aborted = false;
|
||||
let terminating = false;
|
||||
let cleanupStarted = false;
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
let escalation: NodeJS.Timeout | undefined;
|
||||
const terminate = (signal: NodeJS.Signals) => {
|
||||
terminating = true;
|
||||
signalGroup(child, signal);
|
||||
escalation ??= setTimeout(
|
||||
() => signalGroup(child, 'SIGKILL'),
|
||||
options.graceMs ?? 10000,
|
||||
);
|
||||
escalation.unref();
|
||||
};
|
||||
const abort = () => {
|
||||
aborted = true;
|
||||
terminate(cancellationSignal(options.signal));
|
||||
};
|
||||
const startTimeout = () => {
|
||||
if (!options.timeoutMs || options.timeoutMs <= 0 || timedOut || aborted) return;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
if (options.timeoutControl) {
|
||||
try {
|
||||
writeFileSync(options.timeoutControl.marker, '', { mode: 0o600 });
|
||||
} catch (error) {
|
||||
outputFailure = error;
|
||||
}
|
||||
}
|
||||
terminate('SIGINT');
|
||||
}, options.timeoutMs);
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(escalation);
|
||||
options.signal?.removeEventListener('abort', abort);
|
||||
};
|
||||
child.once('error', () => {
|
||||
cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
translate(
|
||||
options.env ?? process.env,
|
||||
'无法启动必要的可执行程序:%s',
|
||||
program,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
const emit = (chunk: Buffer, sink = options.output) => {
|
||||
if (outputFailure) return;
|
||||
try {
|
||||
(sink ?? operationOutput)(chunk);
|
||||
} catch (error) {
|
||||
outputFailure =
|
||||
error ||
|
||||
new Error(
|
||||
translate(options.env ?? process.env, '子进程输出写入失败。'),
|
||||
);
|
||||
signalGroup(child, 'SIGKILL');
|
||||
}
|
||||
};
|
||||
const capture = (chunk: Buffer) => {
|
||||
if (options.capture) {
|
||||
bytes += chunk.length;
|
||||
if (bytes > (options.maxCaptureBytes ?? 1024 * 1024)) {
|
||||
if (!overflow) {
|
||||
overflow = true;
|
||||
signalGroup(child, 'SIGKILL');
|
||||
}
|
||||
} else chunks.push(chunk);
|
||||
} else emit(chunk);
|
||||
};
|
||||
child.stdout!.on('data', (chunk: Buffer) => {
|
||||
if (options.captureFd === 3) emit(chunk);
|
||||
else capture(chunk);
|
||||
});
|
||||
if (options.captureFd === 3) child.stdio[3]!.on('data', capture);
|
||||
if (options.timeoutControl) child.stdio[3]!.on('data', (chunk: Buffer) => {
|
||||
for (const phase of chunk.toString()) {
|
||||
if (phase === 'T') startTimeout();
|
||||
if (phase === 'A') {
|
||||
cleanupStarted = true;
|
||||
clearTimeout(timeout);
|
||||
// Allow the Shell's cleanup to finish after a cooperative timeout.
|
||||
if (timedOut && !aborted) {
|
||||
clearTimeout(escalation);
|
||||
escalation = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
child.stderr!.on('data', (chunk: Buffer) =>
|
||||
emit(chunk, options.stderrOutput ?? options.output),
|
||||
);
|
||||
child.once('exit', () => {
|
||||
// close waits for inherited pipes. Once the Shell and its cleanup have
|
||||
// exited, stop surviving descendants before waiting for those pipes.
|
||||
if (terminating && process.platform !== 'win32')
|
||||
signalGroup(child, 'SIGKILL');
|
||||
});
|
||||
child.once('close', (code, signal) => {
|
||||
// Descendants may close their pipes but ignore the first signal. Do not
|
||||
// cancel escalation and leave them running when the leader closes early.
|
||||
if (terminating && process.platform !== 'win32')
|
||||
signalGroup(child, 'SIGKILL');
|
||||
cleanup();
|
||||
if (outputFailure) {
|
||||
reject(outputFailure);
|
||||
return;
|
||||
}
|
||||
if (overflow) {
|
||||
reject(
|
||||
new Error(
|
||||
translate(
|
||||
options.env ?? process.env,
|
||||
'子进程输出超过配置的捕获上限。',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
code: timedOut
|
||||
? 124
|
||||
: aborted
|
||||
? cancellationExitCode(options.signal)
|
||||
: code ?? (signal ? 128 + constants.signals[signal] : 1),
|
||||
stdout: Buffer.concat(chunks).toString('utf8'),
|
||||
signal,
|
||||
timedOut,
|
||||
...(options.timeoutControl ? { cleanupStarted } : {}),
|
||||
});
|
||||
});
|
||||
if (!options.timeoutControl) startTimeout();
|
||||
options.signal?.addEventListener('abort', abort, { once: true });
|
||||
if (options.signal?.aborted) abort();
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkedProcess(
|
||||
program: string,
|
||||
args: readonly string[],
|
||||
options: ProcessOptions = {},
|
||||
): Promise<ProcessResult> {
|
||||
const result = await runProcess(program, args, options);
|
||||
if (result.code !== 0)
|
||||
fail(
|
||||
translate(
|
||||
options.env ?? process.env,
|
||||
'必要的可执行程序失败(%s,退出码 %s)。',
|
||||
program,
|
||||
result.code,
|
||||
),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { runProcess } from '../runtime/process';
|
||||
import { fail } from '../../shared/errors';
|
||||
|
||||
// Use the installation's POSIX ERE implementation, just as legacy egrep did.
|
||||
// Patterns are argv data; no shell is involved.
|
||||
export async function matchSubscriptionPaths(
|
||||
files: string[],
|
||||
pattern: string | undefined,
|
||||
workspace: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<Set<string>> {
|
||||
if (!pattern) return new Set(files);
|
||||
if (files.some((file) => /[\r\n\0]/.test(file)))
|
||||
fail(translate(env, '订阅路径包含换行或空字符,无法安全过滤。'), 2);
|
||||
const listing = path.join(workspace, `filter-${randomUUID()}.list`);
|
||||
const input = files.length ? files.join('\n') + '\n' : '';
|
||||
await fs.writeFile(listing, input, { mode: 0o600, flag: 'wx' });
|
||||
try {
|
||||
const result = await runProcess(
|
||||
'grep',
|
||||
['-E', '-e', pattern, '--', listing],
|
||||
{
|
||||
env,
|
||||
capture: true,
|
||||
maxCaptureBytes: Buffer.byteLength(input) + 1,
|
||||
timeoutMs: 10000,
|
||||
},
|
||||
);
|
||||
if (result.code === 1) return new Set();
|
||||
if (result.code === 2)
|
||||
fail(translate(env, '订阅 POSIX 正则表达式无效。'), 2);
|
||||
if (result.code !== 0)
|
||||
fail(translate(env, '订阅过滤失败(退出码 %s)。', result.code));
|
||||
return new Set(result.stdout.split('\n').filter(Boolean));
|
||||
} finally {
|
||||
await fs.rm(listing, { force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import type { LocalContext } from '../runtime/context';
|
||||
import { LocalApi } from '../runtime/api';
|
||||
import { checkedProcess } from '../runtime/process';
|
||||
import { regularFiles, within } from '../runtime/files';
|
||||
import { isRecord } from '../../shared/record';
|
||||
import { fail } from '../../shared/errors';
|
||||
import { operationOutput } from '../runtime/output';
|
||||
import { matchSubscriptionPaths } from './subscriptionFilter';
|
||||
|
||||
export interface SubscriptionInput {
|
||||
url: string;
|
||||
branch?: string;
|
||||
include?: string;
|
||||
exclude?: string;
|
||||
dependencies?: string;
|
||||
extensions?: string;
|
||||
proxy?: string;
|
||||
autoAdd?: boolean;
|
||||
autoDelete?: boolean;
|
||||
subscriptionId?: number;
|
||||
}
|
||||
|
||||
export function repositoryName(
|
||||
url: string,
|
||||
branch?: string,
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const normalized = url.replace(/\/$/, '');
|
||||
const last = normalized.lastIndexOf('/');
|
||||
const base = normalized.slice(last + 1).replace(/\.[^.]*$/, '');
|
||||
const owner = normalized
|
||||
.slice(0, last)
|
||||
.split('/')
|
||||
.at(-1)!
|
||||
.split(':')
|
||||
.at(-1)!
|
||||
.split('.')
|
||||
.at(-1)!;
|
||||
const result = `${owner}_${base}${branch ? '_' + branch : ''}`;
|
||||
if (
|
||||
!result ||
|
||||
result.split('/').some((part) => !part || part === '.' || part === '..') ||
|
||||
/[\0\r\n]/.test(result)
|
||||
)
|
||||
fail(translate(environment, '仓库路径无效。'), 2);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function cronMetadata(
|
||||
contents: string,
|
||||
filename: string,
|
||||
fallback: string,
|
||||
): { name: string; schedule: string } {
|
||||
const name =
|
||||
contents.match(/new\s+Env\(\s*['"]([^'"\r\n]+)['"]/)?.[1] ||
|
||||
contents.match(/^\s*(?:#\s*)?name:\s*(.+)$/m)?.[1]?.trim() ||
|
||||
path.basename(filename);
|
||||
const explicit =
|
||||
contents
|
||||
.match(/\bcron:\s*([^\r\n]+)/)?.[1]
|
||||
?.replace(/["',]+$/, '')
|
||||
.trim() || contents.match(/\bcron\s+["']([^"']+)["']/)?.[1];
|
||||
// Legacy add_cron selects a filename-bearing expression before annotations,
|
||||
// deduplicates/sorts the matches, and takes the first expression.
|
||||
const matching = contents
|
||||
.split('\n')
|
||||
.filter((item) => item.includes(path.basename(filename)))
|
||||
.map((item) =>
|
||||
item
|
||||
.match(
|
||||
/(?:[\d*](?:[\d*/,-]*[\d*])?\s+){4,5}[\d*](?:[\d*/,-]*[\d*])?/,
|
||||
)?.[0]
|
||||
?.trim(),
|
||||
)
|
||||
.filter((value): value is string => !!value);
|
||||
const schedule = [...new Set(matching)].sort()[0] || explicit || fallback;
|
||||
return { name, schedule };
|
||||
}
|
||||
|
||||
function taskPath(command: unknown): string | undefined {
|
||||
if (typeof command !== 'string') return undefined;
|
||||
const match = command.match(
|
||||
/^task\s+(?:'((?:[^']|'\\'')*)'|"([^"\n]*)"|(\S+))/,
|
||||
);
|
||||
return match
|
||||
? match[1]?.replace(/'\\''/g, "'") ?? match[2] ?? match[3]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function quote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
async function copyRegular(source: string, destination: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true });
|
||||
await fs.rm(destination, { force: true });
|
||||
await fs.copyFile(source, destination);
|
||||
}
|
||||
|
||||
export async function replaceDirectory(
|
||||
staged: string,
|
||||
destination: string,
|
||||
): Promise<void> {
|
||||
const backup = `${destination}.${randomUUID()}.previous`;
|
||||
const incoming = `${destination}.${randomUUID()}.incoming`;
|
||||
let moved = false;
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true });
|
||||
try {
|
||||
try {
|
||||
await fs.rename(staged, incoming);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EXDEV') throw error;
|
||||
await fs.cp(staged, incoming, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
errorOnExist: true,
|
||||
force: false,
|
||||
});
|
||||
}
|
||||
try {
|
||||
await fs.rename(destination, backup);
|
||||
moved = true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
await fs.rename(incoming, destination);
|
||||
} catch (error) {
|
||||
if (moved) await fs.rename(backup, destination);
|
||||
throw error;
|
||||
} finally {
|
||||
await fs.rm(incoming, { force: true, recursive: true });
|
||||
}
|
||||
if (moved) await fs.rm(backup, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
async function reconcile(
|
||||
context: LocalContext,
|
||||
input: SubscriptionInput,
|
||||
namespace: string,
|
||||
paths: string[],
|
||||
): Promise<{ added: number; removed: number }> {
|
||||
const autoAdd = input.autoAdd ?? context.env.AutoAddCron === 'true';
|
||||
const autoDelete = input.autoDelete ?? context.env.AutoDelCron === 'true';
|
||||
if (!autoAdd && !autoDelete) return { added: 0, removed: 0 };
|
||||
const api = new LocalApi(context);
|
||||
const response = await api.call('crons');
|
||||
if (!isRecord(response.data) || !Array.isArray(response.data.data))
|
||||
fail(translate(context.env, '订阅任务同步返回了无效任务列表。'));
|
||||
const scoped = response.data.data.filter(
|
||||
(row): row is Record<string, unknown> =>
|
||||
isRecord(row) &&
|
||||
typeof row.id === 'number' &&
|
||||
Number.isSafeInteger(row.id) &&
|
||||
row.id > 0 &&
|
||||
(input.subscriptionId === undefined ||
|
||||
row.sub_id === input.subscriptionId) &&
|
||||
(() => {
|
||||
const file = taskPath(row.command);
|
||||
if (!file || file.includes('\\') || path.posix.normalize(file) !== file)
|
||||
return false;
|
||||
return namespace.endsWith('/')
|
||||
? file.startsWith(namespace)
|
||||
: file === namespace;
|
||||
})(),
|
||||
);
|
||||
const existing = new Set(scoped.map((row) => taskPath(row.command)));
|
||||
const expected = new Set(paths);
|
||||
const obsolete = scoped.filter(
|
||||
(row) => !expected.has(taskPath(row.command)!),
|
||||
);
|
||||
let added = 0,
|
||||
removed = 0;
|
||||
if (autoAdd) {
|
||||
for (const relative of paths) {
|
||||
if (existing.has(relative)) continue;
|
||||
const contents = await fs.readFile(
|
||||
within(context.paths.dir_scripts!, relative, context.env),
|
||||
'utf8',
|
||||
);
|
||||
const fallback =
|
||||
context.env.DefaultCronRule ||
|
||||
`${randomInt(0, 59)} ${randomInt(0, 23)} * * *`;
|
||||
const meta = cronMetadata(contents, relative, fallback);
|
||||
await api.call('crons', 'POST', {
|
||||
...meta,
|
||||
command: `task ${
|
||||
/^[\w./-]+$/.test(relative) ? relative : quote(relative)
|
||||
}`,
|
||||
sub_id: input.subscriptionId ?? null,
|
||||
});
|
||||
added++;
|
||||
}
|
||||
}
|
||||
if (autoDelete && obsolete.length) {
|
||||
await api.call(
|
||||
'crons',
|
||||
'DELETE',
|
||||
obsolete.map((row) => row.id),
|
||||
);
|
||||
for (const row of obsolete) {
|
||||
await fs.rm(
|
||||
within(context.paths.dir_scripts!, taskPath(row.command)!, context.env),
|
||||
{
|
||||
force: true,
|
||||
},
|
||||
);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (added || removed) {
|
||||
try {
|
||||
await api.call('system/notify', 'PUT', {
|
||||
title: `${namespace} 订阅同步`,
|
||||
content: `新增 ${added} 个任务,删除 ${removed} 个任务`,
|
||||
});
|
||||
} catch {
|
||||
operationOutput(
|
||||
Buffer.from(
|
||||
translate(
|
||||
context.env,
|
||||
'订阅同步已完成,但通知发送失败,请检查面板通知设置。\n',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
export async function syncRepository(
|
||||
context: LocalContext,
|
||||
input: SubscriptionInput,
|
||||
): Promise<unknown> {
|
||||
const name = repositoryName(input.url, input.branch, context.env);
|
||||
const destination = within(context.paths.dir_scripts!, name, context.env);
|
||||
const repoDestination = within(context.paths.dir_repo!, name, context.env);
|
||||
const extensions = (
|
||||
input.extensions ||
|
||||
context.env.RepoFileExtensions ||
|
||||
'js py'
|
||||
)
|
||||
.split(/[|\s]+/)
|
||||
.filter(Boolean);
|
||||
await fs.mkdir(context.paths.dir_tmp!, { recursive: true });
|
||||
const stage = await fs.mkdtemp(
|
||||
path.join(context.paths.dir_tmp!, 'subscription-'),
|
||||
);
|
||||
const checkout = path.join(stage, 'checkout'),
|
||||
scripts = path.join(stage, 'scripts');
|
||||
try {
|
||||
const env = { ...context.env };
|
||||
const proxy = input.proxy || env.ProxyUrl;
|
||||
if (proxy) {
|
||||
env.http_proxy = proxy;
|
||||
env.https_proxy = proxy;
|
||||
}
|
||||
await checkedProcess(
|
||||
'git',
|
||||
[
|
||||
'clone',
|
||||
'-q',
|
||||
'--depth=1',
|
||||
...(input.branch ? ['--branch', input.branch] : []),
|
||||
'--',
|
||||
input.url,
|
||||
checkout,
|
||||
],
|
||||
{ env, cwd: context.root },
|
||||
);
|
||||
const candidates = (await regularFiles(checkout)).filter(
|
||||
(file) =>
|
||||
!file.split(path.sep).includes('.git') &&
|
||||
extensions.includes(path.extname(file).slice(1)),
|
||||
);
|
||||
const included = await matchSubscriptionPaths(
|
||||
candidates,
|
||||
input.include,
|
||||
stage,
|
||||
env,
|
||||
);
|
||||
const excluded = input.exclude
|
||||
? await matchSubscriptionPaths(candidates, input.exclude, stage, env)
|
||||
: new Set<string>();
|
||||
const dependencies = input.dependencies
|
||||
? await matchSubscriptionPaths(candidates, input.dependencies, stage, env)
|
||||
: new Set<string>();
|
||||
const selected = candidates.filter(
|
||||
(file) => included.has(file) && !excluded.has(file),
|
||||
);
|
||||
await fs.mkdir(scripts);
|
||||
// Retain previous files unless task reconciliation explicitly removes them.
|
||||
for (const file of await regularFiles(destination))
|
||||
await copyRegular(
|
||||
path.join(destination, file),
|
||||
within(scripts, file, context.env),
|
||||
);
|
||||
for (const key of ['file_notify_js', 'file_notify_py']) {
|
||||
const source = context.paths[key]!;
|
||||
if ((await fs.stat(source).catch(() => undefined))?.isFile())
|
||||
await copyRegular(source, path.join(scripts, path.basename(source)));
|
||||
}
|
||||
// Preserve gen_list_repo precedence: notification defaults, repository
|
||||
// dependencies, local dependency overrides, then selected task scripts.
|
||||
for (const file of candidates.filter((file) => dependencies.has(file)))
|
||||
await copyRegular(
|
||||
path.join(checkout, file),
|
||||
within(scripts, file, context.env),
|
||||
);
|
||||
for (const file of await regularFiles(context.paths.dir_dep!))
|
||||
await copyRegular(
|
||||
path.join(context.paths.dir_dep!, file),
|
||||
within(scripts, file, context.env),
|
||||
);
|
||||
for (const file of selected)
|
||||
await copyRegular(
|
||||
path.join(checkout, file),
|
||||
within(scripts, file, context.env),
|
||||
);
|
||||
await replaceDirectory(scripts, destination);
|
||||
await replaceDirectory(checkout, repoDestination);
|
||||
const relativePaths = selected.map((file) =>
|
||||
path.posix.join(name, file.split(path.sep).join('/')),
|
||||
);
|
||||
const changes = await reconcile(context, input, `${name}/`, relativePaths);
|
||||
return { repository: name, files: relativePaths, ...changes };
|
||||
} finally {
|
||||
await fs.rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncRaw(
|
||||
context: LocalContext,
|
||||
input: SubscriptionInput,
|
||||
): Promise<unknown> {
|
||||
const url = new URL(input.url);
|
||||
if (!['http:', 'https:'].includes(url.protocol))
|
||||
fail(translate(context.env, '单文件订阅需要 HTTP(S) 地址。'), 2);
|
||||
const name =
|
||||
repositoryName(input.url, undefined, context.env) +
|
||||
path.extname(url.pathname);
|
||||
const raw = within(context.paths.dir_raw!, name, context.env);
|
||||
const relative = `raw_${name}`;
|
||||
await fs.mkdir(path.dirname(raw), { recursive: true });
|
||||
const temporary = `${raw}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
const env = { ...context.env };
|
||||
const proxy = input.proxy || env.ProxyUrl;
|
||||
if (proxy) {
|
||||
env.http_proxy = proxy;
|
||||
env.https_proxy = proxy;
|
||||
}
|
||||
await checkedProcess(
|
||||
'curl',
|
||||
[
|
||||
'--fail',
|
||||
'--netrc-optional',
|
||||
'--location',
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--output',
|
||||
temporary,
|
||||
'--url',
|
||||
input.url,
|
||||
],
|
||||
{ env, timeoutMs: 120000 },
|
||||
);
|
||||
await fs.rename(temporary, raw);
|
||||
await copyRegular(
|
||||
raw,
|
||||
within(context.paths.dir_scripts!, relative, context.env),
|
||||
);
|
||||
const changes = await reconcile(
|
||||
context,
|
||||
{ ...input, autoDelete: false },
|
||||
relative,
|
||||
[relative],
|
||||
);
|
||||
return { file: relative, ...changes };
|
||||
} finally {
|
||||
await fs.rm(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import { standaloneHelp } from '../help/standalone';
|
||||
import { withCommandCancellation, interruptedCode } from '../runtime/cancellation';
|
||||
import { localContext } from '../runtime/context';
|
||||
import {
|
||||
syncRaw,
|
||||
syncRepository,
|
||||
type SubscriptionInput,
|
||||
} from './subscriptionRunner';
|
||||
import { CliError, fail } from '../../shared/errors';
|
||||
import { loggedOperation } from '../runtime/commandLog';
|
||||
|
||||
// Compatibility worker for the existing backend SUB_ID=... ql repo/raw contract.
|
||||
// This is intentionally not registered in the public panel management CLI.
|
||||
export async function subscriptionWorker(
|
||||
args = process.argv.slice(2),
|
||||
): Promise<number> {
|
||||
return withCommandCancellation(async (signal) => {
|
||||
try {
|
||||
if (args.length === 1 && ['--help', '-h'].includes(args[0]!)) {
|
||||
process.stdout.write(standaloneHelp('worker'));
|
||||
return 0;
|
||||
}
|
||||
const [kind, url, ...options] = args;
|
||||
if (!['repo', 'raw'].includes(kind ?? '') || !url)
|
||||
fail(
|
||||
translate(
|
||||
process.env,
|
||||
'用法:ql-subscription-worker repo|raw <url> [旧订阅参数]',
|
||||
),
|
||||
2,
|
||||
);
|
||||
const bool = (value?: string) => {
|
||||
if (value === undefined || value === '') return undefined;
|
||||
if (!['true', 'false'].includes(value))
|
||||
fail(
|
||||
translate(
|
||||
process.env,
|
||||
'订阅布尔参数无效,只能使用 true 或 false。',
|
||||
),
|
||||
2,
|
||||
);
|
||||
return value === 'true';
|
||||
};
|
||||
if (options.length > (kind === 'repo' ? 8 : 3))
|
||||
fail(translate(process.env, '订阅参数过多。'), 2);
|
||||
const input: SubscriptionInput =
|
||||
kind === 'repo'
|
||||
? {
|
||||
url,
|
||||
include: options[0],
|
||||
exclude: options[1],
|
||||
dependencies: options[2],
|
||||
branch: options[3],
|
||||
extensions: options[4],
|
||||
proxy: options[5],
|
||||
autoAdd: bool(options[6]),
|
||||
autoDelete: bool(options[7]),
|
||||
}
|
||||
: {
|
||||
url,
|
||||
proxy: options[0],
|
||||
autoAdd: bool(options[1]),
|
||||
autoDelete: bool(options[2]),
|
||||
};
|
||||
if (process.env.SUB_ID) {
|
||||
const id = Number(process.env.SUB_ID);
|
||||
if (!Number.isSafeInteger(id) || id <= 0)
|
||||
fail(translate(process.env, 'SUB_ID 无效,必须为正整数。'), 2);
|
||||
input.subscriptionId = id;
|
||||
}
|
||||
const context = await localContext(
|
||||
{ values: {}, positionals: args },
|
||||
{ signal },
|
||||
);
|
||||
const { result, logPath } = await loggedOperation(
|
||||
context,
|
||||
kind!,
|
||||
() =>
|
||||
kind === 'repo'
|
||||
? syncRepository(context, input)
|
||||
: syncRaw(context, input),
|
||||
signal,
|
||||
);
|
||||
process.stdout.write(
|
||||
JSON.stringify({ code: 200, data: result, logPath }) + '\n',
|
||||
);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const code =
|
||||
interruptedCode(signal) ??
|
||||
(error instanceof CliError ? error.exitCode : 1);
|
||||
process.stderr.write(
|
||||
JSON.stringify({
|
||||
code,
|
||||
message: signal.aborted
|
||||
? translate(process.env, '命令已中断。')
|
||||
: error instanceof CliError
|
||||
? error.message
|
||||
: translate(process.env, '订阅执行器失败,请检查本机日志。'),
|
||||
}) + '\n',
|
||||
);
|
||||
return code;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module)
|
||||
void subscriptionWorker().then((code) => {
|
||||
process.exitCode = code;
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { saveResponse } from './download';
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import { CliError, fail } from '../../shared/errors';
|
||||
import type { Credentials, Session, StoredConfig } from '../types';
|
||||
|
||||
import { isRecord } from '../../shared/record';
|
||||
export { isRecord } from '../../shared/record';
|
||||
|
||||
interface RequestOptions {
|
||||
method?: 'GET' | 'PUT' | 'POST' | 'DELETE';
|
||||
body?: unknown;
|
||||
output?: string;
|
||||
text?: boolean;
|
||||
timeoutMs?: number;
|
||||
authenticated?: boolean;
|
||||
}
|
||||
|
||||
export async function request(
|
||||
config: StoredConfig,
|
||||
endpoint: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const { method = 'GET', body, authenticated = true } = options;
|
||||
const scope = endpoint.split(/[/?]/, 1)[0]!;
|
||||
const subscription = scope === 'subscriptions';
|
||||
const resource =
|
||||
scope === 'crons' || subscription
|
||||
? translate(process.env, subscription ? '订阅' : '任务')
|
||||
: scope;
|
||||
const credentialsHint = authenticated
|
||||
? translate(process.env, '应用凭据和 %s 权限', scope)
|
||||
: translate(process.env, '应用凭据');
|
||||
let response: Response;
|
||||
let result: unknown;
|
||||
try {
|
||||
response = await fetch(`${config.url}/open/${endpoint}`, {
|
||||
method,
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(options.timeoutMs ?? 30000),
|
||||
headers: {
|
||||
...(authenticated ? { Authorization: `Bearer ${config.token}` } : {}),
|
||||
...(body !== undefined && !(body instanceof FormData)
|
||||
? { 'Content-Type': 'application/json' }
|
||||
: {}),
|
||||
},
|
||||
body:
|
||||
body instanceof FormData
|
||||
? body
|
||||
: body !== undefined
|
||||
? JSON.stringify(body)
|
||||
: undefined,
|
||||
});
|
||||
// Check HTTP status before decoding (proxies may return HTML on denial).
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
const suffix =
|
||||
method !== 'GET' && response.status >= 500
|
||||
? translate(
|
||||
process.env,
|
||||
' 执行结果可能未知,请先检查%s状态再考虑重试。',
|
||||
resource,
|
||||
)
|
||||
: '';
|
||||
fail(
|
||||
translate(
|
||||
process.env,
|
||||
'API 请求被拒绝(HTTP %s),请检查%s。%s',
|
||||
response.status,
|
||||
credentialsHint,
|
||||
suffix,
|
||||
),
|
||||
[401, 403].includes(response.status) ? 3 : 1,
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.output &&
|
||||
(response.headers.get('content-disposition')?.startsWith('attachment') ||
|
||||
!response.headers.get('content-type')?.includes('application/json'))
|
||||
)
|
||||
return await saveResponse(response, options.output);
|
||||
if (
|
||||
options.text &&
|
||||
!response.headers.get('content-type')?.includes('application/json')
|
||||
)
|
||||
return { code: 200, data: await response.text() };
|
||||
result = await response.json();
|
||||
} catch (error) {
|
||||
// Never expose a fetch error, URL or server error that may include secrets.
|
||||
if (error instanceof CliError) throw error;
|
||||
fail(
|
||||
method !== 'GET'
|
||||
? translate(
|
||||
process.env,
|
||||
'请求失败或响应无效,执行结果未知。请先检查%s状态再考虑重试。',
|
||||
resource,
|
||||
)
|
||||
: translate(
|
||||
process.env,
|
||||
'请求失败或返回的 JSON 无效,请检查实例 URL、网络和 TLS 证书。',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (endpoint.split('?')[0] === 'user/login' && isRecord(result) && result.code === 420)
|
||||
fail(translate(process.env, '需要双因素验证,请调用 user two-factor-login。'), 3);
|
||||
if (!isRecord(result) || result.code !== 200) {
|
||||
const status =
|
||||
isRecord(result) && typeof result.code === 'number'
|
||||
? result.code
|
||||
: undefined;
|
||||
fail(
|
||||
translate(
|
||||
process.env,
|
||||
'API 请求被拒绝(code %s),请检查%s。',
|
||||
status ?? translate(process.env, '未知'),
|
||||
credentialsHint,
|
||||
),
|
||||
status === 401 || status === 403 ? 3 : 1,
|
||||
);
|
||||
}
|
||||
if (options.output)
|
||||
fail(translate(process.env, '接口返回 JSON,未保存下载文件。'));
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function authenticate(config: Credentials): Promise<Session> {
|
||||
const query = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
});
|
||||
const result = await request(config, `auth/token?${query}`, {
|
||||
authenticated: false,
|
||||
});
|
||||
const data = result.data;
|
||||
if (
|
||||
!isRecord(data) ||
|
||||
typeof data.token !== 'string' ||
|
||||
!data.token ||
|
||||
typeof data.expiration !== 'number' ||
|
||||
!Number.isFinite(data.expiration) ||
|
||||
data.expiration <= Date.now() / 1000
|
||||
) {
|
||||
fail(translate(process.env, '认证响应无效。'));
|
||||
}
|
||||
return { ...config, token: data.token, expiration: data.expiration };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { open, unlink } from 'node:fs/promises';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { ReadableStream } from 'node:stream/web';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Never overwrite an existing file; remove only a file created by this call.
|
||||
export async function saveResponse(
|
||||
response: Response,
|
||||
output: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const path = resolve(output);
|
||||
const handle = await open(path, 'wx', 0o600);
|
||||
try {
|
||||
if (!response.body) throw new Error('Missing response body');
|
||||
const stream = handle.createWriteStream();
|
||||
await pipeline(Readable.fromWeb(response.body as ReadableStream<Uint8Array>), stream);
|
||||
return { code: 200, data: { path, bytes: stream.bytesWritten } };
|
||||
} catch (error) {
|
||||
await unlink(path).catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
// Reviewed against back/api routes. The coverage test detects route additions/removals.
|
||||
// All paths are relative to /open; these commands never operate on local panel files.
|
||||
export interface OpenOperation {
|
||||
name: string;
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
path: string;
|
||||
params?: string[];
|
||||
body?: 'ids' | 'json' | 'object-id';
|
||||
upload?: string;
|
||||
download?: boolean;
|
||||
existing?: boolean;
|
||||
anonymous?: boolean;
|
||||
}
|
||||
export const openOperations: readonly OpenOperation[] = [
|
||||
{ name: 'task view-list', method: 'GET', path: 'crons/views' },
|
||||
{
|
||||
name: 'task view-create',
|
||||
method: 'POST',
|
||||
path: 'crons/views',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'task view-update',
|
||||
method: 'PUT',
|
||||
path: 'crons/views',
|
||||
params: ['id'],
|
||||
body: 'object-id',
|
||||
},
|
||||
{
|
||||
name: 'task view-delete',
|
||||
method: 'DELETE',
|
||||
path: 'crons/views',
|
||||
body: 'ids',
|
||||
},
|
||||
{
|
||||
name: 'task view-move',
|
||||
method: 'PUT',
|
||||
path: 'crons/views/move',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'task view-disable',
|
||||
method: 'PUT',
|
||||
path: 'crons/views/disable',
|
||||
body: 'ids',
|
||||
},
|
||||
{
|
||||
name: 'task view-enable',
|
||||
method: 'PUT',
|
||||
path: 'crons/views/enable',
|
||||
body: 'ids',
|
||||
},
|
||||
{ name: 'task list', method: 'GET', path: 'crons', existing: true },
|
||||
{ name: 'task detail', method: 'GET', path: 'crons/detail' },
|
||||
{ name: 'task create', method: 'POST', path: 'crons', body: 'json' },
|
||||
{
|
||||
name: 'task run',
|
||||
method: 'PUT',
|
||||
path: 'crons/run',
|
||||
body: 'ids',
|
||||
existing: true,
|
||||
},
|
||||
{
|
||||
name: 'task stop',
|
||||
method: 'PUT',
|
||||
path: 'crons/stop',
|
||||
body: 'ids',
|
||||
existing: true,
|
||||
},
|
||||
{
|
||||
name: 'task labels-delete',
|
||||
method: 'DELETE',
|
||||
path: 'crons/labels',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'task labels-create',
|
||||
method: 'POST',
|
||||
path: 'crons/labels',
|
||||
body: 'json',
|
||||
},
|
||||
{ name: 'task disable', method: 'PUT', path: 'crons/disable', body: 'ids' },
|
||||
{ name: 'task enable', method: 'PUT', path: 'crons/enable', body: 'ids' },
|
||||
{
|
||||
name: 'task logs',
|
||||
method: 'GET',
|
||||
path: 'crons/:id/log',
|
||||
params: ['id'],
|
||||
existing: true,
|
||||
},
|
||||
{
|
||||
name: 'task update',
|
||||
method: 'PUT',
|
||||
path: 'crons',
|
||||
params: ['id'],
|
||||
body: 'object-id',
|
||||
},
|
||||
{ name: 'task delete', method: 'DELETE', path: 'crons', body: 'ids' },
|
||||
{ name: 'task pin', method: 'PUT', path: 'crons/pin', body: 'ids' },
|
||||
{ name: 'task unpin', method: 'PUT', path: 'crons/unpin', body: 'ids' },
|
||||
{ name: 'task import', method: 'GET', path: 'crons/import' },
|
||||
{
|
||||
name: 'task get',
|
||||
method: 'GET',
|
||||
path: 'crons/:id',
|
||||
params: ['id'],
|
||||
existing: true,
|
||||
},
|
||||
{ name: 'task status', method: 'PUT', path: 'crons/status', body: 'json' },
|
||||
{
|
||||
name: 'task instances',
|
||||
method: 'GET',
|
||||
path: 'crons/:id/instances',
|
||||
params: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'task instance-stop',
|
||||
method: 'POST',
|
||||
path: 'crons/:id/instances/:instanceId/stop',
|
||||
params: ['id', 'instanceId'],
|
||||
},
|
||||
{
|
||||
name: 'task log-files',
|
||||
method: 'GET',
|
||||
path: 'crons/:id/logs',
|
||||
params: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'subscription list',
|
||||
method: 'GET',
|
||||
path: 'subscriptions',
|
||||
existing: true,
|
||||
},
|
||||
{
|
||||
name: 'subscription create',
|
||||
method: 'POST',
|
||||
path: 'subscriptions',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'subscription run',
|
||||
method: 'PUT',
|
||||
path: 'subscriptions/run',
|
||||
body: 'ids',
|
||||
existing: true,
|
||||
},
|
||||
{
|
||||
name: 'subscription stop',
|
||||
method: 'PUT',
|
||||
path: 'subscriptions/stop',
|
||||
body: 'ids',
|
||||
existing: true,
|
||||
},
|
||||
{
|
||||
name: 'subscription disable',
|
||||
method: 'PUT',
|
||||
path: 'subscriptions/disable',
|
||||
body: 'ids',
|
||||
existing: true,
|
||||
},
|
||||
{
|
||||
name: 'subscription enable',
|
||||
method: 'PUT',
|
||||
path: 'subscriptions/enable',
|
||||
body: 'ids',
|
||||
existing: true,
|
||||
},
|
||||
{
|
||||
name: 'subscription logs',
|
||||
method: 'GET',
|
||||
path: 'subscriptions/:id/log',
|
||||
params: ['id'],
|
||||
existing: true,
|
||||
},
|
||||
{
|
||||
name: 'subscription update',
|
||||
method: 'PUT',
|
||||
path: 'subscriptions',
|
||||
params: ['id'],
|
||||
body: 'object-id',
|
||||
},
|
||||
{
|
||||
name: 'subscription delete',
|
||||
method: 'DELETE',
|
||||
path: 'subscriptions',
|
||||
body: 'ids',
|
||||
},
|
||||
{
|
||||
name: 'subscription get',
|
||||
method: 'GET',
|
||||
path: 'subscriptions/:id',
|
||||
params: ['id'],
|
||||
existing: true,
|
||||
},
|
||||
{
|
||||
name: 'subscription status',
|
||||
method: 'PUT',
|
||||
path: 'subscriptions/status',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'subscription log-files',
|
||||
method: 'GET',
|
||||
path: 'subscriptions/:id/logs',
|
||||
params: ['id'],
|
||||
},
|
||||
{ name: 'app list', method: 'GET', path: 'apps' },
|
||||
{ name: 'app create', method: 'POST', path: 'apps', body: 'json' },
|
||||
{
|
||||
name: 'app update',
|
||||
method: 'PUT',
|
||||
path: 'apps',
|
||||
params: ['id'],
|
||||
body: 'object-id',
|
||||
},
|
||||
{ name: 'app delete', method: 'DELETE', path: 'apps', body: 'ids' },
|
||||
{
|
||||
name: 'app reset-secret',
|
||||
method: 'PUT',
|
||||
path: 'apps/:id/reset-secret',
|
||||
params: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'auth login',
|
||||
method: 'GET',
|
||||
path: 'auth/token',
|
||||
existing: true,
|
||||
anonymous: true,
|
||||
},
|
||||
{ name: 'env list', method: 'GET', path: 'envs' },
|
||||
{ name: 'env create', method: 'POST', path: 'envs', body: 'json' },
|
||||
{
|
||||
name: 'env update',
|
||||
method: 'PUT',
|
||||
path: 'envs',
|
||||
params: ['id'],
|
||||
body: 'object-id',
|
||||
},
|
||||
{ name: 'env delete', method: 'DELETE', path: 'envs', body: 'ids' },
|
||||
{
|
||||
name: 'env move',
|
||||
method: 'PUT',
|
||||
path: 'envs/:id/move',
|
||||
params: ['id'],
|
||||
body: 'json',
|
||||
},
|
||||
{ name: 'env disable', method: 'PUT', path: 'envs/disable', body: 'ids' },
|
||||
{ name: 'env enable', method: 'PUT', path: 'envs/enable', body: 'ids' },
|
||||
{ name: 'env rename', method: 'PUT', path: 'envs/name', body: 'json' },
|
||||
{ name: 'env get', method: 'GET', path: 'envs/:id', params: ['id'] },
|
||||
{ name: 'env pin', method: 'PUT', path: 'envs/pin', body: 'ids' },
|
||||
{ name: 'env unpin', method: 'PUT', path: 'envs/unpin', body: 'ids' },
|
||||
{
|
||||
name: 'env labels-create',
|
||||
method: 'POST',
|
||||
path: 'envs/labels',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'env labels-delete',
|
||||
method: 'DELETE',
|
||||
path: 'envs/labels',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'env upload',
|
||||
method: 'POST',
|
||||
path: 'envs/upload',
|
||||
body: 'json',
|
||||
upload: 'env',
|
||||
},
|
||||
{ name: 'config samples', method: 'GET', path: 'configs/samples' },
|
||||
{ name: 'config list', method: 'GET', path: 'configs/files' },
|
||||
{ name: 'config get', method: 'GET', path: 'configs/detail' },
|
||||
{ name: 'config save', method: 'POST', path: 'configs/save', body: 'json' },
|
||||
{ name: 'script list', method: 'GET', path: 'scripts' },
|
||||
{ name: 'script get', method: 'GET', path: 'scripts/detail' },
|
||||
{
|
||||
name: 'script create',
|
||||
method: 'POST',
|
||||
path: 'scripts',
|
||||
body: 'json',
|
||||
upload: 'file',
|
||||
},
|
||||
{
|
||||
name: 'script update',
|
||||
method: 'PUT',
|
||||
path: 'scripts',
|
||||
body: 'json',
|
||||
upload: 'file',
|
||||
},
|
||||
{ name: 'script delete', method: 'DELETE', path: 'scripts', body: 'json' },
|
||||
{
|
||||
name: 'script download',
|
||||
method: 'POST',
|
||||
path: 'scripts/download',
|
||||
body: 'json',
|
||||
download: true,
|
||||
},
|
||||
{ name: 'script run', method: 'PUT', path: 'scripts/run', body: 'json' },
|
||||
{ name: 'script stop', method: 'PUT', path: 'scripts/stop', body: 'json' },
|
||||
{
|
||||
name: 'script rename',
|
||||
method: 'PUT',
|
||||
path: 'scripts/rename',
|
||||
body: 'json',
|
||||
},
|
||||
{ name: 'log list', method: 'GET', path: 'logs' },
|
||||
{ name: 'log get', method: 'GET', path: 'logs/detail' },
|
||||
{ name: 'log delete', method: 'DELETE', path: 'logs', body: 'json' },
|
||||
{
|
||||
name: 'log download',
|
||||
method: 'POST',
|
||||
path: 'logs/download',
|
||||
body: 'json',
|
||||
download: true,
|
||||
},
|
||||
{ name: 'dependency list', method: 'GET', path: 'dependencies' },
|
||||
{
|
||||
name: 'dependency create',
|
||||
method: 'POST',
|
||||
path: 'dependencies',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'dependency update',
|
||||
method: 'PUT',
|
||||
path: 'dependencies',
|
||||
params: ['id'],
|
||||
body: 'object-id',
|
||||
},
|
||||
{
|
||||
name: 'dependency delete',
|
||||
method: 'DELETE',
|
||||
path: 'dependencies',
|
||||
body: 'ids',
|
||||
},
|
||||
{
|
||||
name: 'dependency force-delete',
|
||||
method: 'DELETE',
|
||||
path: 'dependencies/force',
|
||||
body: 'ids',
|
||||
},
|
||||
{
|
||||
name: 'dependency get',
|
||||
method: 'GET',
|
||||
path: 'dependencies/:id',
|
||||
params: ['id'],
|
||||
},
|
||||
{
|
||||
name: 'dependency reinstall',
|
||||
method: 'PUT',
|
||||
path: 'dependencies/reinstall',
|
||||
body: 'ids',
|
||||
},
|
||||
{
|
||||
name: 'dependency cancel',
|
||||
method: 'PUT',
|
||||
path: 'dependencies/cancel',
|
||||
body: 'ids',
|
||||
},
|
||||
{ name: 'system info', method: 'GET', path: 'system' },
|
||||
{ name: 'system config-get', method: 'GET', path: 'system/config' },
|
||||
{
|
||||
name: 'system config-log-remove-frequency',
|
||||
method: 'PUT',
|
||||
path: 'system/config/log-remove-frequency',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'system config-cron-concurrency',
|
||||
method: 'PUT',
|
||||
path: 'system/config/cron-concurrency',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'system config-dependence-proxy',
|
||||
method: 'PUT',
|
||||
path: 'system/config/dependence-proxy',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'system config-node-mirror',
|
||||
method: 'PUT',
|
||||
path: 'system/config/node-mirror',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'system config-python-mirror',
|
||||
method: 'PUT',
|
||||
path: 'system/config/python-mirror',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'system config-linux-mirror',
|
||||
method: 'PUT',
|
||||
path: 'system/config/linux-mirror',
|
||||
body: 'json',
|
||||
},
|
||||
{ name: 'system update-check', method: 'PUT', path: 'system/update-check' },
|
||||
{ name: 'system update', method: 'PUT', path: 'system/update' },
|
||||
{ name: 'system reload', method: 'PUT', path: 'system/reload', body: 'json' },
|
||||
{ name: 'system notify', method: 'PUT', path: 'system/notify', body: 'json' },
|
||||
{
|
||||
name: 'system command-run',
|
||||
method: 'PUT',
|
||||
path: 'system/command-run',
|
||||
body: 'json',
|
||||
download: true,
|
||||
},
|
||||
{
|
||||
name: 'system command-stop',
|
||||
method: 'PUT',
|
||||
path: 'system/command-stop',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'system data-export',
|
||||
method: 'PUT',
|
||||
path: 'system/data/export',
|
||||
body: 'json',
|
||||
download: true,
|
||||
},
|
||||
{
|
||||
name: 'system data-import',
|
||||
method: 'PUT',
|
||||
path: 'system/data/import',
|
||||
body: 'json',
|
||||
upload: 'data',
|
||||
},
|
||||
{ name: 'system logs', method: 'GET', path: 'system/log' },
|
||||
{ name: 'system logs-delete', method: 'DELETE', path: 'system/log' },
|
||||
{
|
||||
name: 'system auth-reset',
|
||||
method: 'PUT',
|
||||
path: 'system/auth/reset',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'system config-timezone',
|
||||
method: 'PUT',
|
||||
path: 'system/config/timezone',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'system config-lang',
|
||||
method: 'PUT',
|
||||
path: 'system/config/lang',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'system config-panel-title',
|
||||
method: 'PUT',
|
||||
path: 'system/config/panel-title',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'system config-global-ssh-key',
|
||||
method: 'PUT',
|
||||
path: 'system/config/global-ssh-key',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'system config-dependence-clean',
|
||||
method: 'PUT',
|
||||
path: 'system/config/dependence-clean',
|
||||
body: 'json',
|
||||
},
|
||||
{ name: 'dashboard record', method: 'POST', path: 'dashboard/record', body: 'json' },
|
||||
{ name: 'dashboard overview', method: 'GET', path: 'dashboard/overview' },
|
||||
{ name: 'dashboard trend', method: 'GET', path: 'dashboard/trend' },
|
||||
{ name: 'dashboard top-time', method: 'GET', path: 'dashboard/top-time' },
|
||||
{ name: 'dashboard top-count', method: 'GET', path: 'dashboard/top-count' },
|
||||
{ name: 'dashboard runtime', method: 'GET', path: 'dashboard/runtime' },
|
||||
{ name: 'dashboard labels', method: 'GET', path: 'dashboard/labels' },
|
||||
{ name: 'dashboard system', method: 'GET', path: 'dashboard/system' },
|
||||
{
|
||||
name: 'system client-ip-get',
|
||||
method: 'GET',
|
||||
path: 'system/client-ip/config',
|
||||
},
|
||||
{
|
||||
name: 'system client-ip-set',
|
||||
method: 'PUT',
|
||||
path: 'system/client-ip/config',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'system client-ip-diagnose',
|
||||
method: 'GET',
|
||||
path: 'system/client-ip/diagnose',
|
||||
},
|
||||
{
|
||||
name: 'system retention-set',
|
||||
method: 'PUT',
|
||||
path: 'system/storage-retention/config',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'system retention-preview',
|
||||
method: 'POST',
|
||||
path: 'system/storage-retention/preview',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'system retention-cleanup',
|
||||
method: 'POST',
|
||||
path: 'system/storage-retention/cleanup',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'user login',
|
||||
method: 'POST',
|
||||
path: 'user/login',
|
||||
body: 'json',
|
||||
anonymous: true,
|
||||
},
|
||||
{ name: 'user logout', method: 'POST', path: 'user/logout' },
|
||||
{ name: 'user update', method: 'PUT', path: 'user', body: 'json' },
|
||||
{ name: 'user get', method: 'GET', path: 'user' },
|
||||
{ name: 'user two-factor-init', method: 'GET', path: 'user/two-factor/init' },
|
||||
{
|
||||
name: 'user two-factor-active',
|
||||
method: 'PUT',
|
||||
path: 'user/two-factor/active',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'user two-factor-deactivate',
|
||||
method: 'PUT',
|
||||
path: 'user/two-factor/deactivate',
|
||||
},
|
||||
{
|
||||
name: 'user two-factor-login',
|
||||
method: 'PUT',
|
||||
path: 'user/two-factor/login',
|
||||
body: 'json',
|
||||
anonymous: true,
|
||||
},
|
||||
{ name: 'user login-log', method: 'GET', path: 'user/login-log' },
|
||||
{ name: 'user ip-blacklist', method: 'GET', path: 'user/ip-blacklist' },
|
||||
{
|
||||
name: 'user ip-blacklist-set',
|
||||
method: 'PUT',
|
||||
path: 'user/ip-blacklist',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'user ip-blacklist-delete',
|
||||
method: 'DELETE',
|
||||
path: 'user/ip-blacklist',
|
||||
body: 'json',
|
||||
},
|
||||
{ name: 'user notification-get', method: 'GET', path: 'user/notification' },
|
||||
{
|
||||
name: 'user notification-set',
|
||||
method: 'PUT',
|
||||
path: 'user/notification',
|
||||
body: 'json',
|
||||
},
|
||||
{
|
||||
name: 'user init',
|
||||
method: 'PUT',
|
||||
path: 'user/init',
|
||||
body: 'json',
|
||||
anonymous: true,
|
||||
},
|
||||
{
|
||||
name: 'user notification-init',
|
||||
method: 'PUT',
|
||||
path: 'user/notification/init',
|
||||
body: 'json',
|
||||
anonymous: true,
|
||||
},
|
||||
{
|
||||
name: 'user avatar',
|
||||
method: 'PUT',
|
||||
path: 'user/avatar',
|
||||
body: 'json',
|
||||
upload: 'avatar',
|
||||
},
|
||||
{ name: 'system apply-reload', method: 'PUT', path: 'update/reload' },
|
||||
{ name: 'system apply-system', method: 'PUT', path: 'update/system' },
|
||||
{ name: 'system apply-data', method: 'PUT', path: 'update/data' },
|
||||
{ name: 'health get', method: 'GET', path: 'health' },
|
||||
];
|
||||
@@ -0,0 +1,90 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { CliError, fail } from '../../shared/errors';
|
||||
import type { StoredConfig } from '../types';
|
||||
import { panelUrl } from './url';
|
||||
|
||||
export function configPath(): string {
|
||||
return path.resolve(
|
||||
process.env.QL_CLI_CONFIG ||
|
||||
path.join(os.homedir(), '.config/qinglong/cli.json'),
|
||||
);
|
||||
}
|
||||
|
||||
export function readConfig(): StoredConfig {
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = fs.openSync(
|
||||
configPath(),
|
||||
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
const stat = fs.fstatSync(fd);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.mode & 0o077 ||
|
||||
(process.getuid && stat.uid !== process.getuid())
|
||||
) {
|
||||
fail(
|
||||
translate(
|
||||
process.env,
|
||||
'CLI 配置必须是当前用户拥有的私有普通文件(0600)。',
|
||||
),
|
||||
);
|
||||
}
|
||||
const config: unknown = JSON.parse(fs.readFileSync(fd, 'utf8'));
|
||||
if (!config || typeof config !== 'object')
|
||||
fail(translate(process.env, 'CLI 配置无效,请运行 ql login。'));
|
||||
const value = config as Record<string, unknown>;
|
||||
if (
|
||||
typeof value.url !== 'string' ||
|
||||
typeof value.clientId !== 'string' ||
|
||||
!value.clientId.trim() ||
|
||||
typeof value.clientSecret !== 'string' ||
|
||||
!value.clientSecret.trim()
|
||||
) {
|
||||
fail(translate(process.env, 'CLI 配置无效,请运行 ql login。'));
|
||||
}
|
||||
return {
|
||||
url: panelUrl(value.url),
|
||||
clientId: value.clientId,
|
||||
clientSecret: value.clientSecret,
|
||||
token: typeof value.token === 'string' ? value.token : undefined,
|
||||
expiration:
|
||||
typeof value.expiration === 'number' ? value.expiration : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT')
|
||||
fail(translate(process.env, '尚未登录,请运行 ql login。'), 3);
|
||||
if (error instanceof CliError) throw error;
|
||||
return fail(
|
||||
translate(
|
||||
process.env,
|
||||
'无法读取 CLI 配置,请检查文件权限并运行 ql login。',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
export function saveConfig(config: StoredConfig): void {
|
||||
const file = configPath();
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${file}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(temporary, JSON.stringify(config) + '\n', {
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
});
|
||||
fs.renameSync(temporary, file);
|
||||
} finally {
|
||||
fs.rmSync(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
fs.rmSync(configPath(), { force: true });
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import { fail } from '../../shared/errors';
|
||||
|
||||
export function panelUrl(value: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
fail(translate(process.env, '请输入有效的面板 URL。'), 2);
|
||||
}
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash
|
||||
) {
|
||||
fail(
|
||||
translate(
|
||||
process.env,
|
||||
'请使用不含凭据、查询参数或片段的 HTTP(S) 面板 URL。',
|
||||
),
|
||||
2,
|
||||
);
|
||||
}
|
||||
if (
|
||||
url.protocol === 'http:' &&
|
||||
!['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
|
||||
) {
|
||||
fail(
|
||||
translate(process.env, '远程认证需要 HTTPS;HTTP 仅允许回环地址。'),
|
||||
2,
|
||||
);
|
||||
}
|
||||
return url.href.replace(/\/+$/, '');
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import readline from 'node:readline';
|
||||
import { Writable } from 'node:stream';
|
||||
import { authenticate, request } from '../api/client';
|
||||
import { readConfig, saveConfig, logout } from '../auth/store';
|
||||
import { panelUrl } from '../auth/url';
|
||||
import { CliError, fail } from '../../shared/errors';
|
||||
import type { ApiResponse } from '../../shared/types';
|
||||
import type { Command, Session } from '../types';
|
||||
|
||||
export async function promptCredential(
|
||||
label: string,
|
||||
hidden = false,
|
||||
): Promise<string> {
|
||||
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
||||
fail(
|
||||
translate(
|
||||
process.env,
|
||||
'非交互登录需要 QL_CLIENT_ID 和 QL_CLIENT_SECRET。',
|
||||
),
|
||||
2,
|
||||
);
|
||||
}
|
||||
// Suppress readline echo through a stream, without using its private methods.
|
||||
const output = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
if (!hidden) process.stderr.write(chunk);
|
||||
callback();
|
||||
},
|
||||
});
|
||||
const terminal = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output,
|
||||
terminal: true,
|
||||
});
|
||||
process.stderr.write(`${label}: `);
|
||||
try {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const cancelled = () =>
|
||||
reject(new CliError(translate(process.env, '登录已取消。'), 2));
|
||||
terminal.once('SIGINT', cancelled);
|
||||
terminal.once('close', cancelled);
|
||||
terminal.question('', resolve);
|
||||
});
|
||||
} finally {
|
||||
terminal.close();
|
||||
output.end();
|
||||
if (hidden) process.stderr.write('\n');
|
||||
}
|
||||
}
|
||||
|
||||
export async function session(): Promise<Session> {
|
||||
if (process.env.QL_ACCESS_TOKEN || process.env.QL_URL) {
|
||||
if (!process.env.QL_ACCESS_TOKEN || !process.env.QL_URL)
|
||||
fail(
|
||||
translate(process.env, 'QL_URL 和 QL_ACCESS_TOKEN 必须同时提供。'),
|
||||
2,
|
||||
);
|
||||
return {
|
||||
url: panelUrl(process.env.QL_URL),
|
||||
token: process.env.QL_ACCESS_TOKEN,
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
expiration: 0,
|
||||
};
|
||||
}
|
||||
const config = readConfig();
|
||||
if (
|
||||
config.token &&
|
||||
typeof config.expiration === 'number' &&
|
||||
Number.isFinite(config.expiration) &&
|
||||
config.expiration > Date.now() / 1000 + 60
|
||||
) {
|
||||
return { ...config, token: config.token, expiration: config.expiration };
|
||||
}
|
||||
const refreshed = await authenticate(config);
|
||||
saveConfig(refreshed);
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
export async function auth(
|
||||
command: Extract<Command, { kind: 'login' | 'logout' | 'status' }>,
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
if (command.kind === 'logout') {
|
||||
logout();
|
||||
return { code: 200, data: { authenticated: false, localOnly: true } };
|
||||
}
|
||||
if (command.kind === 'login') {
|
||||
const url = panelUrl(command.url);
|
||||
const clientId =
|
||||
process.env.QL_CLIENT_ID ||
|
||||
(await promptCredential(translate(process.env, '应用 ID')));
|
||||
const clientSecret =
|
||||
process.env.QL_CLIENT_SECRET ||
|
||||
(await promptCredential(translate(process.env, '应用密钥'), true));
|
||||
if (!clientId.trim() || !clientSecret.trim())
|
||||
fail(translate(process.env, '凭据不能为空。'), 2);
|
||||
const config = await authenticate({ url, clientId, clientSecret });
|
||||
saveConfig(config);
|
||||
return { code: 200, data: { authenticated: true, url } };
|
||||
}
|
||||
const config = await session();
|
||||
const scope = command.scope ?? 'crons';
|
||||
await request(
|
||||
config,
|
||||
(
|
||||
{
|
||||
crons: 'crons?page=1&size=1',
|
||||
configs: 'configs/files',
|
||||
dashboard: 'dashboard/overview',
|
||||
} as Record<string, string>
|
||||
)[scope] ?? scope,
|
||||
);
|
||||
return {
|
||||
code: 200,
|
||||
data: {
|
||||
authenticated: true,
|
||||
url: config.url,
|
||||
expiration: config.expiration,
|
||||
scopeChecked: scope,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { openCommands } from './openCommands';
|
||||
import type { ApiResponse, LogResponse } from '../../shared/types';
|
||||
|
||||
import type { Invocation } from '../../shared/cli/arguments';
|
||||
import { fail } from '../../shared/errors';
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
|
||||
export async function dispatchRemote(
|
||||
command: Invocation,
|
||||
): Promise<ApiResponse<unknown> | LogResponse> {
|
||||
const { name, values, positionals } = command;
|
||||
if (openCommands.some((op) => op.name === name)) {
|
||||
const { openCommand } = await import('./open');
|
||||
return openCommand(command);
|
||||
}
|
||||
if (name.startsWith('auth ')) {
|
||||
const { auth } = await import('./auth');
|
||||
if (name === 'auth login')
|
||||
return auth({ kind: 'login', url: values.url as string });
|
||||
return name === 'auth logout'
|
||||
? auth({ kind: 'logout' })
|
||||
: auth({
|
||||
kind: 'status',
|
||||
scope: values.scope as string,
|
||||
});
|
||||
}
|
||||
if (name.startsWith('task ')) {
|
||||
const { task } = await import('./task');
|
||||
if (name === 'task list')
|
||||
return task({
|
||||
kind: 'list',
|
||||
page: Number(values.page),
|
||||
size: Number(values.size),
|
||||
search: values.search as string | undefined,
|
||||
});
|
||||
const id = Number(positionals[0]);
|
||||
if (name === 'task logs')
|
||||
return task({ kind: 'logs', id, tail: Number(values.tail) });
|
||||
return task({ kind: name.slice(5) as 'get' | 'run' | 'stop', id });
|
||||
}
|
||||
if (name.startsWith('subscription ')) {
|
||||
const { subscription } = await import('./subscription');
|
||||
return subscription(command);
|
||||
}
|
||||
return fail(translate(process.env, '未知命令,请运行 %s --help。', 'ql'), 2);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { readFile, stat, lstat } from 'node:fs/promises';
|
||||
import { openAsBlob } from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
import {
|
||||
openOperations,
|
||||
type OpenOperation,
|
||||
} from '../api/openOperations';
|
||||
import { request, isRecord } from '../api/client';
|
||||
import { session } from './auth';
|
||||
import { panelUrl } from '../auth/url';
|
||||
import { integer, type Invocation } from '../../shared/cli/arguments';
|
||||
import { fail } from '../../shared/errors';
|
||||
import type { ApiResponse } from '../../shared/types';
|
||||
|
||||
|
||||
function usage(zh: string, en: string): never {
|
||||
return fail(process.env.QL_LANG === 'en' ? en : zh, 2);
|
||||
}
|
||||
|
||||
async function input(value: string | boolean | undefined): Promise<unknown> {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
let source = value;
|
||||
try {
|
||||
if (value === '-') {
|
||||
if (process.stdin.isTTY)
|
||||
usage('请通过管道提供 JSON。', 'Pipe JSON to stdin.');
|
||||
const chunks: Buffer[] = [];
|
||||
let length = 0;
|
||||
for await (const chunk of process.stdin) {
|
||||
const buffer = Buffer.from(chunk);
|
||||
length += buffer.length;
|
||||
if (length > 50 * 1024 * 1024) throw new Error('Too large');
|
||||
chunks.push(buffer);
|
||||
}
|
||||
source = Buffer.concat(chunks).toString('utf8');
|
||||
} else if (value.startsWith('@')) {
|
||||
const file = value.slice(1);
|
||||
const info = await stat(file);
|
||||
if (!info.isFile() || info.size > 50 * 1024 * 1024)
|
||||
throw new Error('Invalid input');
|
||||
source = await readFile(file, 'utf8');
|
||||
}
|
||||
return JSON.parse(source);
|
||||
} catch {
|
||||
return usage(
|
||||
'JSON 输入无效或文件无法读取。',
|
||||
'Invalid JSON input or unreadable file.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function route(method: string, path: string): OpenOperation {
|
||||
const match = openOperations.find(
|
||||
(op) =>
|
||||
op.method === method &&
|
||||
new RegExp(
|
||||
'^' + op.path.replace(/:[A-Za-z]+/g, '[1-9][0-9]*') + '$',
|
||||
).test(path),
|
||||
);
|
||||
if (!match)
|
||||
usage(
|
||||
'方法或路径不属于当前支持的 OpenAPI。',
|
||||
'Method/path is not a supported OpenAPI route.',
|
||||
);
|
||||
for (const [index, part] of match.path.split('/').entries())
|
||||
if (part.startsWith(':')) integer(path.split('/')[index]!);
|
||||
return match;
|
||||
}
|
||||
|
||||
function redactApps(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(redactApps);
|
||||
if (!isRecord(value)) return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([key]) => !['client_secret', 'tokens'].includes(key))
|
||||
.map(([key, item]) => [key, redactApps(item)]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function openCommand(
|
||||
command: Invocation,
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
if (command.name === 'api routes') return { code: 200, data: openOperations };
|
||||
const generic = command.name === 'api request';
|
||||
const values = command.values;
|
||||
if (values.data === '-' && values.query === '-')
|
||||
usage(
|
||||
'--data 和 --query 不能同时读取 stdin。',
|
||||
'--data and --query cannot both read stdin.',
|
||||
);
|
||||
let endpoint = generic
|
||||
? command.positionals[1]!.replace(/^\/open\//, '').replace(/^\//, '')
|
||||
: '';
|
||||
if (generic && (!/^[\w/-]+$/.test(endpoint) || endpoint.includes('//')))
|
||||
usage(
|
||||
'请使用相对 OpenAPI 路径,查询参数通过 --query 提供。',
|
||||
'Use a relative OpenAPI path; pass query parameters with --query.',
|
||||
);
|
||||
const op = generic
|
||||
? route(command.positionals[0]!.toUpperCase(), endpoint)
|
||||
: openOperations.find((item) => item.name === command.name)!;
|
||||
if (!op) usage('未知 OpenAPI 命令。', 'Unknown OpenAPI command.');
|
||||
let body = await input(values.data);
|
||||
const query = await input(values.query);
|
||||
if (query !== undefined && !isRecord(query))
|
||||
usage('--query 必须为 JSON 对象。', '--query must be a JSON object.');
|
||||
if (!generic) {
|
||||
endpoint = op.path;
|
||||
for (const [index, name] of (op.params ?? []).entries()) {
|
||||
const value = integer(command.positionals[index]!);
|
||||
endpoint = endpoint.replace(`:${name}`, String(value));
|
||||
if (op.body === 'object-id') {
|
||||
if (body !== undefined && !isRecord(body))
|
||||
usage('--data 必须为 JSON 对象。', '--data must be a JSON object.');
|
||||
if (isRecord(body) && body.id !== undefined && body.id !== value)
|
||||
usage(
|
||||
'请求体 ID 与命令 ID 不一致。',
|
||||
'Body ID differs from command ID.',
|
||||
);
|
||||
body = { ...(body as Record<string, unknown>), id: value };
|
||||
}
|
||||
}
|
||||
if (op.body === 'ids')
|
||||
body = command.positionals.map((value) => integer(value));
|
||||
for (const key of [
|
||||
'name',
|
||||
'command',
|
||||
'schedule',
|
||||
'type',
|
||||
'url',
|
||||
'alias',
|
||||
'schedule-type',
|
||||
'branch',
|
||||
'whitelist',
|
||||
'blacklist',
|
||||
'scopes',
|
||||
]) {
|
||||
if (values[key] === undefined) continue;
|
||||
if (body !== undefined && !isRecord(body))
|
||||
usage('--data 必须为 JSON 对象。', '--data must be a JSON object.');
|
||||
const field = key.replace(/-/g, '_');
|
||||
if (isRecord(body) && Object.hasOwn(body, field))
|
||||
usage(
|
||||
'字段不能同时通过 --data 和选项提供。',
|
||||
'Do not provide a field in both --data and flags.',
|
||||
);
|
||||
body = {
|
||||
...(body as Record<string, unknown>),
|
||||
[field]:
|
||||
key === 'scopes'
|
||||
? String(values[key])
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
: values[key],
|
||||
};
|
||||
}
|
||||
const required: Record<string, string[]> = {
|
||||
'task create': ['command', 'schedule'],
|
||||
'task update': ['command', 'schedule'],
|
||||
'subscription create': ['type', 'url', 'alias', 'schedule_type'],
|
||||
'subscription update': ['type', 'url', 'alias'],
|
||||
};
|
||||
if (
|
||||
(required[op.name] ?? []).some(
|
||||
(key) => !isRecord(body) || typeof body[key] !== 'string' || !body[key],
|
||||
)
|
||||
)
|
||||
usage(
|
||||
'缺少必需字段,请查看命令帮助和 OpenAPI 参考。',
|
||||
'Missing required fields; see command help and OpenAPI reference.',
|
||||
);
|
||||
}
|
||||
if (op.method === 'GET' && body !== undefined)
|
||||
usage('GET 不接受请求体。', 'GET does not accept a body.');
|
||||
if (op.body && body === undefined && !values.file)
|
||||
usage(
|
||||
'此命令需要 --data 或对应字段选项。',
|
||||
'This command needs --data or field options.',
|
||||
);
|
||||
if (values.file) {
|
||||
if (!op.upload)
|
||||
usage('此接口不支持文件上传。', 'This route does not accept uploads.');
|
||||
if (body !== undefined && !isRecord(body))
|
||||
usage(
|
||||
'上传字段必须为 JSON 对象。',
|
||||
'Upload fields must be a JSON object.',
|
||||
);
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(body ?? {})) {
|
||||
if (!['string', 'number', 'boolean'].includes(typeof value))
|
||||
usage('上传字段必须为标量。', 'Upload fields must be scalars.');
|
||||
form.append(key, String(value));
|
||||
}
|
||||
try {
|
||||
const file = String(values.file);
|
||||
if (!(await stat(file)).isFile()) throw new Error('Not a file');
|
||||
form.append(op.upload, await openAsBlob(file), basename(file));
|
||||
} catch {
|
||||
usage('上传文件无法读取。', 'Cannot read upload file.');
|
||||
}
|
||||
body = form;
|
||||
} else if (op.upload && op.path !== 'scripts')
|
||||
usage('此接口需要 --file。', 'This route requires --file.');
|
||||
if (op.download && !values.output)
|
||||
usage('此接口需要 --output 保存响应。', 'This route requires --output.');
|
||||
if (values.output) {
|
||||
if (!op.download)
|
||||
usage('此接口不支持 --output。', 'This route does not support --output.');
|
||||
try {
|
||||
await lstat(String(values.output));
|
||||
usage('输出文件已存在。', 'Output file already exists.');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query ?? {})) {
|
||||
for (const item of Array.isArray(value) ? value : [value]) {
|
||||
if (
|
||||
item === null ||
|
||||
!['string', 'number', 'boolean'].includes(typeof item)
|
||||
)
|
||||
usage(
|
||||
'查询参数必须为标量或标量数组。',
|
||||
'Query values must be scalars or arrays of scalars.',
|
||||
);
|
||||
params.append(key, String(item));
|
||||
}
|
||||
}
|
||||
if (params.size) endpoint += `?${params}`;
|
||||
const config = op.anonymous
|
||||
? {
|
||||
url: panelUrl(process.env.QL_URL || ''),
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
}
|
||||
: await session();
|
||||
const result = await request(config, endpoint, {
|
||||
method: op.method,
|
||||
body,
|
||||
authenticated: !op.anonymous,
|
||||
output: values.output as string | undefined,
|
||||
text: op.path === 'system/log',
|
||||
timeoutMs: values.timeout ? Number(values.timeout) * 1000 : undefined,
|
||||
});
|
||||
const data =
|
||||
op.path.startsWith('apps') && !values['show-secrets']
|
||||
? redactApps(result.data)
|
||||
: result.data;
|
||||
return { ...result, code: 200, data: data ?? null };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { openOperations } from '../api/openOperations';
|
||||
import type { CommandSpec, OptionSpec } from '../../shared/cli/registry';
|
||||
|
||||
const string = (description: string): OptionSpec => ({
|
||||
type: 'string',
|
||||
description,
|
||||
});
|
||||
export const openCommands: CommandSpec[] = openOperations
|
||||
.filter((op) => !op.existing)
|
||||
.map((op) => {
|
||||
const options: Record<string, OptionSpec> = {
|
||||
query: string('Query object as JSON, @file or - for stdin'),
|
||||
timeout: {
|
||||
...string('Request timeout in seconds'),
|
||||
integer: { min: 1, max: 3600 },
|
||||
},
|
||||
};
|
||||
if (op.body && op.body !== 'ids')
|
||||
options.data = string('Request body as JSON, @file or - for stdin');
|
||||
if (op.upload) options.file = string('Local file to upload');
|
||||
if (op.download)
|
||||
options.output = {
|
||||
...string('Save response to a new local file'),
|
||||
required: true,
|
||||
};
|
||||
if (
|
||||
[
|
||||
'task create',
|
||||
'task update',
|
||||
'subscription create',
|
||||
'subscription update',
|
||||
'app create',
|
||||
'app update',
|
||||
].includes(op.name)
|
||||
) {
|
||||
options.name = string('Resource name');
|
||||
if (op.name.startsWith('task ')) {
|
||||
options.command = string('Command executed by the remote panel');
|
||||
options.schedule = string('Cron schedule');
|
||||
} else if (op.name.startsWith('subscription ')) {
|
||||
for (const key of [
|
||||
'type',
|
||||
'url',
|
||||
'alias',
|
||||
'schedule',
|
||||
'schedule-type',
|
||||
'branch',
|
||||
'whitelist',
|
||||
'blacklist',
|
||||
])
|
||||
options[key] = string(`Subscription ${key}`);
|
||||
} else options.scopes = string('Comma-separated application permissions');
|
||||
}
|
||||
if (op.name.startsWith('app '))
|
||||
options['show-secrets'] = {
|
||||
type: 'boolean',
|
||||
description: 'Explicitly include application credentials in output',
|
||||
};
|
||||
const params = op.params ?? [];
|
||||
return {
|
||||
name: op.name,
|
||||
summary: `${op.method} /open/${op.path}`,
|
||||
arguments:
|
||||
op.body === 'ids' ? '<id...>' : params.map((p) => `<${p}>`).join(' '),
|
||||
minimum: op.body === 'ids' ? 1 : params.length,
|
||||
maximum: op.body === 'ids' ? 10000 : params.length,
|
||||
options,
|
||||
};
|
||||
});
|
||||
openCommands.push(
|
||||
{
|
||||
name: 'api request',
|
||||
summary: 'Call a registered OpenAPI route',
|
||||
arguments: '<method> <path>',
|
||||
minimum: 2,
|
||||
maximum: 2,
|
||||
options: {
|
||||
data: string('Request body as JSON, @file or - for stdin'),
|
||||
query: string('Query object as JSON, @file or - for stdin'),
|
||||
file: string('Local file to upload'),
|
||||
output: string('Save response to a new local file'),
|
||||
timeout: {
|
||||
...string('Request timeout in seconds'),
|
||||
integer: { min: 1, max: 3600 },
|
||||
},
|
||||
'show-secrets': {
|
||||
type: 'boolean',
|
||||
description: 'Explicitly include application credentials in output',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: 'api routes', summary: 'List supported OpenAPI routes and commands' },
|
||||
);
|
||||
@@ -0,0 +1,128 @@
|
||||
import { openCommands } from './openCommands';
|
||||
import { openOperations } from '../api/openOperations';
|
||||
import type { CommandSpec, CommandRegistry } from '../../shared/cli/registry';
|
||||
import { integer } from '../../shared/cli/arguments';
|
||||
const positiveId = '<id>';
|
||||
export const commands: readonly CommandSpec[] = [
|
||||
...openCommands,
|
||||
{
|
||||
name: 'auth login',
|
||||
summary: 'Authenticate with a remote panel application',
|
||||
options: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'Panel URL including optional base path',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'auth status',
|
||||
summary: 'Verify authentication and resource permission',
|
||||
options: {
|
||||
scope: {
|
||||
type: 'string',
|
||||
description: 'Permission to check',
|
||||
default: 'crons',
|
||||
choices: [
|
||||
...new Set(openOperations.map((op) => op.path.split('/')[0]!)),
|
||||
].filter((scope) => !['auth', 'update'].includes(scope)),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'auth logout',
|
||||
summary: 'Remove local credentials (does not revoke the server token)',
|
||||
},
|
||||
{
|
||||
name: 'task list',
|
||||
summary: 'List remote tasks',
|
||||
options: {
|
||||
search: { type: 'string', description: 'Search task name or command' },
|
||||
page: {
|
||||
type: 'string',
|
||||
short: 'p',
|
||||
description: 'Page number',
|
||||
default: '1',
|
||||
integer: { min: 1, max: Number.MAX_SAFE_INTEGER },
|
||||
},
|
||||
size: {
|
||||
type: 'string',
|
||||
description: 'Page size',
|
||||
default: '50',
|
||||
integer: { min: 1, max: 200 },
|
||||
},
|
||||
},
|
||||
},
|
||||
...['get', 'run', 'stop'].map(
|
||||
(action): CommandSpec => ({
|
||||
name: `task ${action}`,
|
||||
summary: `${action} a remote task by ID`,
|
||||
arguments: positiveId,
|
||||
minimum: 1,
|
||||
maximum: 1,
|
||||
}),
|
||||
),
|
||||
{
|
||||
name: 'task logs',
|
||||
summary: 'Read the latest task log',
|
||||
arguments: positiveId,
|
||||
minimum: 1,
|
||||
maximum: 1,
|
||||
options: {
|
||||
tail: {
|
||||
type: 'string',
|
||||
short: 'n',
|
||||
description: 'Last N lines',
|
||||
default: '200',
|
||||
integer: { min: 1, max: 10000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'subscription list',
|
||||
summary: 'List panel subscriptions',
|
||||
options: {
|
||||
search: { type: 'string', description: 'Search subscriptions' },
|
||||
},
|
||||
},
|
||||
...['get', 'run', 'stop', 'enable', 'disable'].map(
|
||||
(action): CommandSpec => ({
|
||||
name: `subscription ${action}`,
|
||||
summary: `${action} a panel subscription by ID`,
|
||||
arguments: '<id>',
|
||||
minimum: 1,
|
||||
maximum: 1,
|
||||
}),
|
||||
),
|
||||
{
|
||||
name: 'subscription logs',
|
||||
summary: 'Read latest subscription log',
|
||||
arguments: '<id>',
|
||||
minimum: 1,
|
||||
maximum: 1,
|
||||
options: {
|
||||
tail: {
|
||||
type: 'string',
|
||||
short: 'n',
|
||||
description: 'Last N lines',
|
||||
default: '200',
|
||||
integer: { min: 1, max: 10000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
export const registry: CommandRegistry = {
|
||||
commands,
|
||||
aliases: { login: ['auth', 'login'] },
|
||||
options: {},
|
||||
surface: 'public',
|
||||
validate(command) {
|
||||
if (
|
||||
!openCommands.some((op) => op.name === command.name) &&
|
||||
/^(task|subscription) /.test(command.name) &&
|
||||
!command.name.endsWith(' list')
|
||||
)
|
||||
integer(command.positionals[0]!);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import type { Invocation } from '../../shared/cli/arguments';
|
||||
import { isRecord, request } from '../api/client';
|
||||
import { session } from './auth';
|
||||
import { fail } from '../../shared/errors';
|
||||
import type { ApiResponse, LogResponse } from '../../shared/types';
|
||||
|
||||
|
||||
// Subscription API records can contain private repository credentials and hooks.
|
||||
// The management view deliberately exposes only the fields needed for selection.
|
||||
const visibleFields = [
|
||||
'id',
|
||||
'name',
|
||||
'alias',
|
||||
'type',
|
||||
'status',
|
||||
'is_disabled',
|
||||
'schedule',
|
||||
'schedule_type',
|
||||
'interval_schedule',
|
||||
'branch',
|
||||
'whitelist',
|
||||
'blacklist',
|
||||
'dependences',
|
||||
'extensions',
|
||||
'autoAddCron',
|
||||
'autoDelCron',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
];
|
||||
|
||||
function projectSubscription(value: unknown): Record<string, unknown> {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.id !== 'number' ||
|
||||
!Number.isSafeInteger(value.id) ||
|
||||
value.id <= 0
|
||||
)
|
||||
fail(translate(process.env, '订阅响应无效。'));
|
||||
return Object.fromEntries(
|
||||
visibleFields
|
||||
.filter((field) => value[field] !== undefined)
|
||||
.map((field) => [field, value[field]]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function subscription(
|
||||
command: Invocation,
|
||||
): Promise<ApiResponse<unknown> | LogResponse> {
|
||||
const config = await session();
|
||||
const action = command.name.split(' ')[1]!;
|
||||
if (action === 'list') {
|
||||
const search =
|
||||
typeof command.values.search === 'string'
|
||||
? `?${new URLSearchParams({ searchValue: command.values.search })}`
|
||||
: '';
|
||||
const result = await request(config, `subscriptions${search}`);
|
||||
if (!Array.isArray(result.data))
|
||||
fail(translate(process.env, '订阅列表响应无效。'));
|
||||
return { code: 200, data: result.data.map(projectSubscription) };
|
||||
}
|
||||
const id = Number(command.positionals[0]);
|
||||
if (action === 'get') {
|
||||
const result = projectSubscription(
|
||||
(await request(config, `subscriptions/${id}`)).data,
|
||||
);
|
||||
if (result.id !== id)
|
||||
fail(translate(process.env, '返回的订阅 ID 与请求不一致。'));
|
||||
return { code: 200, data: result };
|
||||
}
|
||||
if (action === 'logs') {
|
||||
const result = await request(config, `subscriptions/${id}/log`);
|
||||
if (typeof result.data !== 'string')
|
||||
fail(translate(process.env, '订阅日志响应无效。'));
|
||||
const lines = result.data.replace(/\r\n/g, '\n').split('\n');
|
||||
if (lines.at(-1) === '') lines.pop();
|
||||
const count = Number(command.values.tail);
|
||||
return {
|
||||
code: 200,
|
||||
data: lines.slice(-count).join('\n'),
|
||||
truncated: result.truncated === true || lines.length > count,
|
||||
};
|
||||
}
|
||||
if (!['run', 'stop', 'enable', 'disable'].includes(action))
|
||||
fail(translate(process.env, '不支持的订阅操作。'), 2);
|
||||
await request(config, `subscriptions/${action}`, {
|
||||
method: 'PUT',
|
||||
body: [id],
|
||||
});
|
||||
return { code: 200, data: { subscriptionId: id, action, accepted: true } };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { translate } from '../../shared/i18n/index';
|
||||
import { isRecord, request } from '../api/client';
|
||||
import { fail } from '../../shared/errors';
|
||||
import type { ApiResponse, LogResponse } from '../../shared/types';
|
||||
import type { Command, Task } from '../types';
|
||||
import { session } from './auth';
|
||||
|
||||
type TaskCommand = Extract<
|
||||
Command,
|
||||
{ kind: 'list' | 'get' | 'logs' | 'run' | 'stop' }
|
||||
>;
|
||||
|
||||
function isTask(value: unknown): value is Task {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.id === 'number' &&
|
||||
Number.isSafeInteger(value.id) &&
|
||||
value.id > 0
|
||||
);
|
||||
}
|
||||
|
||||
export async function task(
|
||||
command: TaskCommand,
|
||||
): Promise<ApiResponse<unknown> | LogResponse> {
|
||||
const config = await session();
|
||||
if (command.kind === 'list') {
|
||||
const query = new URLSearchParams({
|
||||
page: String(command.page),
|
||||
size: String(command.size),
|
||||
});
|
||||
if (command.search !== undefined) query.set('searchValue', command.search);
|
||||
const result = await request(config, `crons?${query}`);
|
||||
const data = result.data;
|
||||
if (
|
||||
!isRecord(data) ||
|
||||
!Array.isArray(data.data) ||
|
||||
!data.data.every(isTask) ||
|
||||
typeof data.total !== 'number' ||
|
||||
!Number.isSafeInteger(data.total) ||
|
||||
data.total < 0
|
||||
) {
|
||||
fail(translate(process.env, '任务列表响应无效。'));
|
||||
}
|
||||
return { code: 200, data: { data: data.data, total: data.total } };
|
||||
}
|
||||
if (command.kind === 'get') {
|
||||
const result = await request(config, `crons/${command.id}`);
|
||||
if (!isTask(result.data) || result.data.id !== command.id)
|
||||
fail(translate(process.env, '任务响应无效。'));
|
||||
return { code: 200, data: result.data };
|
||||
}
|
||||
if (command.kind === 'logs') {
|
||||
const result = await request(config, `crons/${command.id}/log`);
|
||||
if (
|
||||
typeof result.data !== 'string' ||
|
||||
(result.logStatus !== undefined && typeof result.logStatus !== 'string')
|
||||
)
|
||||
fail(translate(process.env, '日志响应无效。'));
|
||||
const lines = result.data.replace(/\r\n/g, '\n').split('\n');
|
||||
if (lines.at(-1) === '') lines.pop();
|
||||
return {
|
||||
code: 200,
|
||||
data: lines.slice(-command.tail).join('\n'),
|
||||
logStatus: result.logStatus as string | undefined,
|
||||
truncated: result.truncated === true || lines.length > command.tail,
|
||||
};
|
||||
}
|
||||
await request(config, `crons/${command.kind}`, {
|
||||
method: 'PUT',
|
||||
body: [command.id],
|
||||
});
|
||||
return {
|
||||
code: 200,
|
||||
data: { taskId: command.id, action: command.kind, accepted: true },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface Credentials {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export interface StoredConfig extends Credentials {
|
||||
token?: string;
|
||||
expiration?: number;
|
||||
}
|
||||
|
||||
export interface Session extends Credentials {
|
||||
token: string;
|
||||
expiration: number;
|
||||
}
|
||||
|
||||
// Preserve additional 2.x fields without depending on backend modules or ORM types.
|
||||
export interface Task {
|
||||
id: number;
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TaskList {
|
||||
data: Task[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type Command =
|
||||
| { kind: 'help' }
|
||||
| { kind: 'login'; url: string }
|
||||
| { kind: 'status'; scope?: string }
|
||||
| { kind: 'logout' }
|
||||
| { kind: 'list'; search?: string; page: number; size: number }
|
||||
| { kind: 'get'; id: number }
|
||||
| { kind: 'logs'; id: number; tail: number }
|
||||
| { kind: 'run' | 'stop'; id: number };
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
quietCommand,
|
||||
addFlags,
|
||||
flagValues,
|
||||
isArgumentError,
|
||||
assertOptionValues,
|
||||
} from './options';
|
||||
import type { CommandSpec } from './registry';
|
||||
import { fail } from '../errors';
|
||||
import { translate } from '../i18n/index';
|
||||
import { globalOptions, helpFor, type CommandRegistry } from './registry';
|
||||
|
||||
export interface Invocation {
|
||||
name: string;
|
||||
values: Record<string, string | boolean | undefined>;
|
||||
positionals: string[];
|
||||
json: boolean;
|
||||
help?: string;
|
||||
}
|
||||
|
||||
export function integer(
|
||||
value: string,
|
||||
min = 1,
|
||||
max = Number.MAX_SAFE_INTEGER,
|
||||
): number {
|
||||
if (
|
||||
!/^\d+$/.test(value) ||
|
||||
!Number.isSafeInteger(Number(value)) ||
|
||||
Number(value) < min ||
|
||||
Number(value) > max
|
||||
) {
|
||||
fail(translate(process.env, '请输入支持范围内的整数。'), 2);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
export function parse(args: string[], registry: CommandRegistry): Invocation {
|
||||
const { commands, aliases, surface, options: localOptions } = registry;
|
||||
const entry = 'ql';
|
||||
const input = [...args];
|
||||
// Aliases are vocabulary, while Commander owns command/option parsing.
|
||||
let offset = 0;
|
||||
while (input[offset] === '--json') offset++;
|
||||
if (input[offset] && Object.hasOwn(aliases, input[offset]!))
|
||||
input.splice(offset, 1, ...aliases[input[offset]!]!);
|
||||
if (input[offset] === 'help') input.splice(offset, 1, '--help');
|
||||
const root = quietCommand('ql').enablePositionalOptions();
|
||||
root.action(() => {});
|
||||
addFlags(root, globalOptions);
|
||||
let spec: CommandSpec | undefined;
|
||||
let group: string | undefined;
|
||||
let values: Invocation['values'] = {};
|
||||
let positionals: string[] = [];
|
||||
const groups = new Map<string, ReturnType<typeof quietCommand>>();
|
||||
for (const item of commands.filter((item) =>
|
||||
surface === 'local' ? item.local : !item.local,
|
||||
)) {
|
||||
const [parentName, action] = item.name.split(' ');
|
||||
if (!groups.has(parentName!)) {
|
||||
const parent = quietCommand(parentName!).enablePositionalOptions();
|
||||
addFlags(parent, globalOptions);
|
||||
parent.action(() => {
|
||||
group = parentName;
|
||||
values = flagValues(parent, globalOptions);
|
||||
});
|
||||
root.addCommand(parent);
|
||||
groups.set(parentName!, parent);
|
||||
}
|
||||
const schema = {
|
||||
...globalOptions,
|
||||
...(item.local ? localOptions : {}),
|
||||
...item.options,
|
||||
};
|
||||
const command = quietCommand(action!).argument('[arguments...]');
|
||||
addFlags(command, schema);
|
||||
command.action(() => {
|
||||
spec = item;
|
||||
group = parentName;
|
||||
values = flagValues(command, schema);
|
||||
positionals = command.args;
|
||||
const parentValues = flagValues(groups.get(parentName!)!, globalOptions);
|
||||
for (const key of ['json', 'help']) {
|
||||
if (parentValues[key] && values[key])
|
||||
fail(
|
||||
translate(process.env, '选项重复,请运行 %s --help。', entry),
|
||||
2,
|
||||
);
|
||||
if (parentValues[key]) values[key] = parentValues[key];
|
||||
}
|
||||
});
|
||||
groups.get(parentName!)!.addCommand(command);
|
||||
}
|
||||
try {
|
||||
assertOptionValues(
|
||||
input,
|
||||
Object.assign({}, globalOptions, ...commands.map((item) => item.options)),
|
||||
);
|
||||
root.parse(input, { from: 'user' });
|
||||
} catch (error) {
|
||||
if (!isArgumentError(error)) throw error;
|
||||
const code = (error as { code: string }).code;
|
||||
const unknown =
|
||||
code === 'commander.unknownCommand' ||
|
||||
(code === 'commander.excessArguments' && !spec);
|
||||
fail(
|
||||
translate(
|
||||
process.env,
|
||||
unknown
|
||||
? '未知命令,请运行 %s --help。'
|
||||
: '选项未知或缺少选项值,请运行 %s --help。',
|
||||
entry,
|
||||
),
|
||||
2,
|
||||
);
|
||||
}
|
||||
const inherited = flagValues(root, globalOptions);
|
||||
for (const key of ['json', 'help']) {
|
||||
if (inherited[key] && values[key])
|
||||
fail(translate(process.env, '选项重复,请运行 %s --help。', entry), 2);
|
||||
if (inherited[key]) values[key] = inherited[key];
|
||||
}
|
||||
const helpOnly = !spec;
|
||||
const schema = {
|
||||
...globalOptions,
|
||||
...(spec?.local ? localOptions : {}),
|
||||
...spec?.options,
|
||||
};
|
||||
const result: Invocation = {
|
||||
name: spec?.name ?? 'help',
|
||||
positionals,
|
||||
values,
|
||||
json: values.json === true,
|
||||
};
|
||||
if (helpOnly || values.help)
|
||||
return { ...result, help: helpFor(registry, spec, group) };
|
||||
if (
|
||||
result.positionals.length < (spec?.minimum ?? 0) ||
|
||||
result.positionals.length > (spec?.maximum ?? 0)
|
||||
)
|
||||
fail(translate(process.env, '参数多余或缺少必要参数。'), 2);
|
||||
for (const [name, option] of Object.entries(schema)) {
|
||||
if (option.required && !values[name])
|
||||
fail(translate(process.env, '缺少 --%s。', name), 2);
|
||||
if (option.choices && !option.choices.includes(values[name] as string))
|
||||
fail(translate(process.env, '--%s 无效。', name), 2);
|
||||
if (option.integer && typeof values[name] === 'string')
|
||||
integer(values[name] as string, option.integer.min, option.integer.max);
|
||||
}
|
||||
registry.validate?.(result);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// One shared bundled copy: other CLI modules stay separate and lazy-loaded.
|
||||
export { Command, Option, CommanderError } from 'commander';
|
||||
@@ -0,0 +1,49 @@
|
||||
import { parse } from './arguments';
|
||||
import { translate } from '../i18n/index';
|
||||
import { CliError } from '../errors';
|
||||
import type { Invocation } from './arguments';
|
||||
import type { CommandRegistry } from './registry';
|
||||
|
||||
export async function invoke(
|
||||
args: string[],
|
||||
registry: CommandRegistry,
|
||||
dispatch: (command: Invocation) => Promise<unknown>,
|
||||
signal?: AbortSignal,
|
||||
interruptionCode?: () => number | undefined,
|
||||
): Promise<number> {
|
||||
let json = args
|
||||
.slice(0, args.indexOf('--') < 0 ? undefined : args.indexOf('--'))
|
||||
.includes('--json');
|
||||
try {
|
||||
const command = parse(args, registry);
|
||||
json = command.json;
|
||||
if (command.help) {
|
||||
process.stdout.write(
|
||||
(json
|
||||
? JSON.stringify({ code: 200, data: { help: command.help } })
|
||||
: command.help) + '\n',
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
const result = await dispatch(command);
|
||||
signal?.throwIfAborted();
|
||||
process.stdout.write(JSON.stringify(result, null, json ? 0 : 2) + '\n');
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const cancelled = signal?.aborted;
|
||||
const code = cancelled
|
||||
? interruptionCode?.() ?? 1
|
||||
: error instanceof CliError
|
||||
? error.exitCode
|
||||
: 1;
|
||||
const message = cancelled
|
||||
? translate(process.env, 'CLI 操作已取消。')
|
||||
: error instanceof CliError
|
||||
? error.message
|
||||
: translate(process.env, 'CLI 操作失败,请检查本机配置和权限。');
|
||||
process.stderr.write(
|
||||
(json ? JSON.stringify({ code, message }) : message) + '\n',
|
||||
);
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Command, CommanderError, Option } from './commander';
|
||||
import { fail } from '../errors';
|
||||
import { translate } from '../i18n/index';
|
||||
|
||||
export interface FlagDefinition {
|
||||
type: 'string' | 'boolean';
|
||||
short?: string;
|
||||
description?: string;
|
||||
default?: string | boolean;
|
||||
}
|
||||
|
||||
export function quietCommand(name: string): Command {
|
||||
return new Command(name)
|
||||
.helpOption(false)
|
||||
.addHelpCommand(false)
|
||||
.exitOverride()
|
||||
.configureOutput({ writeOut() {}, writeErr() {} });
|
||||
}
|
||||
|
||||
export function addFlags(
|
||||
command: Command,
|
||||
schema: Record<string, FlagDefinition>,
|
||||
rejectDuplicates = true,
|
||||
): void {
|
||||
const seen = new Set<string>();
|
||||
for (const [name, definition] of Object.entries(schema)) {
|
||||
const option = new Option(
|
||||
`${definition.short ? `-${definition.short}, ` : ''}--${name}${definition.type === 'string' ? ' <value>' : ''}`,
|
||||
definition.description,
|
||||
);
|
||||
// --no-startup is a positive application flag, not Commander negation.
|
||||
option.negate = false;
|
||||
if (definition.default !== undefined) option.default(definition.default);
|
||||
command.addOption(option);
|
||||
command.on(`option:${option.name()}`, () => {
|
||||
if (rejectDuplicates && seen.has(name))
|
||||
fail(translate(process.env, '选项重复,请运行 %s --help。', 'ql'), 2);
|
||||
seen.add(name);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function flagValues(
|
||||
command: Command,
|
||||
schema: Record<string, FlagDefinition>,
|
||||
): Record<string, string | boolean | undefined> {
|
||||
const raw = command.opts();
|
||||
const result: Record<string, string | boolean | undefined> = {};
|
||||
for (const name of Object.keys(schema)) {
|
||||
const option = command.options.find(item => item.long === `--${name}`)!;
|
||||
if (raw[option.attributeName()] !== undefined)
|
||||
result[name] = raw[option.attributeName()];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Commander permits --url --json as a string value. Preserve the previous
|
||||
// strict contract: leading-dash values need --url=--json (or -p-1).
|
||||
export function assertOptionValues(
|
||||
args: string[],
|
||||
schema: Record<string, FlagDefinition>,
|
||||
passThrough = false,
|
||||
): void {
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const token = args[index]!;
|
||||
if (token === '--') break;
|
||||
if (!token.startsWith('-') || token === '-') {
|
||||
if (passThrough) break;
|
||||
continue;
|
||||
}
|
||||
let expectsValue = false;
|
||||
if (token.startsWith('--')) {
|
||||
if (!token.includes('=')) expectsValue = schema[token.slice(2)]?.type === 'string';
|
||||
} else {
|
||||
for (let offset = 1; offset < token.length; offset++) {
|
||||
const definition = Object.values(schema).find(option => option.short === token[offset]);
|
||||
if (!definition) break;
|
||||
if (definition.type === 'string') {
|
||||
expectsValue = offset === token.length - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (expectsValue) {
|
||||
const next = args[++index];
|
||||
if (next === undefined || (next.length > 1 && next.startsWith('-')))
|
||||
throw new CommanderError(2, 'commander.optionMissingArgument', 'Missing option value');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseFlags(
|
||||
args: string[],
|
||||
schema: Record<string, FlagDefinition>,
|
||||
options: { passThrough?: boolean; rejectDuplicates?: boolean } = {},
|
||||
): { values: Record<string, string | boolean | undefined>; positionals: string[] } {
|
||||
const command = quietCommand('ql').allowExcessArguments(true);
|
||||
if (options.passThrough) command.passThroughOptions();
|
||||
addFlags(command, schema, options.rejectDuplicates ?? true);
|
||||
assertOptionValues(args, schema, options.passThrough);
|
||||
command.parse(args, { from:'user' });
|
||||
return { values:flagValues(command, schema), positionals:command.args };
|
||||
}
|
||||
|
||||
export function isArgumentError(error: unknown): boolean {
|
||||
return error instanceof CommanderError;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { helpText } from '../i18n/help';
|
||||
import { quietCommand, addFlags } from './options';
|
||||
export interface OptionSpec {
|
||||
type: 'string' | 'boolean';
|
||||
short?: string;
|
||||
description: string;
|
||||
default?: string | boolean;
|
||||
required?: boolean;
|
||||
choices?: readonly string[];
|
||||
integer?: { min: number; max: number };
|
||||
}
|
||||
|
||||
export interface CommandSpec {
|
||||
name: string;
|
||||
summary: string;
|
||||
arguments?: string;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
options?: Record<string, OptionSpec>;
|
||||
local?: boolean;
|
||||
}
|
||||
|
||||
export type CommandSurface = 'public' | 'local';
|
||||
|
||||
export interface CommandRegistry {
|
||||
commands: readonly CommandSpec[];
|
||||
aliases: Record<string, string[]>;
|
||||
options: Record<string, OptionSpec>;
|
||||
surface: CommandSurface;
|
||||
validate?: (command: import('./arguments').Invocation) => void;
|
||||
}
|
||||
export const globalOptions: Record<string, OptionSpec> = {
|
||||
json: {
|
||||
type: 'boolean',
|
||||
description: 'One JSON result on stdout; diagnostics on stderr',
|
||||
},
|
||||
help: { type: 'boolean', short: 'h', description: 'Show help' },
|
||||
};
|
||||
|
||||
export function helpFor(
|
||||
registry: CommandRegistry,
|
||||
spec?: CommandSpec,
|
||||
group?: string,
|
||||
): string {
|
||||
const { commands, surface } = registry;
|
||||
const selected = spec
|
||||
? [spec]
|
||||
: commands.filter(
|
||||
(item) =>
|
||||
(surface === 'local' ? item.local : !item.local) &&
|
||||
(!group || item.name.startsWith(`${group} `)),
|
||||
);
|
||||
const displayName = (name: string) =>
|
||||
surface === 'local' ? name.replace(/^local /, '') : name;
|
||||
const program = quietCommand(
|
||||
spec ? `ql ${displayName(spec.name)}` : `ql${group ? ' ' + group : ''}`,
|
||||
);
|
||||
program.description('QingLong 2.x CLI');
|
||||
if (spec) {
|
||||
if (spec.arguments) program.arguments(spec.arguments);
|
||||
program.description(helpText(spec.summary));
|
||||
} else {
|
||||
for (const item of selected) {
|
||||
const child = quietCommand(displayName(item.name)).description(
|
||||
helpText(item.summary),
|
||||
);
|
||||
if (item.arguments) child.arguments(item.arguments);
|
||||
program.addCommand(child);
|
||||
}
|
||||
}
|
||||
const options = {
|
||||
...globalOptions,
|
||||
...registry.options,
|
||||
...spec?.options,
|
||||
};
|
||||
addFlags(
|
||||
program,
|
||||
Object.fromEntries(
|
||||
Object.entries(options).map(([name, option]) => [
|
||||
name,
|
||||
{ ...option, description: helpText(option.description) },
|
||||
]),
|
||||
),
|
||||
);
|
||||
return program
|
||||
.helpInformation()
|
||||
.replace(/^Usage:/m, helpText('Usage:'))
|
||||
.replace(/^Options:/m, helpText('Options:'))
|
||||
.replace(
|
||||
/^Commands:/m,
|
||||
process.env.QL_LANG === 'en' ? 'Commands:' : '命令:',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { quietCommand } from './options';
|
||||
|
||||
// Delegate to a lazily loaded command implementation in this process. Keep
|
||||
// the original argv slice: Commander consumes a leading -- during parsing,
|
||||
// but the script/legacy parsers must receive that boundary unchanged.
|
||||
export async function routeCommands(
|
||||
name: string,
|
||||
args: string[],
|
||||
handlers: Record<string, (args: string[]) => Promise<number>>,
|
||||
fallback: () => Promise<number>,
|
||||
): Promise<number> {
|
||||
let result: number | undefined;
|
||||
const root = quietCommand(name).enablePositionalOptions()
|
||||
.passThroughOptions().allowUnknownOption().argument('[arguments...]');
|
||||
root.action(async () => { result = await fallback(); });
|
||||
for (const [command, handler] of Object.entries(handlers)) {
|
||||
root.addCommand(quietCommand(command).allowUnknownOption()
|
||||
.allowExcessArguments(true).passThroughOptions()
|
||||
.action(async () => { result = await handler(args.slice(1)); }));
|
||||
}
|
||||
await root.parseAsync(args, { from:'user' });
|
||||
return result ?? await fallback();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export type ExitCode = 1 | 2 | 3;
|
||||
|
||||
export class CliError extends Error {
|
||||
constructor(message: string, readonly exitCode: ExitCode = 1) {
|
||||
super(message);
|
||||
this.name = 'CliError';
|
||||
}
|
||||
}
|
||||
|
||||
export function fail(message: string, exitCode: ExitCode = 1): never {
|
||||
throw new CliError(message, exitCode);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// Migrated from shell/lang/en.sh; the original remains for differential evaluation.
|
||||
export const english: Record<string, string> = {
|
||||
'需要双因素验证,请调用 user two-factor-login。': 'Two-factor authentication required; use user two-factor-login.',
|
||||
'QL_URL 和 QL_ACCESS_TOKEN 必须同时提供。': 'Provide QL_URL and QL_ACCESS_TOKEN together.',
|
||||
'接口返回 JSON,未保存下载文件。': 'The endpoint returned JSON; no download was saved.',
|
||||
'已选择的 CLI 安装路径必须为绝对路径。':
|
||||
'The selected CLI installation path must be absolute.',
|
||||
'保留 CLI 入口仅支持已确认的 2.x 升级载荷。':
|
||||
'Preserving CLI entries requires a verified 2.x upgrade payload.',
|
||||
'无法确认已选择的 CLI 命令加载器,请检查安装。':
|
||||
'Cannot verify the selected CLI command loader. Check the installation.',
|
||||
|
||||
'容器入口仅接受通过环境变量配置。':
|
||||
'Container entry accepts configuration through environment variables only.',
|
||||
'任务延迟过滤已取消。': 'Task delay filtering cancelled.',
|
||||
'任务延迟过滤失败(退出码 %s)。': 'Task delay filtering failed (exit %s).',
|
||||
'直接运行的后端在启动期间退出(%s)。':
|
||||
'Direct backend exited during startup (%s).',
|
||||
'无法获取本机系统令牌,请检查运行中的面板。':
|
||||
'Cannot obtain a local system token. Check the running panel.',
|
||||
'本机面板端口无效。': 'Invalid local panel port.',
|
||||
'本机 API 拒绝请求(HTTP %s)。': 'Local API rejected request (HTTP %s).',
|
||||
'本机 API 请求失败;请先检查操作结果,不要直接重试写入。':
|
||||
'Local API request failed; do not retry a mutation without checking its result.',
|
||||
'本机 API 拒绝请求,请检查面板日志和权限。':
|
||||
'Local API rejected request. Check panel logs and permissions.',
|
||||
'任务列表响应无效。': 'Invalid task list response.',
|
||||
'任务响应无效。': 'Invalid task response.',
|
||||
'日志响应无效。': 'Invalid log response.',
|
||||
'订阅响应无效。': 'Invalid subscription response.',
|
||||
'订阅列表响应无效。': 'Invalid subscription list response.',
|
||||
'返回的订阅 ID 与请求不一致。': 'Unexpected subscription ID.',
|
||||
'订阅日志响应无效。': 'Invalid subscription log response.',
|
||||
'不支持的订阅操作。': 'Unsupported subscription operation.',
|
||||
'路径必须位于其管理目录内部。':
|
||||
'Path must be a descendant of its managed directory.',
|
||||
'仓库路径无效。': 'Invalid repository path.',
|
||||
'单文件订阅需要 HTTP(S) 地址。': 'Raw subscriptions require HTTP(S).',
|
||||
'订阅任务同步返回了无效任务列表。':
|
||||
'Invalid task list during subscription reconciliation.',
|
||||
'无法启动必要的可执行程序:%s': 'Cannot start required executable: %s',
|
||||
'子进程输出写入失败。': 'Child output sink failed.',
|
||||
'子进程输出超过配置的捕获上限。':
|
||||
'Child output exceeded the configured capture limit.',
|
||||
'必要的可执行程序失败(%s,退出码 %s)。':
|
||||
'Required executable failed (%s, exit %s).',
|
||||
'CLI 安装路径必须为绝对路径。': 'CLI installation paths must be absolute.',
|
||||
'CLI 入口必须为普通文件。': 'CLI entry is not a regular file.',
|
||||
'命令日志目录无效。': 'Invalid command log directory.',
|
||||
'QlPort 必须在 1 到 65535 之间。': 'QlPort must be between 1 and 65535.',
|
||||
'CLI 调用失败。': 'CLI invocation failed.',
|
||||
'不支持此部署操作系统:%s。': 'Unsupported deployment OS: %s.',
|
||||
'Python 返回了无效版本。': 'Python returned an invalid version.',
|
||||
'启动需要以 /data 结尾的绝对数据目录。':
|
||||
'Startup requires an absolute data directory ending in /data.',
|
||||
'容器安装目录和数据目录必须为绝对路径。':
|
||||
'Container root and data directory must be absolute paths.',
|
||||
'容器目录不可写或不可访问:%s(UID %s)。请检查挂载目录的所有者和权限。':
|
||||
'Container directory is not writable/searchable: %s (UID %s). Check the mounted directory ownership and permissions.',
|
||||
'无法初始化 %s:%s': 'Cannot initialize %s: %s',
|
||||
'QL_SCHEDULER 必须为 node 或 system。':
|
||||
'QL_SCHEDULER must be node or system.',
|
||||
'容器调度器意外退出(%s)。': 'Container scheduler exited unexpectedly (%s).',
|
||||
'Bot 安装不支持此操作系统:%s。': 'Bot installation does not support OS: %s.',
|
||||
'Bot 仓库包含指向目录外部的符号链接。':
|
||||
'Bot repository contains an external symlink.',
|
||||
'Bot 仓库包含不支持的文件类型。':
|
||||
'Bot repository contains an unsupported entry.',
|
||||
'Bot 源必须为普通目录。': 'Bot source must be a regular directory.',
|
||||
'Bot 配置模板必须为普通文件。':
|
||||
'Bot configuration template must be a regular file.',
|
||||
'Bot 进程管理需要 Linux。': 'Bot process management requires Linux.',
|
||||
'Bot 安装和进程管理需要 Linux。':
|
||||
'Bot installation and process management require Linux.',
|
||||
'Bot 在启动期间退出(%s)。': 'Bot exited during startup (%s).',
|
||||
'订阅路径包含换行或空字符,无法安全过滤。':
|
||||
'Subscription paths with line breaks cannot be filtered safely.',
|
||||
'订阅 POSIX 正则表达式无效。':
|
||||
'Invalid subscription POSIX regular expression.',
|
||||
'订阅过滤失败(退出码 %s)。': 'Subscription filtering failed (exit %s).',
|
||||
'账号选择格式无效。': 'Invalid account selection.',
|
||||
'账号选择超出环境变量的账号范围。':
|
||||
'Account selection is outside the environment variable range.',
|
||||
'超时必须为正时长,例如 30s 或 5m。':
|
||||
'Timeout must be a positive duration, e.g. 30s or 5m.',
|
||||
'超时时长超出支持范围。': 'Timeout is outside the supported range.',
|
||||
'必须指定脚本或可执行程序。': 'A script or executable is required.',
|
||||
'必须指定有效的账号环境变量名。':
|
||||
'A valid account environment variable is required.',
|
||||
'cron 环境变量值无效。': 'Invalid cron environment value',
|
||||
'cron 安装路径必须为绝对路径。': 'Cron installation paths must be absolute',
|
||||
'拒绝覆盖不属于此 CLI 的用户 crontab 命令。':
|
||||
'Refusing to replace an unrelated user crontab command',
|
||||
'无法安装或读取面板 crontab,请检查环境路径和系统 cron 工具。':
|
||||
'Cannot install or read the selected panel crontab. Check its environment paths and system cron tool.',
|
||||
'后端进程未在 5 秒内停止,已取消重启。':
|
||||
'Direct backend did not stop within 5 seconds; restart cancelled.',
|
||||
'无法检查运行中的后端进程。': 'Cannot inspect running backend processes.',
|
||||
'升级文件包含指向目录外部的符号链接。':
|
||||
'Upgrade payload contains an external symlink.',
|
||||
'升级文件包含不支持的文件类型。':
|
||||
'Upgrade payload contains unsupported filesystem entries.',
|
||||
'升级归档包含无效路径。': 'Upgrade archive contains an invalid path.',
|
||||
'升级归档包含符号链接。': 'Upgrade archive contains symlinks.',
|
||||
'暂存升级指针无效。': 'Invalid staged upgrade pointer.',
|
||||
'暂存升级目录无效。': 'Invalid staged upgrade directory.',
|
||||
'暂存升级就绪记录与目录不匹配。':
|
||||
'Staged upgrade readiness does not match its directory.',
|
||||
'替换源与目标相同。': 'Replacement source equals destination.',
|
||||
'替换源位于目标目录内部。': 'Replacement source is inside its destination.',
|
||||
'重载失败;文件已恢复,但原服务无法重新启动。':
|
||||
'Reload failed; files restored, but the previous service could not restart.',
|
||||
'数据重载需要独立的数据目录。':
|
||||
'Data reload requires a dedicated data directory.',
|
||||
'暂存数据与已安装的数据目录必须分离。':
|
||||
'Staged data must be separate from the installed data directory.',
|
||||
'主机开机注册需要 OpenRC 或 systemd;仅在其他管理器负责服务时使用 --no-startup。':
|
||||
'Host startup registration requires OpenRC or systemd. Use --no-startup only when another supervisor owns the services.',
|
||||
'本机命令需要通过 --root 或 QL_DIR 指定绝对路径。':
|
||||
'Local commands require an absolute --root or QL_DIR.',
|
||||
'面板安装目录不存在或不是目录。': 'Panel root does not exist.',
|
||||
'用户配置加载已取消。': 'User configuration evaluation cancelled.',
|
||||
'用户配置加载失败。': 'User configuration evaluation failed.',
|
||||
'用户配置返回了无效的环境数据。':
|
||||
'User configuration returned invalid environment data.',
|
||||
|
||||
'用法:ql-compat update [true|false]': 'Usage: ql-compat update [true|false]',
|
||||
'用法:ql-compat reload [services|system|data]':
|
||||
'Usage: ql-compat reload [services|system|data]',
|
||||
'未知旧命令,请运行 ql-compat --help。':
|
||||
'Unknown legacy command. Run ql-compat --help.',
|
||||
'命令已中断。': 'Command interrupted.',
|
||||
'旧命令适配器执行失败。': 'Legacy command adapter failed.',
|
||||
'用法:ql-subscription-worker repo|raw <url> [旧订阅参数]':
|
||||
'Usage: ql-subscription-worker repo|raw <url> [legacy subscription arguments]',
|
||||
'订阅布尔参数无效,只能使用 true 或 false。': 'Invalid subscription boolean.',
|
||||
'订阅参数过多。': 'Too many subscription arguments.',
|
||||
'SUB_ID 无效,必须为正整数。': 'Invalid SUB_ID.',
|
||||
'订阅执行器失败,请检查本机日志。':
|
||||
'Subscription worker failed. Check local logs.',
|
||||
|
||||
'任务执行器选项无效。': 'Invalid runner options.',
|
||||
'本机任务在加载配置时已取消。':
|
||||
'Local task execution cancelled during configuration.',
|
||||
'本机任务执行失败,请检查面板环境和任务日志。':
|
||||
'Local task execution failed. Check the panel environment and task log.',
|
||||
'应用 ID': 'Client ID',
|
||||
应用密钥: 'Client secret',
|
||||
订阅: 'subscription',
|
||||
任务: 'task',
|
||||
应用凭据: 'application credentials',
|
||||
'应用凭据和 %s 权限': 'application credentials and %s permission',
|
||||
未知: 'unknown',
|
||||
' 执行结果可能未知,请先检查%s状态再考虑重试。':
|
||||
' Execution outcome may be unknown; check %s status before retrying.',
|
||||
'API 请求被拒绝(HTTP %s),请检查%s。%s':
|
||||
'API request rejected (HTTP %s). Check %s.%s',
|
||||
'请求失败或响应无效,执行结果未知。请先检查%s状态再考虑重试。':
|
||||
'Request failed or returned an invalid response; execution outcome is unknown. Check %s status before retrying.',
|
||||
'请求失败或返回的 JSON 无效,请检查实例 URL、网络和 TLS 证书。':
|
||||
'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 配置必须是当前用户拥有的私有普通文件(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。':
|
||||
'Cannot read CLI config. Check file permissions and run ql login.',
|
||||
'请输入有效的面板 URL。': 'A valid panel URL is required.',
|
||||
'请使用不含凭据、查询参数或片段的 HTTP(S) 面板 URL。':
|
||||
'Use an HTTP(S) panel URL without credentials, query or fragment.',
|
||||
'远程认证需要 HTTPS;HTTP 仅允许回环地址。':
|
||||
'Remote authentication requires HTTPS; HTTP is allowed only for loopback.',
|
||||
'非交互登录需要 QL_CLIENT_ID 和 QL_CLIENT_SECRET。':
|
||||
'Non-interactive login requires QL_CLIENT_ID and QL_CLIENT_SECRET.',
|
||||
'登录已取消。': 'Login cancelled.',
|
||||
'凭据不能为空。': 'Credentials cannot be empty.',
|
||||
'认证响应无效。': 'Invalid authentication response.',
|
||||
|
||||
'请输入支持范围内的整数。': 'Expected an integer within the supported range.',
|
||||
'未知命令,请运行 %s --help。': 'Unknown command. Run %s --help.',
|
||||
'选项未知或缺少选项值,请运行 %s --help。':
|
||||
'Unknown or missing option. Run %s --help.',
|
||||
'选项重复,请运行 %s --help。': 'Duplicate option. Run %s --help.',
|
||||
'参数多余或缺少必要参数。':
|
||||
'Unexpected argument or missing required argument.',
|
||||
'缺少 --%s。': 'Missing --%s.',
|
||||
'--%s 无效。': 'Invalid --%s.',
|
||||
'CLI 操作已取消。': 'CLI operation cancelled.',
|
||||
'CLI 操作失败,请检查本机配置和权限。':
|
||||
'CLI operation failed. Check local configuration and permissions.',
|
||||
'任务状态上报失败,请检查本机面板。\n':
|
||||
'Task lifecycle reporting failed; check the local panel.\n',
|
||||
'任务统计上报失败,请检查本机面板。\n':
|
||||
'Task statistics reporting failed; check the local panel.\n',
|
||||
'命令状态上报失败,请检查本机面板。\n':
|
||||
'Command lifecycle reporting failed; check the local panel.\n',
|
||||
'订阅同步已完成,但通知发送失败,请检查面板通知设置。\n':
|
||||
'Subscription synchronized, but notification delivery failed. Check panel notification settings.\n',
|
||||
|
||||
'\n## 执行结束... %s 退出码 %s\n': '\n## Finished... %s exit code %s\n',
|
||||
'任务随机延迟 %s 秒,将于 %s 开始,配置文件参数 RandomDelay 置空可取消延迟\n':
|
||||
'Task delayed %s seconds, will start at %s. Set RandomDelay to empty to cancel delay\n',
|
||||
'开始执行...\n': 'Starting execution...\n',
|
||||
已停止: 'Stopped',
|
||||
完成: 'Completed',
|
||||
'失败(退出码 %s)': 'Failed (exit code %s)',
|
||||
'安装 %s 依赖包...\n': 'Installing %s dependencies...\n',
|
||||
'开始拉取仓库 %s 到 %s\n': 'Cloning repository %s to %s\n',
|
||||
添加成功: 'Added successfully',
|
||||
'添加失败(%s)': 'Add failed (%s)',
|
||||
更新成功: 'Updated successfully',
|
||||
'更新失败(%s)': 'Update failed (%s)',
|
||||
成功: 'Success',
|
||||
'失败(%s)': 'Failed (%s)',
|
||||
'通知发送成功🎉': 'Notification sent successfully 🎉',
|
||||
'通知失败(%s)': 'Notification failed (%s)',
|
||||
'当前有以下脚本可以运行:': 'Available scripts:',
|
||||
暂无脚本可以执行: 'No scripts available',
|
||||
'警告:工作目录不存在 %s': 'Warning: working directory does not exist: %s',
|
||||
'\n缺少并发运行的环境变量参数': '\nMissing concurrency environment variable',
|
||||
'\n缺少单独运行的参数 task xxx.js desi Test':
|
||||
'\nMissing parameter: task xxx.js desi Test',
|
||||
'暂不支持此系统 %s': 'Unsupported system: %s',
|
||||
'检测到 pm2 服务正在运行': 'pm2 service is running',
|
||||
'npm 模块位置: %s': 'npm module location: %s',
|
||||
'导入数据成功 %s': 'Data imported successfully: %s',
|
||||
'导出数据成功 %s': 'Data exported successfully: %s',
|
||||
'当前版本: %s / 最新版本: %s': 'Current: %s / Latest: %s',
|
||||
已是最新版本: 'Already up to date',
|
||||
'%s 个定时任务正在运行': '%s scheduled tasks running',
|
||||
'执行前置命令\n': 'Running pre-command\n',
|
||||
'\n执行前置命令结束\n': '\nPre-command finished\n',
|
||||
'\n执行后置命令\n': '\nRunning post-command\n',
|
||||
'\n执行后置命令结束': '\nPost-command finished',
|
||||
'警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容':
|
||||
'Warning: PM2 start failed (exit code: %s), possibly due to hardware incompatibility',
|
||||
'正在尝试直接使用 Node.js 启动服务...':
|
||||
'Attempting to start service with Node.js directly...',
|
||||
'已使用 Node.js 直接启动服务 (PID: %s)':
|
||||
'Service started with Node.js directly (PID: %s)',
|
||||
'注意: 使用此模式时,部分 PM2 管理功能将不可用':
|
||||
'Note: some PM2 management features are unavailable in this mode',
|
||||
'## 开始执行... %s\n': '## Starting... %s\n',
|
||||
'\n## 已停止 🛑... %s 耗时 %s 秒%s':
|
||||
'\n## Stopped 🛑... %s took %s seconds%s',
|
||||
'\n## 完成 ✅... %s 耗时 %s 秒%s':
|
||||
'\n## Completed ✅... %s took %s seconds%s',
|
||||
'\n## 失败 ❌(退出码 %s)... %s 耗时 %s 秒%s':
|
||||
'\n## Failed ❌(exit code %s)... %s took %s seconds%s',
|
||||
'%s -> 添加成功': '%s -> Added successfully',
|
||||
'%s -> 添加失败(%s)': '%s -> Add failed (%s)',
|
||||
'%s -> 更新成功': '%s -> Updated successfully',
|
||||
'%s -> 更新失败(%s)': '%s -> Update failed (%s)',
|
||||
'%s成功🎉': '%s succeeded 🎉',
|
||||
'%s失败(%s)': '%s failed (%s)',
|
||||
'检测到有%s的定时任务:': 'Found %s scheduled tasks:',
|
||||
'\n开始尝试自动删除失效的定时任务...':
|
||||
'\nAttempting to remove invalid scheduled tasks...',
|
||||
'\n开始尝试自动添加定时任务...': '\nAttempting to add scheduled tasks...',
|
||||
'拉取 %s 成功...\n': 'Pull %s succeeded...\n',
|
||||
'拉取 %s 失败,请检查日志...\n': 'Pull %s failed, check logs...\n',
|
||||
'开始下载:%s 保存路径:%s\n': 'Downloading: %s to: %s\n',
|
||||
'下载 %s 成功...\n': 'Download %s succeeded...\n',
|
||||
'下载 %s 失败,保留之前正常下载的版本...\n':
|
||||
'Download %s failed, keeping previous version...\n',
|
||||
'%s文件不存在,跳过执行...\n': '%s does not exist, skipping...\n',
|
||||
'使用 %s 源更新...\n': 'Updating using %s mirror...\n',
|
||||
'更新青龙源文件成功...\n': 'Qinglong source updated successfully\n',
|
||||
'更新青龙源文件失败,请检查网络...\n':
|
||||
'Qinglong source update failed, check network\n',
|
||||
'更新青龙静态资源成功...\n': 'Static assets updated successfully\n',
|
||||
'更新青龙静态资源失败,请检查网络...\n':
|
||||
'Static assets update failed, check network\n',
|
||||
'\n开始检测依赖...\n': '\nChecking dependencies...\n',
|
||||
'\n依赖检测安装成功...\n': '\nDependencies installed successfully\n',
|
||||
'更新包下载成功...\n': 'Package download succeeded\n',
|
||||
'\n依赖检测安装失败,请检查网络...\n':
|
||||
'\nDependency installation failed, check network\n',
|
||||
'\n1、安装bot依赖...\n': '\n1. Installing bot dependencies...\n',
|
||||
'\nbot依赖安装成功...\n': '\nBot dependencies installed\n',
|
||||
'2、下载bot所需文件...\n': '2. Downloading bot files...\n',
|
||||
'\nbot文件下载成功...\n': '\nBot files downloaded\n',
|
||||
'3、安装python3依赖...\n': '3. Installing python3 dependencies...\n',
|
||||
'\npython3依赖安装成功...\n': '\nPython3 dependencies installed\n',
|
||||
'4、启动bot程序...\n': '4. Starting bot...\n',
|
||||
'bot启动成功...\n': 'Bot started successfully\n',
|
||||
'---> 1. 开始检测配置文件\n': '---> 1. Checking config...\n',
|
||||
'---> 配置文件检测完成\n': '---> Config check complete\n',
|
||||
'---> 2. 开始安装青龙依赖\n': '---> 2. Installing qinglong dependencies...\n',
|
||||
'---> 青龙依赖安装完成\n': '---> Dependencies installed\n',
|
||||
'---> 脚本依赖安装完成\n': '---> Script dependencies installed\n',
|
||||
'---> 1. 复制通知文件\n': '---> 1. Copying notification files...\n',
|
||||
'---> 复制一份 %s 为 %s\n': '---> Copying %s to %s\n',
|
||||
'---> 通知文件复制完成\n': '---> Notification files copied\n',
|
||||
'---> pm2日志': '---> pm2 log',
|
||||
'\n=====> 检测面板': '\n=====> Checking panel',
|
||||
'=====> 面板服务启动正常\n': '=====> Panel service running normally\n',
|
||||
'\n=====> 检测后台': '\n=====> Checking backend',
|
||||
'=====> 后台服务启动正常\n': '=====> Backend service running normally\n',
|
||||
'=====> 开始检测': '=====> Starting check',
|
||||
'\n=====> 检测结束\n': '\n=====> Check complete\n',
|
||||
'查询文件 %s': 'Checking file: %s',
|
||||
'删除中~': 'Deleting...',
|
||||
'正在被 %s 使用,跳过~': 'In use by %s, skipping...',
|
||||
'查找旧日志文件中...\n': 'Looking for old log files...\n',
|
||||
'删除旧日志执行完毕\n': 'Old log cleanup complete\n',
|
||||
'未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装':
|
||||
'Module not found. Run: npm i -g @whyour/qinglong',
|
||||
'请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务':
|
||||
'Set env: export QL_DIR=%s, then run qinglong to start',
|
||||
'请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量':
|
||||
'Set QL_DATA_DIR (absolute path ending with /data, e.g. /ql/data)',
|
||||
'QL_DATA_DIR 必须以 /data 结尾,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中':
|
||||
'QL_DATA_DIR must end with /data, e.g. /ql/data',
|
||||
'暂不支持此系统部署 %s': 'Unsupported system for deployment: %s',
|
||||
'命令输入错误...\n': 'Invalid command...\n',
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
const chinese: Record<string, string> = {
|
||||
'Query object as JSON, @file or - for stdin': '查询参数 JSON;支持 @文件或 - 从 stdin 读取',
|
||||
'Request body as JSON, @file or - for stdin': '请求体 JSON;支持 @文件或 - 从 stdin 读取',
|
||||
'Request timeout in seconds': '请求超时秒数(默认 30,最多 3600)',
|
||||
'Local file to upload': '要上传的本机文件',
|
||||
'Save response to a new local file': '响应保存到新文件(不覆盖已有文件)',
|
||||
'Resource name': '资源名称',
|
||||
'Command executed by the remote panel': '由远程面板执行的命令',
|
||||
'Cron schedule': '定时规则',
|
||||
'Comma-separated application permissions': '应用权限,用逗号分隔',
|
||||
'Explicitly include application credentials in output': '显式输出应用密钥(默认隐藏)',
|
||||
'Call a registered OpenAPI route': '调用已登记的 OpenAPI 路由',
|
||||
'List supported OpenAPI routes and commands': '列出支持的 OpenAPI 路由和对应命令',
|
||||
'Subscription type': '订阅类型:public-repo/private-repo/file',
|
||||
'Subscription url': '订阅地址',
|
||||
'Subscription alias': '订阅别名',
|
||||
'Subscription schedule': '订阅定时规则',
|
||||
'Subscription schedule-type': '订阅定时类型:crontab/interval',
|
||||
'Subscription branch': '订阅分支',
|
||||
'Subscription whitelist': '订阅包含规则',
|
||||
'Subscription blacklist': '订阅排除规则',
|
||||
|
||||
'Skip OS boot registration (for containers)': '跳过系统开机注册(用于容器)',
|
||||
'Authenticate with a remote panel application': '使用远程面板应用凭据登录',
|
||||
'Panel URL including optional base path': '面板地址,可包含基础路径',
|
||||
'Verify authentication and resource permission': '检查登录状态和资源权限',
|
||||
'Permission to check': '要检查的权限',
|
||||
'Remove local credentials (does not revoke the server token)':
|
||||
'清除本机凭据(不会撤销服务端令牌)',
|
||||
'List remote tasks': '列出远程任务',
|
||||
'Search task name or command': '按任务名称或命令搜索',
|
||||
'Page number': '页码',
|
||||
'Page size': '每页数量',
|
||||
'Read the latest task log': '读取最新任务日志',
|
||||
'Last N lines': '最后 N 行',
|
||||
'List panel subscriptions': '列出面板订阅',
|
||||
'Search subscriptions': '搜索订阅',
|
||||
'Read latest subscription log': '读取最新订阅日志',
|
||||
'Remove expired logs not referenced by a task':
|
||||
'删除过期且未被任务引用的日志',
|
||||
'Execute the user extra.sh hook': '执行用户的 extra.sh 钩子',
|
||||
'Restore missing configuration templates and runtime directories':
|
||||
'恢复缺失的配置模板和运行目录',
|
||||
'Install runtime dependencies, repair files, diagnose and reload services':
|
||||
'安装运行依赖、修复文件、诊断并重载服务',
|
||||
'Install dependencies and start the optional local Telegram bot':
|
||||
'安装依赖并启动可选的本机 Telegram 机器人',
|
||||
'Install runtime prerequisites and start nginx and panel services':
|
||||
'安装运行环境并启动 nginx 和面板服务',
|
||||
'Restart services without installing packages or starting optional hooks':
|
||||
'重启服务,跳过依赖安装和可选钩子',
|
||||
'Restart local services or apply staged system/data files':
|
||||
'重启本机服务或应用已准备的系统/数据文件',
|
||||
'Reload target': '重载目标',
|
||||
'Stage and apply a local panel upgrade': '准备并应用本机面板升级',
|
||||
'Archive source': '升级包来源',
|
||||
'Prepare upgrade without replacing files or restarting services':
|
||||
'仅准备升级包,不替换文件或重启服务',
|
||||
'One JSON result on stdout; diagnostics on stderr':
|
||||
'标准输出返回一个 JSON 结果,诊断写入标准错误',
|
||||
'Show help': '显示帮助',
|
||||
'Installed panel root (defaults to QL_DIR)':
|
||||
'已安装面板的根目录(默认 QL_DIR)',
|
||||
'Panel data directory (defaults to QL_DATA_DIR)':
|
||||
'面板数据目录(默认 QL_DATA_DIR)',
|
||||
'Usage:': '用法:',
|
||||
'Options:': '选项:',
|
||||
'default:': '默认:',
|
||||
'Alias: login = auth login.': '别名:login = auth login。',
|
||||
'Local operator commands require an installed panel.':
|
||||
'本机运维命令需要已安装的面板。',
|
||||
};
|
||||
const actions: Record<string, string> = {
|
||||
get: '查看',
|
||||
run: '运行',
|
||||
stop: '停止',
|
||||
enable: '启用',
|
||||
disable: '禁用',
|
||||
};
|
||||
for (const [action, text] of Object.entries(actions)) {
|
||||
chinese[`${action} a remote task by ID`] = `按 ID ${text}远程任务`;
|
||||
chinese[`${action} a panel subscription by ID`] = `按 ID ${text}面板订阅`;
|
||||
}
|
||||
for (const [action, text] of Object.entries({
|
||||
resetlet: '清除登录限制',
|
||||
resettfa: '关闭双因素认证',
|
||||
resetpwd: '重置密码',
|
||||
resetname: '重置用户名',
|
||||
}))
|
||||
chinese[`Local panel ${action}`] = `本机面板:${text}`;
|
||||
|
||||
export function helpText(
|
||||
text: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
if (env.QL_LANG !== 'en' && /^(GET|POST|PUT|DELETE) \/open\//.test(text))
|
||||
return `远程 API:${text}`;
|
||||
return env.QL_LANG === 'en' ? text : chinese[text] ?? text;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { english } from './en';
|
||||
|
||||
export function translate(
|
||||
env: NodeJS.ProcessEnv,
|
||||
key: string,
|
||||
...values: unknown[]
|
||||
): string {
|
||||
const template = env.QL_LANG === 'en' ? english[key] ?? key : key;
|
||||
let index = 0;
|
||||
return template.replace(/%%|%s/g, (token) =>
|
||||
token === '%%' ? '%' : String(values[index++] ?? ''),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface ApiResponse<T> {
|
||||
code: 200;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface LogResponse extends ApiResponse<string> {
|
||||
logStatus?: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user