Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

- Add `--output PATH` to safely export preset, custom and interactive configurations without overwriting existing files or symlinks.
- Fail explicitly when interactive input closes before a selection; keep blank Enter as the recommended preset and Ctrl+C as cancellation.
- Document safe exports instead of shell redirection and add file-preservation and CLI failure regression tests. Published configuration contents are unchanged; no Quantumult X device testing was performed.

## [0.6.0] - 2026-09-09

- Provide upstream-backed recommended and extended profiles with explicit Quantumult X policy groups.
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ AI 汇总 OpenAI、Claude、Gemini、Copilot 上游列表;其他 AI 网站按
- [扩展版:20 个策略组](https://raw.githubusercontent.com/lonecoding/QuantumultX-Rules/main/config/extended.conf)
- [按需组合策略组](docs/profiles.md#按需组合策略组)

自定义组合可安全导出到新文件(需要 Node.js 22+):

```bash
node scripts/generate_profiles.js --groups AI,Telegram,YouTube --output ../my-quantumultx.conf
```

已有文件不会被覆盖;生成后导入 Quantumult X,再添加自己的订阅。

两版加载相同的上游资源,区别是显示哪些独立策略组。没有独立组的 AI 服务跟随 AI,
YouTube 优先跟随 Google,其他海外服务跟随 Proxies;Apple / Microsoft 默认直连。
组名是服务用途,不要求与上游规则文件名一一对应。
Expand Down
6 changes: 6 additions & 0 deletions docs/maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ fixed Direct behavior, independent blocking controls, resource placement, parent
upstream-only subscriptions and generated output consistency. They do not emulate Quantumult X.
See [profile setup and checks](profiles.md) for the complete command list.

Custom exports use `--output PATH` with exclusive creation: existing files and symlinks are refused.
Keep argument validation and rendering ahead of file creation. Interactive EOF must fail instead of
returning success without a configuration. File-output tests use temporary directories and check
preservation of existing content, missing parents, invalid arguments and clean stdout.
Without `--output`, stdout remains the configuration stream and stderr carries prompts/status.

## Standalone subscription compatibility

Existing `rules/Service/Service.list` and `adblock.list` URLs remain available to prior subscribers.
Expand Down
18 changes: 15 additions & 3 deletions docs/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,29 @@ Copilot 直接使用上游 Copilot.list。标准版默认跟随 AI;扩展版
以下命令在仓库根目录执行,输出到仓库外的新文件:

```bash
node scripts/generate_profiles.js --interactive > ../my-quantumultx.conf
node scripts/generate_profiles.js --interactive --output ../my-quantumultx.conf
```

按提示输入组名,如 `AI,Telegram,YouTube`,就会生成基础 5 组加这 3 个独立组。
也可以直接指定:

```bash
node scripts/generate_profiles.js --groups AI,Telegram,YouTube > ../my-quantumultx.conf
node scripts/generate_profiles.js --groups AI,Telegram,YouTube --output ../my-quantumultx.conf
```

可选组名:`AI,ChatGPT,Claude,Gemini,Copilot,YouTube,Telegram,TikTok,Netflix,Spotify,AppleTV,AppleNews,Microsoft,Apple,Google`。
`--groups none` 只保留基础组。未知名称或重复组名会报错。
`--output` 也可以配合 `--preset extended` 使用;路径中有空格时加引号。
文件相对路径以执行命令时的目录为准,父目录需要已存在。
导出成功会显示 `Saved:` 和完整路径;已有文件(包括符号链接)会被拒绝覆盖,
请换一个新文件名,或先备份并移走原文件。组名或参数错误、交互输入提前关闭时,
程序返回失败,不创建配置文件。交互模式直接按回车仍使用标准版,Ctrl+C 取消生成。

不指定 `--output` 时仍向标准输出打印配置,兼容已有管道用法。
建议保存文件时使用 `--output`:命令行的 `>` 会在生成器运行前清空目标文件,
即使组名错误也可能覆盖原配置。`--output` 不能与 `--write`、`--check`、`--help` 混用;
`--write` 仅供维护者重新生成仓库内的预设文件。

生成器只处理本地配置,不获取订阅、不联网、不收集使用数据。生成后导入文件,再在
设备上添加自己的订阅。此工具是维护及生成工具,不是放进 `resource_parser_url` 的客户端解析器。

Expand Down Expand Up @@ -174,7 +185,8 @@ python scripts/check_routing.py
python -m unittest discover -s tests -v
```

CI 检查生成内容、全部 32768 种策略组组合的有效引用与循环、规则保留、默认绑定和命令行错误。
CI 检查生成内容、全部 32768 种策略组组合的有效引用与循环、规则保留、默认绑定和命令行错误,
并检查文件导出、已有文件保护及交互输入提前关闭时的失败状态。
定时 / 手动网络检查覆盖所有配置中的外部 URL。34 个路由样例用于 tests/fixtures/standalone.conf 中的独立规则兼容测试;
新预设没有模拟上游全部规则、DNS、区域数据或客户端匹配引擎。

Expand Down
63 changes: 56 additions & 7 deletions scripts/generate_profiles.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,25 @@ const HELP = `Generate complete Quantumult X configurations (Node.js 22+; no dep
node scripts/generate_profiles.js --preset extended Print a preset
node scripts/generate_profiles.js --groups AI,Telegram,YouTube
node scripts/generate_profiles.js --interactive Choose groups interactively
node scripts/generate_profiles.js --groups AI,Telegram --output ../my-quantumultx.conf
node scripts/generate_profiles.js --write Regenerate all published presets
node scripts/generate_profiles.js --check Check published presets without writing
Presets: ${Object.keys(PRESETS).join(', ')}
Groups: ${SERVICES.map(service => service.id).join(', ')}
Use --groups none for base groups only. Service rules remain active when groups
are omitted. Add subscriptions only on your device; this tool never fetches them.
Redirect custom output to a file outside this repository before importing it.
Example: node scripts/generate_profiles.js --groups AI,Telegram > ../my-quantumultx.conf
Use --output PATH to save a new file (also works with --preset or --interactive).
Existing files are never overwritten. The parent directory must already exist.
Without --output, the configuration is printed to stdout as before.
Prefer --output over shell redirection, which can truncate a file even on errors.
Save custom configurations outside this repository before adding subscriptions.
Setup and policy defaults: docs/profiles.md
`;

async function main(args) {
const { mode, output } = parseOutput(args);
args = mode;
if (args.length === 1 && args[0] === '--help') {
process.stdout.write(HELP);
return;
Expand All @@ -47,12 +53,22 @@ async function main(args) {
return;
}
if (args.length === 1 && args[0] === '--interactive') {
const readline = require('node:readline/promises');
const readline = require('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
try {
process.stderr.write(`可选策略组:${SERVICES.map(service => service.id).join(', ')}\n`);
const answer = (await rl.question('输入逗号分隔的组名,留空使用标准版,none 仅保留基础组:')).trim();
process.stdout.write(renderConfig(answer === '' ? PRESETS.recommended : parseGroups(answer)));
const answer = (await new Promise((resolve, reject) => {
// readline does not call the question callback on EOF or Ctrl+C.
rl.once('close', () => reject(new Error('Input closed before a selection was received; no configuration generated.')));
rl.once('SIGINT', () => {
const error = new Error('Cancelled; no configuration generated.');
error.exitCode = 130;
reject(error);
rl.close();
});
rl.question('输入逗号分隔的组名,留空使用标准版,none 仅保留基础组:', resolve);
})).trim();
saveConfig(renderConfig(answer === '' ? PRESETS.recommended : parseGroups(answer)), output);
} finally {
rl.close();
}
Expand All @@ -67,7 +83,40 @@ async function main(args) {
} else if (args.length) {
throw new Error('Invalid arguments. Use --help; choose one mode at a time.');
}
process.stdout.write(renderConfig(groups));
saveConfig(renderConfig(groups), output);
}

function parseOutput(args) {
const mode = [];
let output;
for (let i = 0; i < args.length; i++) {
if (args[i] !== '--output') {
mode.push(args[i]);
continue;
}
if (output !== undefined) throw new Error('Use --output only once.');
output = args[++i];
if (!output || output.startsWith('--')) throw new Error('--output requires a file path.');
}
if (output !== undefined && mode.some(arg => ['--write', '--check', '--help'].includes(arg))) {
throw new Error('--output cannot be combined with --write, --check or --help.');
}
return { mode, output };
}

function saveConfig(config, output) {
if (output === undefined) {
process.stdout.write(config);
return;
}
// Exclusive creation also refuses symlinks; an existence check alone would race.
try {
fs.writeFileSync(output, config, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
} catch (error) {
if (error.code === 'EEXIST') throw new Error(`File already exists: ${output}. Choose a new filename; nothing was overwritten.`);
throw new Error(`Cannot save configuration to ${output}: ${error.message}`);
}
process.stderr.write(`Saved: ${path.resolve(output)}\n`);
}

function parseGroups(value) {
Expand All @@ -77,5 +126,5 @@ function parseGroups(value) {

main(process.argv.slice(2)).catch(error => {
process.stderr.write(`${error.message}\n`);
process.exitCode = 1;
process.exitCode = error.exitCode || 1;
});
83 changes: 83 additions & 0 deletions tests/config-builder.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { spawnSync } = require('node:child_process');
const { SERVICES, PRESETS, renderConfig, PARSER_URL } = require('../scripts/config-builder.js');
const ROOT = path.resolve(__dirname, '..');
Expand Down Expand Up @@ -172,6 +173,88 @@ test('CLI selection, interactive choice, and errors produce importable output or
}
});

test('CLI exports presets, custom and interactive selections without mixing prompts into files', t => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'qx-export-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const script = path.join(ROOT, 'scripts/generate_profiles.js');
const choices = [
[[], PRESETS.recommended],
[['--preset', 'extended'], PRESETS.extended],
[['--groups', 'AI,Telegram'], ['AI', 'Telegram']],
[['--groups', 'none'], []],
[['--interactive'], ['Google', 'YouTube'], 'Google,YouTube\n'],
[['--interactive'], PRESETS.recommended, '\n'],
];
for (const [i, [args, groups, input]] of choices.entries()) {
const file = `custom ${i}.conf`;
const result = spawnSync(process.execPath, [script, '--output', file, ...args], {
cwd: directory, encoding: 'utf8', input, timeout: 5000,
});
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, '');
assert.match(result.stderr, /Saved:/);
assert.equal(fs.readFileSync(path.join(directory, file), 'utf8'), renderConfig(groups));
}
});

test('CLI export preserves existing files, directories and symlink targets', t => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'qx-preserve-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const existing = path.join(directory, 'existing.conf');
const link = path.join(directory, 'link.conf');
const dangling = path.join(directory, 'dangling.conf');
const missing = path.join(directory, 'missing.conf');
const original = '# User configuration with private local changes\n';
fs.writeFileSync(existing, original);
fs.symlinkSync(existing, link);
fs.symlinkSync(missing, dangling);
for (const file of [existing, link, dangling, directory]) {
const result = spawnSync(process.execPath, ['scripts/generate_profiles.js', '--groups', 'AI', '--output', file], {
cwd: ROOT, encoding: 'utf8', timeout: 5000,
});
assert.equal(result.status, 1, result.stderr);
assert.equal(result.stdout, '');
assert.match(result.stderr, /File already exists/);
assert.equal(fs.readFileSync(existing, 'utf8'), original);
assert.equal(fs.existsSync(missing), false);
}
assert.equal(fs.lstatSync(link).isSymbolicLink(), true);
assert.equal(fs.lstatSync(dangling).isSymbolicLink(), true);
});

test('CLI errors and closed interactive input produce no export and a failure status', t => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'qx-invalid-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const file = path.join(directory, 'new.conf');
for (const args of [
['--groups', 'Typo'], ['--groups', 'AI,AI'], ['--preset', 'unknown'],
['--write'], ['--check'], ['--help'], ['--interactive'],
['--output', path.join(directory, 'duplicate.conf')],
['--preset', 'extended', '--groups', 'AI'],
]) {
const result = spawnSync(process.execPath, ['scripts/generate_profiles.js', ...args, '--output', file], {
cwd: ROOT, encoding: 'utf8', input: '', timeout: 5000,
});
assert.equal(result.status, 1, result.stderr);
assert.equal(result.stdout, '');
assert.notEqual(result.stderr, '');
assert.deepEqual(fs.readdirSync(directory), []);
}
for (const args of [['--output'], ['--output', ''], ['--output', '--groups', 'AI'], ['--interactive']]) {
const result = spawnSync(process.execPath, ['scripts/generate_profiles.js', ...args], {
cwd: ROOT, encoding: 'utf8', input: '', timeout: 5000,
});
assert.equal(result.status, 1, result.stderr);
assert.equal(result.stdout, '');
}
const missingParent = spawnSync(process.execPath, ['scripts/generate_profiles.js', '--output', path.join(directory, 'absent', 'out.conf')], {
cwd: ROOT, encoding: 'utf8', timeout: 5000,
});
assert.equal(missingParent.status, 1);
assert.match(missingParent.stderr, /Cannot save configuration/);
assert.deepEqual(fs.readdirSync(directory), []);
});


test('blocking controls are independent and direct remains fixed in every template', () => {
for (const name of ['recommended', 'extended', 'full', 'daily']) {
Expand Down
Loading