devconfig-gen-singbox 2.1.0__tar.gz → 2.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/PKG-INFO +20 -11
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/README.md +19 -10
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/pyproject.toml +2 -3
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/__init__.py +3 -1
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/cli.py +67 -1
- devconfig_gen_singbox-2.1.2/src/devconfig_gen/interactive.py +377 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/models.py +25 -4
- devconfig_gen_singbox-2.1.2/src/devconfig_gen/web_ui.py +2508 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen_singbox.egg-info/PKG-INFO +20 -11
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen_singbox.egg-info/SOURCES.txt +6 -1
- devconfig_gen_singbox-2.1.2/src/devconfig_gen_singbox.egg-info/entry_points.txt +2 -0
- devconfig_gen_singbox-2.1.2/src/singbox_ops/cli.py +207 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_cli_e2e.py +1 -1
- devconfig_gen_singbox-2.1.2/tests/test_interactive.py +238 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_singbox_ops_cli.py +13 -0
- devconfig_gen_singbox-2.1.2/tests/test_web_ui.py +224 -0
- devconfig_gen_singbox-2.1.2/tests/test_web_ui_widgets.py +146 -0
- devconfig_gen_singbox-2.1.0/src/devconfig_gen_singbox.egg-info/entry_points.txt +0 -4
- devconfig_gen_singbox-2.1.0/src/singbox_ops/cli.py +0 -109
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/LICENSE +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/setup.cfg +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/engine.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/formats.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/providers/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/providers/custom.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/providers/env_provider.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/providers/json_provider.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/providers/singbox/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/providers/singbox/data/rules.json +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/providers/singbox/links.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/providers/singbox/models.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/providers/singbox/plugins/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/providers/singbox/plugins/anytls.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/providers/singbox/plugins/base.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/providers/singbox/plugins/hysteria2.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/providers/singbox/plugins/tuic.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/providers/singbox/provider.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/providers/singbox/route.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/providers/singbox/schema.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/py.typed +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/registry.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen/validation.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen_singbox.egg-info/dependency_links.txt +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen_singbox.egg-info/requires.txt +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/devconfig_gen_singbox.egg-info/top_level.txt +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/acme/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/acme/base.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/acme/cloudflare_dns01.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/base.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/dns/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/dns/base.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/dns/cloudflare.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/export/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/export/local_files.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/runtime/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/runtime/nftables.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/runtime/packages.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/runtime/systemd.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/runtime/warp_watchdog.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/secrets/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/secrets/base.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/secrets/python_secrets.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/secrets/singbox_subprocess.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/secrets/static.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/state/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/adapters/state/local_json.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/core/__init__.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/core/command.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/core/context_builder.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/core/exceptions.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/core/network.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/core/orchestrator.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/core/plan.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/core/protocols.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/templates/singbox_auto_update.py.template +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/src/singbox_ops/templates/warp_watchdog.sh.template +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_api_parity.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_custom_provider.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_devconfig_core.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_env_provider.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_formats.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_merge.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_provider_metadata.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_singbox_ops_acme.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_singbox_ops_context_builder.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_singbox_ops_dns.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_singbox_ops_orchestrator.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_singbox_ops_plan.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_singbox_ops_runtime.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_singbox_ops_secrets.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_singbox_ops_state_export.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_singbox_provider.py +0 -0
- {devconfig_gen_singbox-2.1.0 → devconfig_gen_singbox-2.1.2}/tests/test_validation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devconfig_gen_singbox
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.2
|
|
4
4
|
Summary: Provider-based developer configuration generation engine and CLI
|
|
5
5
|
Author-email: henryliu443 <henryliu443@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -28,7 +28,7 @@ Dynamic: license-file
|
|
|
28
28
|
> **A sing-box domain implementation built on top of DevConfig-Gen.**
|
|
29
29
|
> **构建在 `DevConfig-Gen` 之上的 sing-box 领域实现(child / fork)。**
|
|
30
30
|
|
|
31
|
-
[](CHANGELOG.md)
|
|
32
32
|
[](LICENSE)
|
|
33
33
|

|
|
34
34
|

