tooling: add external acp config helper
This commit is contained in:
parent
3c3f8a420a
commit
4763852db0
210
docs/howto/external-acp-bridge-config.md
Normal file
210
docs/howto/external-acp-bridge-config.md
Normal file
@ -0,0 +1,210 @@
|
||||
# 外部 ACP 接入配置脚本
|
||||
|
||||
这个 how-to 对应仓库内的独立工具:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart
|
||||
```
|
||||
|
||||
它只做一件事:生成或更新 XWorkmate 本地 `settings.yaml` 里的 `externalAcpEndpoints`,不改 Flutter 运行时代码,不碰 secrets。
|
||||
|
||||
前提:
|
||||
|
||||
- 在仓库根目录执行。
|
||||
- 首次在新 clone 上使用前,先跑一次 `flutter pub get`,确保 `.dart_tool/package_config.json` 已生成。
|
||||
|
||||
## App Store 对齐约束
|
||||
|
||||
这次工具链按 App Store 分发边界设计,约束如下:
|
||||
|
||||
- 这是仓库外置脚本,不是 app bundle 内功能。
|
||||
- `codex`、`opencode`、`supergateway`、`mcp-bridge`、`gemini-bridge` 都必须作为用户侧或运维侧前置依赖,不能打包进 XWorkmate 的 App Store 构建产物。
|
||||
- 脚本只改用户态 `settings.yaml`,不改 entitlements、不改打包脚本、不往 DMG / `.app` bundle 写入任何第三方二进制或 secrets。
|
||||
- 外部 bridge 进程必须由用户在安装后手动启动;这次方案不要求、也不允许 App Store 包内自动拉起这些进程。
|
||||
|
||||
## 默认 provider 映射
|
||||
|
||||
脚本默认会把 4 个内置 provider 写成下面的 endpoint:
|
||||
|
||||
| Provider | Endpoint | 约定启动方式 |
|
||||
| --- | --- | --- |
|
||||
| Codex | `ws://127.0.0.1:9001` | `codex app-server --listen ws://127.0.0.1:9001` |
|
||||
| OpenCode | `http://127.0.0.1:4096` | `opencode serve --port 4096` |
|
||||
| Claude | `ws://127.0.0.1:9011` | 本地 bridge 槽位 |
|
||||
| Gemini | `ws://127.0.0.1:9012` | 本地 bridge 槽位 |
|
||||
|
||||
注意:
|
||||
|
||||
- 这里把 OpenCode 固定写成 `127.0.0.1:4096`。如果你之前记成 `27.0.0.1:4096`,这里应为 loopback 地址 `127.0.0.1:4096`。
|
||||
- 当前 XWorkmate external ACP 路径保存的是 provider endpoint;脚本负责“写配置”,不负责替你守护进程。
|
||||
- `settings.yaml` 是非敏感配置源。token、password、API key 仍不应该写进去。
|
||||
|
||||
## 默认路径
|
||||
|
||||
脚本默认按宿主平台定位 `settings.yaml`:
|
||||
|
||||
- macOS: `~/Library/Application Support/xworkmate/config/settings.yaml`
|
||||
- Linux: `${XDG_CONFIG_HOME:-~/.config}/xworkmate/config/settings.yaml`
|
||||
- Windows: `%APPDATA%\\xworkmate\\config\\settings.yaml`
|
||||
|
||||
也可以显式传 `--settings-file` 覆盖。
|
||||
|
||||
## 常用命令
|
||||
|
||||
先只看计划,不落盘:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart print
|
||||
```
|
||||
|
||||
按默认 endpoint 落盘,并自动备份原文件:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart apply
|
||||
```
|
||||
|
||||
指定自定义 endpoint:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart apply \
|
||||
--claude-endpoint ws://127.0.0.1:19111 \
|
||||
--gemini-endpoint ws://127.0.0.1:19112
|
||||
```
|
||||
|
||||
只打印将要写入的 YAML,不真正写文件:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart apply --dry-run
|
||||
```
|
||||
|
||||
禁用某个 provider 槽位:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart apply --disable-claude
|
||||
```
|
||||
|
||||
## 原生 provider
|
||||
|
||||
### Codex
|
||||
|
||||
启动:
|
||||
|
||||
```bash
|
||||
codex app-server --listen ws://127.0.0.1:9001
|
||||
```
|
||||
|
||||
写配置:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart apply \
|
||||
--codex-endpoint ws://127.0.0.1:9001
|
||||
```
|
||||
|
||||
### OpenCode
|
||||
|
||||
启动:
|
||||
|
||||
```bash
|
||||
opencode serve --port 4096
|
||||
```
|
||||
|
||||
写配置:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart apply \
|
||||
--opencode-endpoint http://127.0.0.1:4096
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 这是按当前约定写入的原生 OpenCode endpoint。
|
||||
- XWorkmate 当前 single-agent 运行时会把 `http/https` endpoint 归一化成 `ws/wss` 连接再发 JSON-RPC 请求,所以 OpenCode 端是否完全兼容,取决于你前面的桥是否提供了 XWorkmate 当前期望的方法集。
|
||||
- 这次变更不改项目代码,因此这里只负责把槽位写好,不额外实现 OpenCode 协议适配。
|
||||
|
||||
## 桥接 provider
|
||||
|
||||
### Gemini
|
||||
|
||||
你给出的要求是:
|
||||
|
||||
1. 本地起一个 `stdio` MCP server
|
||||
2. 再把它桥到 XWorkmate 使用的本地 endpoint
|
||||
|
||||
推荐的最小链路:
|
||||
|
||||
1. 安装 Gemini CLI 并登录
|
||||
2. 安装 `gemini-bridge`
|
||||
3. 用 `supergateway` 把本地 `stdio` MCP server 暴露到 WebSocket 端口
|
||||
|
||||
参考命令:
|
||||
|
||||
```bash
|
||||
npm install -g @google/gemini-cli
|
||||
gemini auth login
|
||||
pip install gemini-bridge
|
||||
npx -y supergateway \
|
||||
--stdio "uvx gemini-bridge" \
|
||||
--outputTransport ws \
|
||||
--port 9012 \
|
||||
--messagePath /
|
||||
```
|
||||
|
||||
然后写入:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart apply \
|
||||
--gemini-endpoint ws://127.0.0.1:9012
|
||||
```
|
||||
|
||||
### Claude
|
||||
|
||||
你给出的要求同样是:
|
||||
|
||||
1. 本地起一个 `stdio` bridge
|
||||
2. 再桥到 XWorkmate 的本地 endpoint
|
||||
|
||||
按这次文档约定,Claude 槽位使用:
|
||||
|
||||
- 本地 bridge 命令:`mcp-bridge`
|
||||
- WebSocket 暴露:`supergateway`
|
||||
- 本地 endpoint:`ws://127.0.0.1:9011`
|
||||
|
||||
参考命令:
|
||||
|
||||
```bash
|
||||
pip install mcp-bridge
|
||||
mcp-bridge init
|
||||
```
|
||||
|
||||
编辑:
|
||||
|
||||
```text
|
||||
~/.config/mcp-bridge/config.json
|
||||
```
|
||||
|
||||
填入你的远程 MCP URL 和认证信息后,再启动本地桥:
|
||||
|
||||
```bash
|
||||
npx -y supergateway \
|
||||
--stdio "mcp-bridge" \
|
||||
--outputTransport ws \
|
||||
--port 9011 \
|
||||
--messagePath /
|
||||
```
|
||||
|
||||
然后写入:
|
||||
|
||||
```bash
|
||||
dart tool/configure_external_acp.dart apply \
|
||||
--claude-endpoint ws://127.0.0.1:9011
|
||||
```
|
||||
|
||||
## 兼容性边界
|
||||
|
||||
这次提交只补“配置脚本 + 使用说明”,不改 XWorkmate runtime,所以边界需要说清楚:
|
||||
|
||||
- 脚本保证 `externalAcpEndpoints` 写法正确,并保留非内置 custom provider 条目。
|
||||
- 脚本不会自动拉起 `codex`、`opencode`、`supergateway`、`mcp-bridge` 或 `gemini-bridge`。
|
||||
- 脚本不会写入任何 secret。
|
||||
- Claude / Gemini 这里走的是“本地 bridge endpoint 槽位”方案。是否能被当前 XWorkmate runtime 直接消费,取决于桥后的协议是否与当前 external ACP 路径兼容。
|
||||
- 如果后续要把 Claude / Gemini 做成真正的开箱即用 provider,需要再补一层项目内协议适配;这次明确不做。
|
||||
533
tool/configure_external_acp.dart
Normal file
533
tool/configure_external_acp.dart
Normal file
@ -0,0 +1,533 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
const Map<String, String> _providerLabels = <String, String>{
|
||||
'codex': 'Codex',
|
||||
'opencode': 'OpenCode',
|
||||
'claude': 'Claude',
|
||||
'gemini': 'Gemini',
|
||||
};
|
||||
|
||||
const Map<String, String> _defaultEndpoints = <String, String>{
|
||||
'codex': 'ws://127.0.0.1:9001',
|
||||
'opencode': 'http://127.0.0.1:4096',
|
||||
'claude': 'ws://127.0.0.1:9011',
|
||||
'gemini': 'ws://127.0.0.1:9012',
|
||||
};
|
||||
|
||||
const Map<String, String> _defaultLaunchCommands = <String, String>{
|
||||
'codex': 'codex app-server --listen ws://127.0.0.1:9001',
|
||||
'opencode': 'opencode serve --port 4096',
|
||||
'claude':
|
||||
'npx -y supergateway --stdio "mcp-bridge" --outputTransport ws --port 9011 --messagePath /',
|
||||
'gemini':
|
||||
'npx -y supergateway --stdio "uvx gemini-bridge" --outputTransport ws --port 9012 --messagePath /',
|
||||
};
|
||||
|
||||
void main(List<String> args) async {
|
||||
final options = _CliOptions.parse(args);
|
||||
if (options.showHelp) {
|
||||
stdout.write(_usage());
|
||||
exit(0);
|
||||
}
|
||||
|
||||
final settingsFile =
|
||||
options.settingsFile ??
|
||||
_defaultSettingsFile(
|
||||
environment: Platform.environment,
|
||||
operatingSystem: Platform.operatingSystem,
|
||||
);
|
||||
final resolvedEndpoints = <String, String>{
|
||||
for (final entry in _defaultEndpoints.entries)
|
||||
entry.key: options.endpoints[entry.key] ?? entry.value,
|
||||
};
|
||||
|
||||
if (options.command == _Command.printPlan) {
|
||||
stdout.write(
|
||||
_renderPlan(
|
||||
settingsFile: settingsFile,
|
||||
endpoints: resolvedEndpoints,
|
||||
modeLabel: 'print-only',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final existing = await _readExistingSettings(settingsFile);
|
||||
final updated = _mergeExternalAcpEndpoints(
|
||||
existing,
|
||||
endpoints: resolvedEndpoints,
|
||||
enableProviders: options.enableProviders,
|
||||
);
|
||||
|
||||
if (options.dryRun) {
|
||||
stdout.write(encodeYamlDocument(updated));
|
||||
return;
|
||||
}
|
||||
|
||||
await settingsFile.parent.create(recursive: true);
|
||||
if (await settingsFile.exists() && options.backup) {
|
||||
final backupFile = File(
|
||||
'${settingsFile.path}.bak.${DateTime.now().toUtc().millisecondsSinceEpoch}',
|
||||
);
|
||||
await settingsFile.copy(backupFile.path);
|
||||
stdout.writeln('Backup written: ${backupFile.path}');
|
||||
}
|
||||
|
||||
await settingsFile.writeAsString(encodeYamlDocument(updated));
|
||||
stdout.writeln('Updated: ${settingsFile.path}');
|
||||
stdout.write(
|
||||
_renderPlan(
|
||||
settingsFile: settingsFile,
|
||||
endpoints: resolvedEndpoints,
|
||||
modeLabel: 'applied',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _usage() {
|
||||
return '''
|
||||
Usage:
|
||||
dart tool/configure_external_acp.dart apply [options]
|
||||
dart tool/configure_external_acp.dart print [options]
|
||||
|
||||
Commands:
|
||||
apply Update XWorkmate settings.yaml externalAcpEndpoints.
|
||||
print Print the resolved endpoint plan and suggested launch commands.
|
||||
|
||||
Options:
|
||||
--settings-file <path> Override settings.yaml path.
|
||||
--codex-endpoint <url> Default: ${_defaultEndpoints['codex']}
|
||||
--opencode-endpoint <url> Default: ${_defaultEndpoints['opencode']}
|
||||
--claude-endpoint <url> Default: ${_defaultEndpoints['claude']}
|
||||
--gemini-endpoint <url> Default: ${_defaultEndpoints['gemini']}
|
||||
--disable-codex Mark the Codex slot as disabled.
|
||||
--disable-opencode Mark the OpenCode slot as disabled.
|
||||
--disable-claude Mark the Claude slot as disabled.
|
||||
--disable-gemini Mark the Gemini slot as disabled.
|
||||
--no-backup Skip settings.yaml backup on apply.
|
||||
--dry-run Print the resulting YAML instead of writing it.
|
||||
--help Show this help.
|
||||
|
||||
Notes:
|
||||
- This tool only updates the externalAcpEndpoints block and preserves all
|
||||
other settings keys.
|
||||
- App Store-safe usage means running this tool outside the shipped app bundle
|
||||
and keeping Codex/OpenCode/Supergateway/MCP bridge binaries outside the
|
||||
bundle as user-managed prerequisites.
|
||||
- Default macOS settings path:
|
||||
~/Library/Application Support/xworkmate/config/settings.yaml
|
||||
- Default Linux settings path:
|
||||
~/.config/xworkmate/config/settings.yaml
|
||||
''';
|
||||
}
|
||||
|
||||
String _renderPlan({
|
||||
required File settingsFile,
|
||||
required Map<String, String> endpoints,
|
||||
required String modeLabel,
|
||||
}) {
|
||||
final buffer = StringBuffer()
|
||||
..writeln()
|
||||
..writeln('Settings file: ${settingsFile.path}')
|
||||
..writeln('Mode: $modeLabel')
|
||||
..writeln('Provider endpoint plan:');
|
||||
|
||||
for (final provider in _providerLabels.keys) {
|
||||
buffer.writeln('- ${_providerLabels[provider]}: ${endpoints[provider]}');
|
||||
buffer.writeln(' launch: ${_defaultLaunchCommands[provider]}');
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('Bridge notes:')
|
||||
..writeln(
|
||||
'- Codex is configured for native app-server WebSocket on port 9001.',
|
||||
)
|
||||
..writeln(
|
||||
'- OpenCode is configured to the native HTTP server on port 4096.',
|
||||
)
|
||||
..writeln(
|
||||
'- Claude and Gemini are assigned local bridge slots; see docs/howto/external-acp-bridge-config.md for install and compatibility notes.',
|
||||
)
|
||||
..writeln(
|
||||
'- App Store alignment: all bridge binaries stay outside the app bundle and must be started manually after install.',
|
||||
);
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mergeExternalAcpEndpoints(
|
||||
Map<String, dynamic> existing, {
|
||||
required Map<String, String> endpoints,
|
||||
required Map<String, bool> enableProviders,
|
||||
}) {
|
||||
final updated = Map<String, dynamic>.from(existing);
|
||||
final incomingProfiles = (existing['externalAcpEndpoints'] is List)
|
||||
? List<Object?>.from(existing['externalAcpEndpoints'] as List)
|
||||
: <Object?>[];
|
||||
|
||||
final byKey = <String, Map<String, dynamic>>{};
|
||||
final extras = <Map<String, dynamic>>[];
|
||||
|
||||
for (final item in incomingProfiles) {
|
||||
if (item is! Map) {
|
||||
continue;
|
||||
}
|
||||
final profile = item.map(
|
||||
(Object? key, Object? value) => MapEntry(key.toString(), value),
|
||||
);
|
||||
final providerKey =
|
||||
profile['providerKey']?.toString().trim().toLowerCase() ?? '';
|
||||
if (_providerLabels.containsKey(providerKey)) {
|
||||
byKey[providerKey] = profile;
|
||||
} else if (providerKey.isNotEmpty) {
|
||||
extras.add(profile);
|
||||
}
|
||||
}
|
||||
|
||||
final builtins = <Map<String, dynamic>>[
|
||||
for (final provider in _providerLabels.keys)
|
||||
<String, dynamic>{
|
||||
...?byKey[provider],
|
||||
'providerKey': provider,
|
||||
'label': _providerLabels[provider],
|
||||
'endpoint': endpoints[provider] ?? '',
|
||||
'enabled': enableProviders[provider] ?? true,
|
||||
},
|
||||
];
|
||||
|
||||
updated['externalAcpEndpoints'] = <Object>[...builtins, ...extras];
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _readExistingSettings(File settingsFile) async {
|
||||
if (!await settingsFile.exists()) {
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
try {
|
||||
final raw = await settingsFile.readAsString();
|
||||
final decoded = decodeYamlDocument(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded is Map) {
|
||||
return decoded.map(
|
||||
(Object? key, Object? value) => MapEntry(key.toString(), value),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
stderr.writeln(
|
||||
'Warning: failed to parse ${settingsFile.path}; starting from an empty map. $error',
|
||||
);
|
||||
}
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
File _defaultSettingsFile({
|
||||
required Map<String, String> environment,
|
||||
required String operatingSystem,
|
||||
}) {
|
||||
final home = environment['HOME']?.trim() ?? '';
|
||||
if (operatingSystem == 'macos' && home.isNotEmpty) {
|
||||
return File(
|
||||
'$home/Library/Application Support/xworkmate/config/settings.yaml',
|
||||
);
|
||||
}
|
||||
if (operatingSystem == 'linux' && home.isNotEmpty) {
|
||||
final xdgConfigHome = environment['XDG_CONFIG_HOME']?.trim() ?? '';
|
||||
final base = xdgConfigHome.isNotEmpty ? xdgConfigHome : '$home/.config';
|
||||
return File('$base/xworkmate/config/settings.yaml');
|
||||
}
|
||||
if (operatingSystem == 'windows') {
|
||||
final appData = environment['APPDATA']?.trim() ?? '';
|
||||
if (appData.isNotEmpty) {
|
||||
return File('$appData\\xworkmate\\config\\settings.yaml');
|
||||
}
|
||||
final userProfile = environment['USERPROFILE']?.trim() ?? '';
|
||||
if (userProfile.isNotEmpty) {
|
||||
return File('$userProfile\\.xworkmate\\config\\settings.yaml');
|
||||
}
|
||||
}
|
||||
if (home.isNotEmpty) {
|
||||
return File('$home/.xworkmate/config/settings.yaml');
|
||||
}
|
||||
return File('settings.yaml');
|
||||
}
|
||||
|
||||
enum _Command { apply, printPlan }
|
||||
|
||||
class _CliOptions {
|
||||
const _CliOptions({
|
||||
required this.command,
|
||||
required this.showHelp,
|
||||
required this.dryRun,
|
||||
required this.backup,
|
||||
required this.settingsFile,
|
||||
required this.endpoints,
|
||||
required this.enableProviders,
|
||||
});
|
||||
|
||||
final _Command command;
|
||||
final bool showHelp;
|
||||
final bool dryRun;
|
||||
final bool backup;
|
||||
final File? settingsFile;
|
||||
final Map<String, String> endpoints;
|
||||
final Map<String, bool> enableProviders;
|
||||
|
||||
static _CliOptions parse(List<String> args) {
|
||||
if (args.isEmpty) {
|
||||
return _CliOptions(
|
||||
command: _Command.apply,
|
||||
showHelp: true,
|
||||
dryRun: false,
|
||||
backup: true,
|
||||
settingsFile: null,
|
||||
endpoints: const <String, String>{},
|
||||
enableProviders: const <String, bool>{},
|
||||
);
|
||||
}
|
||||
|
||||
final normalizedCommand = switch (args.first.trim().toLowerCase()) {
|
||||
'apply' => _Command.apply,
|
||||
'print' => _Command.printPlan,
|
||||
'--help' || '-h' || 'help' => _Command.apply,
|
||||
_ => _Command.apply,
|
||||
};
|
||||
final showHelp = <String>{
|
||||
'--help',
|
||||
'-h',
|
||||
'help',
|
||||
}.contains(args.first.trim().toLowerCase());
|
||||
final rest = showHelp ? args.skip(1).toList(growable: false) : args.skip(1);
|
||||
|
||||
var dryRun = false;
|
||||
var backup = true;
|
||||
File? settingsFile;
|
||||
final endpoints = <String, String>{};
|
||||
final enableProviders = <String, bool>{};
|
||||
|
||||
final values = rest.toList(growable: false);
|
||||
for (var index = 0; index < values.length; index += 1) {
|
||||
final argument = values[index].trim();
|
||||
if (argument.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
if (argument == '--help' || argument == '-h') {
|
||||
return _CliOptions(
|
||||
command: normalizedCommand,
|
||||
showHelp: true,
|
||||
dryRun: dryRun,
|
||||
backup: backup,
|
||||
settingsFile: settingsFile,
|
||||
endpoints: endpoints,
|
||||
enableProviders: enableProviders,
|
||||
);
|
||||
}
|
||||
if (argument == '--dry-run') {
|
||||
dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (argument == '--no-backup') {
|
||||
backup = false;
|
||||
continue;
|
||||
}
|
||||
if (argument.startsWith('--disable-')) {
|
||||
final provider = argument.substring('--disable-'.length).trim();
|
||||
if (_providerLabels.containsKey(provider)) {
|
||||
enableProviders[provider] = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!argument.startsWith('--')) {
|
||||
stderr.writeln('Ignoring unexpected argument: $argument');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index + 1 >= values.length) {
|
||||
throw ArgumentError('Missing value for $argument');
|
||||
}
|
||||
|
||||
final value = values[index + 1].trim();
|
||||
index += 1;
|
||||
switch (argument) {
|
||||
case '--settings-file':
|
||||
settingsFile = File(value);
|
||||
break;
|
||||
case '--codex-endpoint':
|
||||
endpoints['codex'] = value;
|
||||
break;
|
||||
case '--opencode-endpoint':
|
||||
endpoints['opencode'] = value;
|
||||
break;
|
||||
case '--claude-endpoint':
|
||||
endpoints['claude'] = value;
|
||||
break;
|
||||
case '--gemini-endpoint':
|
||||
endpoints['gemini'] = value;
|
||||
break;
|
||||
default:
|
||||
stderr.writeln('Ignoring unknown option: $argument');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return _CliOptions(
|
||||
command: normalizedCommand,
|
||||
showHelp: showHelp,
|
||||
dryRun: dryRun,
|
||||
backup: backup,
|
||||
settingsFile: settingsFile,
|
||||
endpoints: endpoints,
|
||||
enableProviders: enableProviders,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Object? decodeYamlDocument(String raw) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return _yamlToObject(loadYaml(trimmed));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Object? _yamlToObject(Object? value) {
|
||||
if (value is YamlMap) {
|
||||
return value.map(
|
||||
(Object? key, Object? item) =>
|
||||
MapEntry(key?.toString() ?? '', _yamlToObject(item)),
|
||||
);
|
||||
}
|
||||
if (value is YamlList) {
|
||||
return value.map(_yamlToObject).toList(growable: false);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
String encodeYamlDocument(Object? value) {
|
||||
final buffer = StringBuffer('---\n');
|
||||
_writeYamlValue(buffer, value, 0, listItem: false);
|
||||
if (!buffer.toString().endsWith('\n')) {
|
||||
buffer.writeln();
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
void _writeYamlValue(
|
||||
StringBuffer buffer,
|
||||
Object? value,
|
||||
int indent, {
|
||||
required bool listItem,
|
||||
}) {
|
||||
final prefix = ' ' * indent;
|
||||
if (value is Map) {
|
||||
if (value.isEmpty) {
|
||||
if (listItem) {
|
||||
buffer.writeln('{}');
|
||||
} else {
|
||||
buffer.writeln('$prefix{}');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (listItem) {
|
||||
buffer.writeln();
|
||||
}
|
||||
for (final entry in value.entries) {
|
||||
final key = entry.key.toString();
|
||||
final item = entry.value;
|
||||
if (_isInlineYamlValue(item)) {
|
||||
buffer.writeln('$prefix$key: ${_yamlInlineValue(item)}');
|
||||
} else if (item is String && item.contains('\n')) {
|
||||
buffer.writeln('$prefix$key: |-');
|
||||
for (final line in item.split('\n')) {
|
||||
buffer.writeln('${' ' * (indent + 1)}$line');
|
||||
}
|
||||
} else {
|
||||
buffer.writeln('$prefix$key:');
|
||||
_writeYamlValue(buffer, item, indent + 1, listItem: false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value is List) {
|
||||
if (value.isEmpty) {
|
||||
if (listItem) {
|
||||
buffer.writeln('[]');
|
||||
} else {
|
||||
buffer.writeln('$prefix[]');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (listItem) {
|
||||
buffer.writeln();
|
||||
}
|
||||
for (final item in value) {
|
||||
if (_isInlineYamlValue(item)) {
|
||||
buffer.writeln('$prefix- ${_yamlInlineValue(item)}');
|
||||
} else if (item is String && item.contains('\n')) {
|
||||
buffer.writeln('$prefix- |-');
|
||||
for (final line in item.split('\n')) {
|
||||
buffer.writeln('${' ' * (indent + 1)}$line');
|
||||
}
|
||||
} else {
|
||||
buffer.writeln('$prefix-');
|
||||
_writeYamlValue(buffer, item, indent + 1, listItem: false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (listItem) {
|
||||
buffer.writeln(_yamlInlineValue(value));
|
||||
return;
|
||||
}
|
||||
buffer.writeln('$prefix${_yamlInlineValue(value)}');
|
||||
}
|
||||
|
||||
bool _isInlineYamlValue(Object? value) {
|
||||
if (value == null || value is bool || value is num) {
|
||||
return true;
|
||||
}
|
||||
if (value is String) {
|
||||
return !value.contains('\n');
|
||||
}
|
||||
if (value is List) {
|
||||
return value.isEmpty;
|
||||
}
|
||||
if (value is Map) {
|
||||
return value.isEmpty;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
String _yamlInlineValue(Object? value) {
|
||||
if (value == null) {
|
||||
return 'null';
|
||||
}
|
||||
if (value is bool || value is num) {
|
||||
return value.toString();
|
||||
}
|
||||
if (value is List && value.isEmpty) {
|
||||
return '[]';
|
||||
}
|
||||
if (value is Map && value.isEmpty) {
|
||||
return '{}';
|
||||
}
|
||||
final stringValue = value.toString();
|
||||
if (stringValue.isEmpty) {
|
||||
return "''";
|
||||
}
|
||||
final safe = RegExp(r'^[A-Za-z0-9_./:@+%-]+$');
|
||||
final reserved = <String>{'null', 'true', 'false', '~'};
|
||||
if (safe.hasMatch(stringValue) && !reserved.contains(stringValue)) {
|
||||
return stringValue;
|
||||
}
|
||||
final escaped = stringValue.replaceAll("'", "''");
|
||||
return "'$escaped'";
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user