From 2dd2dc44f6bd44e8de39bf1419a8612c8f3f5582 Mon Sep 17 00:00:00 2001 From: Cooper <109766944+lonecoding@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:54:06 +0800 Subject: [PATCH] feat: safely export custom configurations to new files --- CHANGELOG.md | 4 ++ README.md | 8 ++++ docs/maintenance.md | 6 +++ docs/profiles.md | 18 ++++++-- scripts/generate_profiles.js | 63 ++++++++++++++++++++++++--- tests/config-builder.test.js | 83 ++++++++++++++++++++++++++++++++++++ 6 files changed, 172 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b66ae89..4bf3ea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 6d5b999..82fdcc4 100644 --- a/README.md +++ b/README.md @@ -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 默认直连。 组名是服务用途,不要求与上游规则文件名一一对应。 diff --git a/docs/maintenance.md b/docs/maintenance.md index 4fda9e0..a383e1a 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -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. diff --git a/docs/profiles.md b/docs/profiles.md index acec7d6..787c0fb 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -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` 的客户端解析器。 @@ -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、区域数据或客户端匹配引擎。 diff --git a/scripts/generate_profiles.js b/scripts/generate_profiles.js index 22eaa86..d47a068 100644 --- a/scripts/generate_profiles.js +++ b/scripts/generate_profiles.js @@ -11,6 +11,7 @@ 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 @@ -18,12 +19,17 @@ 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; @@ -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(); } @@ -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) { @@ -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; }); diff --git a/tests/config-builder.test.js b/tests/config-builder.test.js index 8c3a88c..5057acf 100644 --- a/tests/config-builder.test.js +++ b/tests/config-builder.test.js @@ -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, '..'); @@ -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']) {