|
|
@@ -62,7 +62,7 @@ devconfig_gen_singbox
|
|
|
62
62
|
git clone https://github.com/henryliu443/devconfig_gen_singbox.git
|
|
63
63
|
cd devconfig_gen_singbox
|
|
64
64
|
pip install -e ".[yaml]"
|
|
65
|
-
|
|
65
|
+
devconfig_gen_singbox generate --provider singbox --input examples/singbox.yaml --output-dir dist
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
未安装时亦可直接通过源码运行:
|
|
@@ -226,9 +226,9 @@ options:
|
|
|
226
226
|
## 输入示例 / Input Example
|
|
227
227
|
|
|
228
228
|
```bash
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
229
|
+
devconfig_gen_singbox generate --provider singbox --input examples/singbox.yaml --output-dir dist
|
|
230
|
+
devconfig_gen_singbox validate --provider singbox --input examples/singbox.yaml
|
|
231
|
+
devconfig_gen_singbox schema --provider singbox
|
|
232
232
|
```
|
|
233
233
|
|
|
234
234
|
完整 context 见 [`examples/singbox.yaml`](examples/singbox.yaml)。
|
|
@@ -281,13 +281,14 @@ singbox-ops destroy --plan examples/singbox-deploy.yaml
|
|
|
281
281
|
- 结构化校验与诊断(`Diagnostic` / `ValidationError`)
|
|
282
282
|
- JSON/YAML 序列化(标准库 + 可选 PyYAML,内置子集解析器兜底)
|
|
283
283
|
- 多源合并与 dotted-path 覆盖(`deep_merge`、`--set`)
|
|
284
|
-
- CLI
|
|
284
|
+
- CLI、**终端向导**(`devconfig_gen_singbox init`)、**本地 Web 工作台**
|
|
285
|
+
(`devconfig_gen_singbox ui`)与 Python API
|
|
285
286
|
|
|
286
287
|
同时也继承中立的 Provider `custom` / `json` / `env`。本仓库在其之上新增领域
|
|
287
288
|
Provider `singbox`:
|
|
288
289
|
|
|
289
290
|
```bash
|
|
290
|
-
|
|
291
|
+
devconfig_gen_singbox providers
|
|
291
292
|
# custom / env / json / singbox
|
|
292
293
|
```
|
|
293
294
|
|
|
@@ -298,9 +299,15 @@ devconfig-gen providers
|
|
|
298
299
|
|
|
299
300
|
```bash
|
|
300
301
|
# 生成 / 校验 / 查看 schema
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
302
|
+
devconfig_gen_singbox generate --provider singbox --input examples/singbox.yaml --output-dir dist --format yaml
|
|
303
|
+
devconfig_gen_singbox validate --provider singbox --input examples/singbox.yaml --json
|
|
304
|
+
devconfig_gen_singbox schema --provider singbox
|
|
305
|
+
|
|
306
|
+
# 交互式终端向导(所有 provider 通用,含 singbox)
|
|
307
|
+
devconfig_gen_singbox init --provider singbox --output-dir dist
|
|
308
|
+
|
|
309
|
+
# 本地 Web 工作台(零构建,标准库 http.server)
|
|
310
|
+
devconfig_gen_singbox ui --port 8848
|
|
304
311
|
```
|
|
305
312
|
|
|
306
313
|
```python
|
|
@@ -348,6 +355,8 @@ generate_pipeline(
|
|
|
348
355
|
| 校验与诊断 | [`docs/validation.md`](docs/validation.md) | [validation](https://henryliu443.github.io/DevConfig-Gen/docs/validation/) |
|
|
349
356
|
| Provider 开发 | [`docs/providers.md`](docs/providers.md) | [providers](https://henryliu443.github.io/DevConfig-Gen/docs/providers/) |
|
|
350
357
|
| Python API | [`docs/python-api.md`](docs/python-api.md) | [python-api](https://henryliu443.github.io/DevConfig-Gen/docs/python-api/) |
|
|
358
|
+
| 终端向导 | [`docs/wizard.md`](docs/wizard.md) | [wizard](https://henryliu443.github.io/DevConfig-Gen/docs/wizard/) |
|
|
359
|
+
| Web 工作台 | [`docs/web-ui.md`](docs/web-ui.md) | [web-ui](https://henryliu443.github.io/DevConfig-Gen/docs/web-ui/) |
|
|
351
360
|
| 架构总览 | [`ARCHITECTURE.md`](ARCHITECTURE.md) | — |
|
|
352
361
|
| Provider 铁标准 | [`PROVIDER_STANDARD.md`](PROVIDER_STANDARD.md) | — |
|
|
353
362
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> **A sing-box domain implementation built on top of DevConfig-Gen.**
|
|
4
4
|
> **构建在 `DevConfig-Gen` 之上的 sing-box 领域实现(child / fork)。**
|
|
5
5
|
|
|
6
|
-
[](CHANGELOG.md)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|

|
|
9
9
|

|
|
@@ -37,7 +37,7 @@ devconfig_gen_singbox
|
|
|
37
37
|
git clone https://github.com/henryliu443/devconfig_gen_singbox.git
|
|
38
38
|
cd devconfig_gen_singbox
|
|
39
39
|
pip install -e ".[yaml]"
|
|
40
|
-
|
|
40
|
+
devconfig_gen_singbox generate --provider singbox --input examples/singbox.yaml --output-dir dist
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
未安装时亦可直接通过源码运行:
|
|
@@ -201,9 +201,9 @@ options:
|
|
|
201
201
|
## 输入示例 / Input Example
|
|
202
202
|
|
|
203
203
|
```bash
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
204
|
+
devconfig_gen_singbox generate --provider singbox --input examples/singbox.yaml --output-dir dist
|
|
205
|
+
devconfig_gen_singbox validate --provider singbox --input examples/singbox.yaml
|
|
206
|
+
devconfig_gen_singbox schema --provider singbox
|
|
207
207
|
```
|
|
208
208
|
|
|
209
209
|
完整 context 见 [`examples/singbox.yaml`](examples/singbox.yaml)。
|
|
@@ -256,13 +256,14 @@ singbox-ops destroy --plan examples/singbox-deploy.yaml
|
|
|
256
256
|
- 结构化校验与诊断(`Diagnostic` / `ValidationError`)
|
|
257
257
|
- JSON/YAML 序列化(标准库 + 可选 PyYAML,内置子集解析器兜底)
|
|
258
258
|
- 多源合并与 dotted-path 覆盖(`deep_merge`、`--set`)
|
|
259
|
-
- CLI
|
|
259
|
+
- CLI、**终端向导**(`devconfig_gen_singbox init`)、**本地 Web 工作台**
|
|
260
|
+
(`devconfig_gen_singbox ui`)与 Python API
|
|
260
261
|
|
|
261
262
|
同时也继承中立的 Provider `custom` / `json` / `env`。本仓库在其之上新增领域
|
|
262
263
|
Provider `singbox`:
|
|
263
264
|
|
|
264
265
|
```bash
|
|
265
|
-
|
|
266
|
+
devconfig_gen_singbox providers
|
|
266
267
|
# custom / env / json / singbox
|
|
267
268
|
```
|
|
268
269
|
|
|
@@ -273,9 +274,15 @@ devconfig-gen providers
|
|
|
273
274
|
|
|
274
275
|
```bash
|
|
275
276
|
# 生成 / 校验 / 查看 schema
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
277
|
+
devconfig_gen_singbox generate --provider singbox --input examples/singbox.yaml --output-dir dist --format yaml
|
|
278
|
+
devconfig_gen_singbox validate --provider singbox --input examples/singbox.yaml --json
|
|
279
|
+
devconfig_gen_singbox schema --provider singbox
|
|
280
|
+
|
|
281
|
+
# 交互式终端向导(所有 provider 通用,含 singbox)
|
|
282
|
+
devconfig_gen_singbox init --provider singbox --output-dir dist
|
|
283
|
+
|
|
284
|
+
# 本地 Web 工作台(零构建,标准库 http.server)
|
|
285
|
+
devconfig_gen_singbox ui --port 8848
|
|
279
286
|
```
|
|
280
287
|
|
|
281
288
|
```python
|
|
@@ -323,6 +330,8 @@ generate_pipeline(
|
|
|
323
330
|
| 校验与诊断 | [`docs/validation.md`](docs/validation.md) | [validation](https://henryliu443.github.io/DevConfig-Gen/docs/validation/) |
|
|
324
331
|
| Provider 开发 | [`docs/providers.md`](docs/providers.md) | [providers](https://henryliu443.github.io/DevConfig-Gen/docs/providers/) |
|
|
325
332
|
| Python API | [`docs/python-api.md`](docs/python-api.md) | [python-api](https://henryliu443.github.io/DevConfig-Gen/docs/python-api/) |
|
|
333
|
+
| 终端向导 | [`docs/wizard.md`](docs/wizard.md) | [wizard](https://henryliu443.github.io/DevConfig-Gen/docs/wizard/) |
|
|
334
|
+
| Web 工作台 | [`docs/web-ui.md`](docs/web-ui.md) | [web-ui](https://henryliu443.github.io/DevConfig-Gen/docs/web-ui/) |
|
|
326
335
|
| 架构总览 | [`ARCHITECTURE.md`](ARCHITECTURE.md) | — |
|
|
327
336
|
| Provider 铁标准 | [`PROVIDER_STANDARD.md`](PROVIDER_STANDARD.md) | — |
|
|
328
337
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devconfig_gen_singbox"
|
|
7
|
-
version = "2.1.
|
|
7
|
+
version = "2.1.2"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name = "henryliu443", email = "henryliu443@gmail.com" },
|
|
10
10
|
]
|
|
@@ -26,9 +26,8 @@ dev = ["build", "wheel", "PyYAML>=5.1", "cryptography>=3.4"]
|
|
|
26
26
|
docs = ["mkdocs-material>=9.5,<10"]
|
|
27
27
|
|
|
28
28
|
[project.scripts]
|
|
29
|
-
|
|
29
|
+
# One command, named exactly after the PyPI distribution.
|
|
30
30
|
devconfig_gen_singbox = "devconfig_gen.cli:main"
|
|
31
|
-
singbox-ops = "singbox_ops.cli:main"
|
|
32
31
|
|
|
33
32
|
[tool.setuptools.packages.find]
|
|
34
33
|
where = ["src"]
|
|
@@ -32,6 +32,7 @@ from .models import (
|
|
|
32
32
|
GenerationResult,
|
|
33
33
|
ProviderField,
|
|
34
34
|
ProviderStep,
|
|
35
|
+
WebUIWidgets,
|
|
35
36
|
)
|
|
36
37
|
from .registry import ProviderRegistry, default_registry
|
|
37
38
|
from .validation import ValidationError
|
|
@@ -47,6 +48,7 @@ __all__ = [
|
|
|
47
48
|
"ProviderRegistry",
|
|
48
49
|
"ProviderStep",
|
|
49
50
|
"ValidationError",
|
|
51
|
+
"WebUIWidgets",
|
|
50
52
|
"build_request",
|
|
51
53
|
"coerce_scalar",
|
|
52
54
|
"deep_merge",
|
|
@@ -64,4 +66,4 @@ __all__ = [
|
|
|
64
66
|
"loads",
|
|
65
67
|
"validate_request",
|
|
66
68
|
]
|
|
67
|
-
__version__ = "2.1.
|
|
69
|
+
__version__ = "2.1.2"
|
|
@@ -14,7 +14,7 @@ from .registry import default_registry
|
|
|
14
14
|
|
|
15
15
|
def build_parser() -> argparse.ArgumentParser:
|
|
16
16
|
parser = argparse.ArgumentParser(
|
|
17
|
-
prog="
|
|
17
|
+
prog="devconfig_gen_singbox",
|
|
18
18
|
description="Generate and validate structured JSON/YAML configuration.",
|
|
19
19
|
)
|
|
20
20
|
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
@@ -74,9 +74,51 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
74
74
|
schema_cmd.add_argument("--provider", default="json", help="Provider name (default: json)")
|
|
75
75
|
schema_cmd.set_defaults(handler=_schema)
|
|
76
76
|
|
|
77
|
+
init_cmd = sub.add_parser(
|
|
78
|
+
"init",
|
|
79
|
+
help="Run interactive terminal wizard to create configuration",
|
|
80
|
+
description="Run interactive terminal wizard to create configuration.",
|
|
81
|
+
)
|
|
82
|
+
init_cmd.add_argument("--provider", default="custom", help="Provider name (default: custom)")
|
|
83
|
+
init_cmd.add_argument("--input", type=Path, help="Optional existing config file to pre-fill wizard")
|
|
84
|
+
init_cmd.add_argument("--output-dir", default=".", type=Path, help="Directory for generated artifact")
|
|
85
|
+
init_cmd.add_argument("--format", choices=("json", "yaml"), help="Output format override")
|
|
86
|
+
init_cmd.set_defaults(handler=_init)
|
|
87
|
+
|
|
88
|
+
ui_cmd = sub.add_parser(
|
|
89
|
+
"ui",
|
|
90
|
+
help="Launch local configuration studio WebUI",
|
|
91
|
+
description="Launch local configuration studio WebUI.",
|
|
92
|
+
)
|
|
93
|
+
ui_cmd.add_argument("--host", default="127.0.0.1", help="Host address (default: 127.0.0.1)")
|
|
94
|
+
ui_cmd.add_argument("--port", default=8848, type=int, help="Port to bind (default: 8848)")
|
|
95
|
+
ui_cmd.add_argument("--no-browser", action="store_true", help="Do not automatically open browser")
|
|
96
|
+
ui_cmd.add_argument(
|
|
97
|
+
"--workspace",
|
|
98
|
+
default=None,
|
|
99
|
+
help="Directory the studio may export into (default: current directory)",
|
|
100
|
+
)
|
|
101
|
+
ui_cmd.set_defaults(handler=_ui)
|
|
102
|
+
|
|
103
|
+
_register_ops_subparsers(sub)
|
|
104
|
+
|
|
77
105
|
return parser
|
|
78
106
|
|
|
79
107
|
|
|
108
|
+
def _register_ops_subparsers(sub) -> None:
|
|
109
|
+
"""Graft the optional ``singbox_ops`` subcommands onto this single CLI.
|
|
110
|
+
|
|
111
|
+
``singbox_ops`` is imported lazily, so importing ``devconfig_gen`` stays
|
|
112
|
+
side-effect free and the pure engine works even without the ops package.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
from singbox_ops.cli import add_subparsers
|
|
117
|
+
except Exception: # pragma: no cover - ops layer is optional
|
|
118
|
+
return
|
|
119
|
+
add_subparsers(sub)
|
|
120
|
+
|
|
121
|
+
|
|
80
122
|
def _list_providers(args) -> int:
|
|
81
123
|
for name in default_registry.names():
|
|
82
124
|
print(name)
|
|
@@ -154,6 +196,30 @@ def _schema(args) -> int:
|
|
|
154
196
|
return 0
|
|
155
197
|
|
|
156
198
|
|
|
199
|
+
def _init(args) -> int:
|
|
200
|
+
from .interactive import run_interactive_wizard
|
|
201
|
+
|
|
202
|
+
input_path = str(args.input) if args.input else None
|
|
203
|
+
return run_interactive_wizard(
|
|
204
|
+
args.provider,
|
|
205
|
+
input_path=input_path,
|
|
206
|
+
output_dir=str(args.output_dir),
|
|
207
|
+
output_format=args.format,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _ui(args) -> int:
|
|
212
|
+
from .web_ui import run_web_ui
|
|
213
|
+
|
|
214
|
+
run_web_ui(
|
|
215
|
+
host=args.host,
|
|
216
|
+
port=args.port,
|
|
217
|
+
open_browser=not args.no_browser,
|
|
218
|
+
workspace_root=args.workspace,
|
|
219
|
+
)
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
157
223
|
def main(argv=None) -> int:
|
|
158
224
|
args = build_parser().parse_args(argv)
|
|
159
225
|
return args.handler(args)
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Interactive terminal wizard for generating configuration.
|
|
2
|
+
|
|
3
|
+
Designed for headless environments (e.g. Linux SSH servers or terminal-first
|
|
4
|
+
workflows on macOS). Driven entirely by the provider's declarative steps and
|
|
5
|
+
schema metadata, so there is no duplication with the core engine or the WebUI.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, Optional, Sequence, TextIO
|
|
13
|
+
|
|
14
|
+
from . import formats
|
|
15
|
+
from .engine import describe_provider, diagnose_request, generate
|
|
16
|
+
from .models import GenerationRequest, ProviderField, ProviderStep
|
|
17
|
+
from .registry import ProviderRegistry, default_registry
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _EndOfInput(Exception):
|
|
21
|
+
"""Raised when the input stream ends before a required answer is given."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, field_name: str):
|
|
24
|
+
super().__init__(field_name)
|
|
25
|
+
self.field_name = field_name
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _set_dotted_path(target: Dict[str, Any], path: str, value: Any) -> None:
|
|
29
|
+
"""Set a value in a nested dict using a dotted path like 'app.port'."""
|
|
30
|
+
|
|
31
|
+
parts = path.split(".")
|
|
32
|
+
current = target
|
|
33
|
+
for part in parts[:-1]:
|
|
34
|
+
if part not in current or not isinstance(current[part], dict):
|
|
35
|
+
current[part] = {}
|
|
36
|
+
current = current[part]
|
|
37
|
+
current[parts[-1]] = value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_dotted_path(source: Any, path: str, default: Any = None) -> Any:
|
|
41
|
+
"""Retrieve a value from a nested dict using a dotted path."""
|
|
42
|
+
|
|
43
|
+
if not isinstance(source, dict):
|
|
44
|
+
return default
|
|
45
|
+
current: Any = source
|
|
46
|
+
for part in path.split("."):
|
|
47
|
+
if not isinstance(current, dict) or part not in current:
|
|
48
|
+
return default
|
|
49
|
+
current = current[part]
|
|
50
|
+
return current
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _read_line(reader: TextIO, writer: TextIO, prompt: str) -> Optional[str]:
|
|
54
|
+
"""Write ``prompt`` and read one line.
|
|
55
|
+
|
|
56
|
+
Returns the stripped line, or ``None`` when the stream is exhausted. A blank
|
|
57
|
+
line (``"\\n"``) is distinct from end-of-input (``""``).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
writer.write(prompt)
|
|
61
|
+
writer.flush()
|
|
62
|
+
line = reader.readline()
|
|
63
|
+
if line == "":
|
|
64
|
+
return None
|
|
65
|
+
return line.strip()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _prompt_string(
|
|
69
|
+
field: ProviderField,
|
|
70
|
+
current_value: Any,
|
|
71
|
+
reader: TextIO,
|
|
72
|
+
writer: TextIO,
|
|
73
|
+
) -> Any:
|
|
74
|
+
effective_default = current_value if current_value is not None else field.default
|
|
75
|
+
default_str = f" [{effective_default}]" if effective_default is not None else ""
|
|
76
|
+
req_str = " (required)" if field.required and effective_default is None else ""
|
|
77
|
+
|
|
78
|
+
if field.choices:
|
|
79
|
+
writer.write(f"? {field.name} - Select option:{req_str}\n")
|
|
80
|
+
for i, choice in enumerate(field.choices, 1):
|
|
81
|
+
writer.write(f" {i}) {choice}\n")
|
|
82
|
+
lower_choices = {str(c).lower(): c for c in field.choices}
|
|
83
|
+
while True:
|
|
84
|
+
answer = _read_line(reader, writer, f" Enter choice number or name{default_str}: ")
|
|
85
|
+
if answer is None:
|
|
86
|
+
if effective_default is not None:
|
|
87
|
+
return effective_default
|
|
88
|
+
raise _EndOfInput(field.name)
|
|
89
|
+
if answer == "":
|
|
90
|
+
if effective_default is not None:
|
|
91
|
+
return effective_default
|
|
92
|
+
if field.required:
|
|
93
|
+
writer.write(" [!] Selection is required. Please choose an option.\n")
|
|
94
|
+
continue
|
|
95
|
+
return None
|
|
96
|
+
if answer.isdigit():
|
|
97
|
+
idx = int(answer) - 1
|
|
98
|
+
if 0 <= idx < len(field.choices):
|
|
99
|
+
return field.choices[idx]
|
|
100
|
+
if answer.lower() in lower_choices:
|
|
101
|
+
return lower_choices[answer.lower()]
|
|
102
|
+
writer.write(
|
|
103
|
+
f" [!] Invalid choice: {answer!r}. Please select 1-{len(field.choices)}.\n"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
while True:
|
|
107
|
+
answer = _read_line(reader, writer, f"? {field.name}{req_str}{default_str}: ")
|
|
108
|
+
if answer is None:
|
|
109
|
+
if effective_default is not None:
|
|
110
|
+
return effective_default
|
|
111
|
+
raise _EndOfInput(field.name)
|
|
112
|
+
if answer == "":
|
|
113
|
+
if effective_default is not None:
|
|
114
|
+
return effective_default
|
|
115
|
+
if field.required:
|
|
116
|
+
writer.write(" [!] This field is required.\n")
|
|
117
|
+
continue
|
|
118
|
+
return None
|
|
119
|
+
return answer
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _prompt_integer(
|
|
123
|
+
field: ProviderField,
|
|
124
|
+
current_value: Any,
|
|
125
|
+
reader: TextIO,
|
|
126
|
+
writer: TextIO,
|
|
127
|
+
) -> Any:
|
|
128
|
+
effective_default = current_value if current_value is not None else field.default
|
|
129
|
+
default_str = f" [{effective_default}]" if effective_default is not None else ""
|
|
130
|
+
bounds = []
|
|
131
|
+
if field.minimum is not None:
|
|
132
|
+
bounds.append(f"min {int(field.minimum)}")
|
|
133
|
+
if field.maximum is not None:
|
|
134
|
+
bounds.append(f"max {int(field.maximum)}")
|
|
135
|
+
bound_str = f" ({', '.join(bounds)})" if bounds else ""
|
|
136
|
+
req_str = " (required)" if field.required and effective_default is None else ""
|
|
137
|
+
|
|
138
|
+
while True:
|
|
139
|
+
answer = _read_line(reader, writer, f"? {field.name}{bound_str}{req_str}{default_str}: ")
|
|
140
|
+
if answer is None:
|
|
141
|
+
if effective_default is not None:
|
|
142
|
+
return effective_default
|
|
143
|
+
raise _EndOfInput(field.name)
|
|
144
|
+
if answer == "":
|
|
145
|
+
if effective_default is not None:
|
|
146
|
+
return effective_default
|
|
147
|
+
if field.required:
|
|
148
|
+
writer.write(" [!] An integer value is required.\n")
|
|
149
|
+
continue
|
|
150
|
+
return None
|
|
151
|
+
try:
|
|
152
|
+
number = int(answer)
|
|
153
|
+
except ValueError:
|
|
154
|
+
writer.write(f" [!] Expected integer, got {answer!r}.\n")
|
|
155
|
+
continue
|
|
156
|
+
if field.minimum is not None and number < field.minimum:
|
|
157
|
+
writer.write(f" [!] Value must be >= {int(field.minimum)}.\n")
|
|
158
|
+
continue
|
|
159
|
+
if field.maximum is not None and number > field.maximum:
|
|
160
|
+
writer.write(f" [!] Value must be <= {int(field.maximum)}.\n")
|
|
161
|
+
continue
|
|
162
|
+
return number
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _prompt_boolean(
|
|
166
|
+
field: ProviderField,
|
|
167
|
+
current_value: Any,
|
|
168
|
+
reader: TextIO,
|
|
169
|
+
writer: TextIO,
|
|
170
|
+
) -> bool:
|
|
171
|
+
effective_default = bool(current_value if current_value is not None else field.default)
|
|
172
|
+
prompt_hint = "[Y/n]" if effective_default else "[y/N]"
|
|
173
|
+
while True:
|
|
174
|
+
answer = _read_line(reader, writer, f"? {field.name} {prompt_hint}: ")
|
|
175
|
+
if answer is None or answer == "":
|
|
176
|
+
return effective_default
|
|
177
|
+
lowered = answer.lower()
|
|
178
|
+
if lowered in ("y", "yes", "true", "1"):
|
|
179
|
+
return True
|
|
180
|
+
if lowered in ("n", "no", "false", "0"):
|
|
181
|
+
return False
|
|
182
|
+
writer.write(" [!] Please enter 'y' for yes or 'n' for no.\n")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _prompt_mapping(
|
|
186
|
+
field: ProviderField,
|
|
187
|
+
current_value: Any,
|
|
188
|
+
reader: TextIO,
|
|
189
|
+
writer: TextIO,
|
|
190
|
+
) -> Dict[str, str]:
|
|
191
|
+
mapping: Dict[str, str] = dict(current_value) if isinstance(current_value, dict) else {}
|
|
192
|
+
writer.write(
|
|
193
|
+
f"? {field.name} (enter key=value pairs, press enter on empty line to finish):\n"
|
|
194
|
+
)
|
|
195
|
+
if mapping:
|
|
196
|
+
writer.write(
|
|
197
|
+
" Current entries: " + ", ".join(f"{k}={v}" for k, v in mapping.items()) + "\n"
|
|
198
|
+
)
|
|
199
|
+
while True:
|
|
200
|
+
answer = _read_line(reader, writer, " key=val (or empty to finish): ")
|
|
201
|
+
if answer is None or answer == "":
|
|
202
|
+
break
|
|
203
|
+
if "=" not in answer:
|
|
204
|
+
writer.write(" [!] Invalid format. Must be key=value.\n")
|
|
205
|
+
continue
|
|
206
|
+
key, value = answer.split("=", 1)
|
|
207
|
+
mapping[key.strip()] = value.strip()
|
|
208
|
+
return mapping
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _prompt_document(
|
|
212
|
+
field: ProviderField,
|
|
213
|
+
current_value: Any,
|
|
214
|
+
reader: TextIO,
|
|
215
|
+
writer: TextIO,
|
|
216
|
+
) -> Any:
|
|
217
|
+
writer.write(f"? {field.name} (load document file or enter entries):\n")
|
|
218
|
+
path_str = _read_line(
|
|
219
|
+
reader, writer, " Path to JSON/YAML file (or press enter for key=value input): "
|
|
220
|
+
)
|
|
221
|
+
if path_str:
|
|
222
|
+
file_path = Path(path_str).expanduser()
|
|
223
|
+
if file_path.is_file():
|
|
224
|
+
try:
|
|
225
|
+
loaded = formats.load_file(file_path)
|
|
226
|
+
writer.write(f" [✓] Loaded document from {file_path}\n")
|
|
227
|
+
return loaded
|
|
228
|
+
except Exception as exc:
|
|
229
|
+
writer.write(f" [!] Error reading {file_path}: {exc}\n")
|
|
230
|
+
else:
|
|
231
|
+
writer.write(f" [!] File not found: {path_str}\n")
|
|
232
|
+
return _prompt_mapping(field, current_value, reader, writer)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def prompt_field(
|
|
236
|
+
field: ProviderField,
|
|
237
|
+
current_value: Any,
|
|
238
|
+
reader: TextIO,
|
|
239
|
+
writer: TextIO,
|
|
240
|
+
) -> Any:
|
|
241
|
+
"""Prompt the user for a single field's value based on its type."""
|
|
242
|
+
|
|
243
|
+
if field.type == "integer":
|
|
244
|
+
return _prompt_integer(field, current_value, reader, writer)
|
|
245
|
+
if field.type == "boolean":
|
|
246
|
+
return _prompt_boolean(field, current_value, reader, writer)
|
|
247
|
+
if field.type in ("mapping", "dict"):
|
|
248
|
+
return _prompt_mapping(field, current_value, reader, writer)
|
|
249
|
+
if field.type in ("document", "tree"):
|
|
250
|
+
return _prompt_document(field, current_value, reader, writer)
|
|
251
|
+
return _prompt_string(field, current_value, reader, writer)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def run_interactive_wizard(
|
|
255
|
+
provider_name: str,
|
|
256
|
+
*,
|
|
257
|
+
input_path: Optional[str] = None,
|
|
258
|
+
context: Optional[Dict[str, Any]] = None,
|
|
259
|
+
output_dir: Optional[str] = None,
|
|
260
|
+
output_format: Optional[str] = None,
|
|
261
|
+
registry: Optional[ProviderRegistry] = None,
|
|
262
|
+
reader: Optional[TextIO] = None,
|
|
263
|
+
writer: Optional[TextIO] = None,
|
|
264
|
+
) -> int:
|
|
265
|
+
"""Execute the interactive wizard loop in the terminal.
|
|
266
|
+
|
|
267
|
+
``context`` seeds the wizard with existing answers. ``input_path`` loads a
|
|
268
|
+
document from disk and merges it on top of ``context``. On validation
|
|
269
|
+
failure the wizard offers to re-run while keeping every answer pre-filled.
|
|
270
|
+
|
|
271
|
+
Returns ``0`` on successful generation and ``1`` on cancellation or error.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
in_stream = reader or sys.stdin
|
|
275
|
+
out_stream = writer or sys.stdout
|
|
276
|
+
|
|
277
|
+
reg = registry or default_registry
|
|
278
|
+
try:
|
|
279
|
+
reg.get(provider_name)
|
|
280
|
+
except ValueError as exc:
|
|
281
|
+
out_stream.write(f"Error: {exc}\n")
|
|
282
|
+
return 1
|
|
283
|
+
|
|
284
|
+
steps: Sequence[ProviderStep] = describe_provider(provider_name, registry=reg)
|
|
285
|
+
if not steps:
|
|
286
|
+
out_stream.write(
|
|
287
|
+
f"Provider '{provider_name}' does not define declarative wizard steps.\n"
|
|
288
|
+
)
|
|
289
|
+
return 1
|
|
290
|
+
|
|
291
|
+
working: Dict[str, Any] = dict(context) if context else {}
|
|
292
|
+
if input_path:
|
|
293
|
+
try:
|
|
294
|
+
loaded = formats.load_file(input_path)
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
out_stream.write(f"[warn] Could not load initial context: {exc}\n")
|
|
297
|
+
else:
|
|
298
|
+
if isinstance(loaded, dict):
|
|
299
|
+
working.update(loaded)
|
|
300
|
+
out_stream.write(f"[info] Pre-populated wizard with values from {input_path}\n")
|
|
301
|
+
else:
|
|
302
|
+
out_stream.write(
|
|
303
|
+
f"[warn] {input_path} does not contain a mapping; ignoring it.\n"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
out_stream.write("========================================================\n")
|
|
307
|
+
out_stream.write(f" DevConfig-Gen Interactive Wizard: '{provider_name}'\n")
|
|
308
|
+
out_stream.write(" Answer the prompts below. Press Enter to use defaults.\n")
|
|
309
|
+
out_stream.write("========================================================\n")
|
|
310
|
+
|
|
311
|
+
total_steps = len(steps)
|
|
312
|
+
while True:
|
|
313
|
+
try:
|
|
314
|
+
for step_idx, step in enumerate(steps, 1):
|
|
315
|
+
out_stream.write(f"\n--- [{step_idx}/{total_steps}] {step.title} ---\n")
|
|
316
|
+
if step.description:
|
|
317
|
+
out_stream.write(f" {step.description}\n")
|
|
318
|
+
for field in step.fields:
|
|
319
|
+
existing = _get_dotted_path(working, field.name)
|
|
320
|
+
value = prompt_field(field, existing, in_stream, out_stream)
|
|
321
|
+
if value is not None:
|
|
322
|
+
_set_dotted_path(working, field.name, value)
|
|
323
|
+
except _EndOfInput as exc:
|
|
324
|
+
out_stream.write(
|
|
325
|
+
f"\n[!] Input ended before '{exc.field_name}' was provided. Aborting.\n"
|
|
326
|
+
)
|
|
327
|
+
return 1
|
|
328
|
+
|
|
329
|
+
out_stream.write("\nValidating configuration...\n")
|
|
330
|
+
diagnostics = diagnose_request(provider_name, context=working, registry=reg)
|
|
331
|
+
errors = [item for item in diagnostics if item.severity == "error"]
|
|
332
|
+
if not errors:
|
|
333
|
+
break
|
|
334
|
+
|
|
335
|
+
out_stream.write("\n[!] Validation found errors:\n")
|
|
336
|
+
for err in errors:
|
|
337
|
+
out_stream.write(f" - {err.field}: {err.message}\n")
|
|
338
|
+
|
|
339
|
+
answer = _read_line(
|
|
340
|
+
in_stream,
|
|
341
|
+
out_stream,
|
|
342
|
+
"Re-run the wizard to correct these? [Y/n]: ",
|
|
343
|
+
)
|
|
344
|
+
if answer is None or answer.lower() in ("n", "no"):
|
|
345
|
+
out_stream.write("Aborted.\n")
|
|
346
|
+
return 1
|
|
347
|
+
out_stream.write("\nRe-running wizard with your previous answers pre-filled...\n")
|
|
348
|
+
|
|
349
|
+
out_stream.write("[✓] All validations passed!\n\n")
|
|
350
|
+
|
|
351
|
+
selected_format = output_format
|
|
352
|
+
if not selected_format:
|
|
353
|
+
answer = _read_line(
|
|
354
|
+
in_stream,
|
|
355
|
+
out_stream,
|
|
356
|
+
"Select output format (1: YAML [default], 2: JSON): ",
|
|
357
|
+
)
|
|
358
|
+
selected_format = "json" if answer == "2" else "yaml"
|
|
359
|
+
|
|
360
|
+
target_dir = output_dir or "."
|
|
361
|
+
out_stream.write(f"Writing configuration to '{target_dir}'...\n")
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
result = generate(
|
|
365
|
+
provider_name,
|
|
366
|
+
GenerationRequest(context=working, options={"format": selected_format}),
|
|
367
|
+
registry=reg,
|
|
368
|
+
output_dir=target_dir,
|
|
369
|
+
)
|
|
370
|
+
except Exception as exc:
|
|
371
|
+
out_stream.write(f"[!] Generation failed: {exc}\n")
|
|
372
|
+
return 1
|
|
373
|
+
|
|
374
|
+
for artifact in result.artifacts:
|
|
375
|
+
out_path = Path(target_dir) / artifact.name
|
|
376
|
+
out_stream.write(f"[✓] Generated artifact: {out_path.resolve()}\n")
|
|
377
|
+
return 0
|