ctx_cli 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +22 -0
- data/README.md +80 -0
- data/docs/migration.md +13 -0
- data/examples/configuration.rb +16 -0
- data/examples/offline.rb +16 -0
- data/lib/ctx_cli/application.rb +28 -0
- data/lib/ctx_cli/collection.rb +116 -0
- data/lib/ctx_cli/command.rb +42 -0
- data/lib/ctx_cli/configuration.rb +378 -0
- data/lib/ctx_cli/initialization.rb +71 -0
- data/lib/ctx_cli/input.rb +10 -0
- data/lib/ctx_cli/options.rb +38 -0
- data/lib/ctx_cli/output.rb +22 -0
- data/lib/ctx_cli/terminal.rb +39 -0
- data/lib/ctx_cli/version.rb +5 -0
- data/lib/ctx_cli.rb +12 -0
- metadata +166 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 367e5a47fb0f653f5ef833f8efee765606717ae2c97aa6025f6116032ba1edbf
|
|
4
|
+
data.tar.gz: b87ac1112712391f7fd1eac7fc66fd4e93e6af6dc5ceb3d08508872f0c55345c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 948f143970bc4d5a0af50470e6c35a1bdfe98c0eb63d6d3efa18531c7578f6a00ea3da4da2545091433416b119e715f69bd739c745ca763f08acf2432a0a51d1
|
|
7
|
+
data.tar.gz: a3f788a3d3bd2e946c484594747c01912d28455a3b771f5acc7d8208cacca5d838103003fa67383bc3139afceb84e1f1555cd86981d22f355d239dffd8a8e691
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.0 (2026-10-22)
|
|
6
|
+
|
|
7
|
+
- Extract reusable configuration discovery, private initialization, command dispatch, option groups, terminal interaction, input/output and collection entrypoint orchestration.
|
|
8
|
+
- Integrate existing VXLAN, Wireless and net-connector backup/topology entrypoints; keep baseline integration limited to the existing coverage script.
|
|
9
|
+
- Preserve Ruby 3.4 compatibility and verify standalone package installation without application or device dependencies.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Song Huang
|
|
4
|
+
Copyright (c) 2026 Gatework contributors
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# ctx_cli
|
|
2
|
+
|
|
3
|
+
可组合的 Ruby 命令行基础库,用于减少网络自动化模块的入口样板代码。它处理配置、终端和入口流程,领域模块继续拥有厂商语法、业务校验、清单选择及设备能力。
|
|
4
|
+
|
|
5
|
+
## 公共能力
|
|
6
|
+
|
|
7
|
+
| 对象 | 职责 |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `Application` | 惰性子命令分发、全局帮助/版本、返回退出码 |
|
|
10
|
+
| `Command` / `Options` | 每次独立的参数快照、信息分支、公共 OptionParser 分组 |
|
|
11
|
+
| `Configuration` | 多层文件发现、隔离环境、来源路径、纯数据快照 |
|
|
12
|
+
| `Initialization` | 私有文件排他创建、同步、保留已有配置 |
|
|
13
|
+
| `Terminal` | 真实终端检查、问答、隐藏密码、完整 token 确认 |
|
|
14
|
+
| `Input` / `Output` | 文件/stdin、显式 JSON、仅含错误类型的诊断 |
|
|
15
|
+
| `Collection` | 配置、预览、全批预检、调度交付及退出码映射 |
|
|
16
|
+
|
|
17
|
+
Ruby >= 3.4。`require "ctx_cli"` 或按能力加载 `ctx_cli/configuration` 等路径;加载不读取用户文件、不修改 ENV、不建立网络连接。核心不依赖 ctx_parser、ctx_runner 或 net-connector。代码和工程规范参照 ctx_parser,保留 Ruby 3.4 是为了兼容现有 backup 宿主。
|
|
18
|
+
|
|
19
|
+
## 少写入口代码
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "ctx_cli"
|
|
23
|
+
|
|
24
|
+
command = CtxCli::Command.new(banner: "audit [选项]", defaults: { complete: false }) do |parser, values|
|
|
25
|
+
parser.on("--file FILE") { |path| values[:file] = path }
|
|
26
|
+
parser.on("--complete") { values[:complete] = true }
|
|
27
|
+
CtxCli::Options.information(parser, values)
|
|
28
|
+
end
|
|
29
|
+
values = command.parse(ARGV)
|
|
30
|
+
unless command.informational?(examples: "audit --file running.cfg --complete")
|
|
31
|
+
body = CtxCli::Input.read(path: values[:file])
|
|
32
|
+
# 调用自己的领域 API;完整采集与业务健康由领域分别判断。
|
|
33
|
+
CtxCli::Output.new.json({ bytes: body.bytesize, complete: values[:complete] })
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
库方法不调用 exit。宿主自己决定参数错误、部分结果、取消对应的退出码。`Command#parse` 不接受位置参数,每次重建默认值和 OptionParser;配置块必须将本次值写入块参数,不缓存上次的值容器。特殊参数直接用 OptionParser,不要求继承或注册 DSL。
|
|
38
|
+
|
|
39
|
+
`Options.configuration(parser) { |key, value| ... }` 登记 config/env_file/parallel/directory;`parallel_help:` 可说明宿主自己的并发范围、默认值与环境变量。`Options.initialization`、`output`、`information` 接收当前标记 Hash。值转换、公开字段允许列表、层级优先级和领域校验属于宿主。
|
|
40
|
+
|
|
41
|
+
## 配置与初始化
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
files = CtxCli::Configuration.new(
|
|
45
|
+
name: "audit", namespace: "network-tools", names: %w[audit backup],
|
|
46
|
+
common_keys: %w[ssh inventory], config_env: "AUDIT_CONFIG",
|
|
47
|
+
path_fields: %w[directory ssh.known_hosts], env_path_keys: %w[AUDIT_DIRECTORY]
|
|
48
|
+
).load
|
|
49
|
+
|
|
50
|
+
files.data # 深冻结的 YAML 合并结果
|
|
51
|
+
files.environment # .env 加原进程环境;不修改 ENV
|
|
52
|
+
files.sources # 路径与字段来源,不包含环境值
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
默认依次搜索 `/etc/<namespace>`、`/usr/local/etc/<namespace>`、`$XDG_CONFIG_HOME/<namespace>`(否则 `~/.config/<namespace>`)、入口目录和启动目录。每层读取 `<namespace>.yml` 与 `<name>.yml`,显式文件最后。共享文件按 `common` 和工具名称分区;name 与 namespace 相同时使用普通独立文件。`directories:` 可以完整替换搜索范围。
|
|
56
|
+
|
|
57
|
+
宿主把各层公共环境值转换成领域字段后,通过 `Configuration.merge(defaults, dotenv_values, yaml, process_values, cli)` 实现 CLI > 进程环境 > YAML > .env > 默认值。数组整层替换,false/nil 保留。相对路径按来源目录解释;支持 `path_literals:`(如 `system`)和 URI。`load` 重复调用不重新读文件。
|
|
58
|
+
|
|
59
|
+
`Initialization#create(path:, contents:)` 返回 `:created` 或 `:exists`。文件先写完并同步,再排他发布并同步目录;既有普通文件保留,符号链接/目录拒绝。发布后同步失败仍抛错,不能当作成功;不把凭据自动写入模板。例子见 [examples/configuration.rb](examples/configuration.rb)。
|
|
60
|
+
|
|
61
|
+
## 采集协议
|
|
62
|
+
|
|
63
|
+
`Collection.new(adapter:, options:, progress:, batch:, label:, report_error:, failure_code:, out:, error:, input:)` 返回一个可运行入口。options/progress 为工厂 callable;batch 是实现 `new(report:, progress:, requested:)`、`collect(device) { snapshot }` 与 `run(present:) { operation }` 的协作者,当前使用 `CtxRunner::Batch`。Collection 不加载 Runner,也不重复实现报告或取消状态机。
|
|
64
|
+
|
|
65
|
+
adapter 实现 `inventory(options, progress:) -> [targets, total]`、`prepare(targets, options) -> context`、`report(options)`、`execute(targets, options) { |target| ... }`、`collect(target, options, context:, progress:) -> snapshot`、`after_collect(report, options)`。execute 必须在返回前回收线程。预览在凭据和设备预检之前,所有预检在报告创建与连接之前。`run` 返回 0 完整、1 未完成/无目标、2 错误、130 取消。
|
|
66
|
+
|
|
67
|
+
Options 协作者沿用现有入口协议:`informational?`、`validate_interaction!`、`load_environment`、`initialize_files`、`missing?`、`load`、`config`、`flags`、`values`、`sources`、`public_values`。厂商命令、凭据映射、AP 租约、Fabric 检查、backup 成功策略保留在消费模块。详见 [迁移边界](docs/migration.md)。
|
|
68
|
+
|
|
69
|
+
## 开发与验证
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
bundle install
|
|
73
|
+
bundle exec rake
|
|
74
|
+
bundle exec rake build
|
|
75
|
+
ruby script/verify_package.rb pkg/ctx_cli-0.1.0.gem
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
默认任务包括 RSpec、RuboCop 和离线示例。安装验证在独立 GEM_HOME/GEM_PATH 下运行普通与最小 Bundler 应用,检查实际加载来源。CI 参照 ctx_parser 的固定 action 提交和候选制品校验;发布需另行明确授权。
|
|
79
|
+
|
|
80
|
+
消费仓库可用 `CTX_CLI_PATH=../ctx_cli bundle exec rake` 显式联调;普通入口须先安装候选包。不隐式探测相邻目录。baseline 只在 Gemfile 中为现有 coverage 脚本引入开发依赖,没有新增 baseline 命令或核心依赖。
|
data/docs/migration.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# 消费模块接入边界
|
|
2
|
+
|
|
3
|
+
| 消费者 | 移交给 ctx_cli | 保留职责 |
|
|
4
|
+
| --- | --- | --- |
|
|
5
|
+
| net-connector Config | YAML/.env 发现、来源路径、数据快照 | net-connector.yml 分区、默认目录与环境变量 |
|
|
6
|
+
| backup/TFTP/topology | 公共参数组、终端检查/密码、配置排他发布 | Planner、备份回执、TFTP 核验、归档与成功策略 |
|
|
7
|
+
| VXLAN | 分发、公共参数、初始化、终端确认、离线输入输出、采集入口 | NX-OS 命令、Fabric、计划项/哈希验证、凭据及操作租约 |
|
|
8
|
+
| Wireless | 同上 | 平台、AP/LLDP、计划格式、凭据与 AP 会话 |
|
|
9
|
+
| baseline | 现有 script/coverage.rb 参数和输出边界 | 能力矩阵生成、--check、领域 API 与所有离线示例 |
|
|
10
|
+
|
|
11
|
+
ctx_runner 继续拥有采集/报告/执行的业务状态,net-connector Inventory 继续拥有筛选;ctx_cli 不反向加载二者。普通 Config 旧入口保留为宿主政策适配,无第二份配置发现实现。
|
|
12
|
+
|
|
13
|
+
ctx_cli 初始版本为 0.1.0。首次发布依赖顺序先 ctx_cli,再 net-connector 与 CLI 消费模块;ctx_runner 的依赖链仍按其原有约定。提交、远程创建和发布按用户明确授权的范围执行。
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ctx_cli"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
# 自定义宿主命名空间;示例只操作临时目录,不读取真实用户配置。
|
|
7
|
+
Dir.mktmpdir do |root|
|
|
8
|
+
path = File.join(root, "audit.yml")
|
|
9
|
+
publisher = CtxCli::Initialization.new
|
|
10
|
+
abort "Initialization failed" unless publisher.create(path: path, contents: "directory: reports\nparallel: 2\n") == :created
|
|
11
|
+
files = CtxCli::Configuration.new(name: "audit", env: {}, directories: [ root ], path_fields: [ "directory" ]).load
|
|
12
|
+
effective = CtxCli::Configuration.merge({ "parallel" => 1 }, files.data, { "parallel" => 4 })
|
|
13
|
+
abort "Unexpected precedence" unless effective == { "directory" => File.join(root, "reports"), "parallel" => 4 }
|
|
14
|
+
abort "Existing configuration overwritten" unless publisher.create(path: path, contents: "invalid") == :exists
|
|
15
|
+
end
|
|
16
|
+
puts "Configuration discovery and private initialization verified"
|
data/examples/offline.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ctx_cli"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
# 领域只声明自己的参数和动作;标准流始终由宿主拥有。
|
|
7
|
+
out = StringIO.new
|
|
8
|
+
command = CtxCli::Command.new(banner: "sample [--complete]", defaults: { complete: false }, out: out) do |parser, values|
|
|
9
|
+
parser.on("--complete", "声明输入采集完整") { values[:complete] = true }
|
|
10
|
+
CtxCli::Options.information(parser, values)
|
|
11
|
+
end
|
|
12
|
+
values = command.parse([ "--complete" ])
|
|
13
|
+
body = CtxCli::Input.read(input: StringIO.new("synthetic evidence"))
|
|
14
|
+
CtxCli::Output.new(out: out).json({ complete: values[:complete], bytes: body.bytesize })
|
|
15
|
+
abort "Unexpected offline output" unless JSON.parse(out.string) == { "complete" => true, "bytes" => 18 }
|
|
16
|
+
puts "Offline command boundary verified"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CtxCli
|
|
4
|
+
# 命令工厂按需执行;帮助和版本不加载领域实现或配置。
|
|
5
|
+
class Application
|
|
6
|
+
def initialize(name:, version:, usage:, commands:, out: $stdout, error: $stderr)
|
|
7
|
+
@name, @version, @usage, @commands = name, version, usage, commands.dup.freeze
|
|
8
|
+
@out, @error = out, error
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# 退出行为归调用方;保留 argv 并只返回命令退出码。
|
|
12
|
+
def run(argv)
|
|
13
|
+
arguments = argv.dup
|
|
14
|
+
command = arguments.shift
|
|
15
|
+
if [ nil, "--help", "--help-all", "-h" ].include?(command) && arguments.empty?
|
|
16
|
+
@out.puts @usage
|
|
17
|
+
return 0
|
|
18
|
+
end
|
|
19
|
+
if %w[--version -v].include?(command) && arguments.empty?
|
|
20
|
+
@out.puts @version
|
|
21
|
+
return 0
|
|
22
|
+
end
|
|
23
|
+
return @commands.fetch(command).call(arguments) if @commands.key?(command)
|
|
24
|
+
@error.puts "未知命令或多余参数;使用 #{@name} --help 查看用法"
|
|
25
|
+
2
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "output"
|
|
5
|
+
|
|
6
|
+
module CtxCli
|
|
7
|
+
# 终端采集入口的固定顺序;领域适配器拥有清单、预检、调度及采集策略。
|
|
8
|
+
class Collection
|
|
9
|
+
def initialize(adapter:, options:, progress:, batch:, label:, report_error:, failure_code:,
|
|
10
|
+
out: $stdout, error: $stderr, input: $stdin)
|
|
11
|
+
@adapter, @options_factory, @progress_factory, @batch_class = adapter, options, progress, batch
|
|
12
|
+
@label, @report_error, @failure_code = label, report_error, failure_code
|
|
13
|
+
@out, @error, @input = out, error, input
|
|
14
|
+
@output = Output.new(out: out, error: error)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# 先预检全部输入和凭据,再创建报告和连接;失败分类不打印原始异常正文。
|
|
18
|
+
def run(argv)
|
|
19
|
+
# 每次运行独立持有批次状态;预检失败不能改写上一次报告。
|
|
20
|
+
@options, @store, @progress, @devices, @batch = nil, nil, nil, [], nil
|
|
21
|
+
@options = @options_factory.call(argv)
|
|
22
|
+
return 0 if @options.informational?
|
|
23
|
+
@options.validate_interaction!(input: @input, error: @error)
|
|
24
|
+
@options.load_environment
|
|
25
|
+
if @options.flags[:init]
|
|
26
|
+
@options.initialize_files
|
|
27
|
+
return 0
|
|
28
|
+
end
|
|
29
|
+
return 0 if @options.missing?(input: @input, error: @error)
|
|
30
|
+
@options.load
|
|
31
|
+
@progress = @progress_factory.call(@options)
|
|
32
|
+
@progress.notice("配置:#{@options.config}")
|
|
33
|
+
return output(@options.public_values.merge("sources" => @options.sources)) if @options.flags[:show_config]
|
|
34
|
+
@devices, @inventory_total = @adapter.inventory(@options, progress: @progress)
|
|
35
|
+
devices = @devices
|
|
36
|
+
return output(devices) if @options.flags[:inventory_only]
|
|
37
|
+
if devices.empty?
|
|
38
|
+
@progress.notice("没有匹配设备,未建立连接")
|
|
39
|
+
@output.json({ status: :no_devices, total: 0 }, pretty: false) if @options.flags[:json]
|
|
40
|
+
return 1
|
|
41
|
+
end
|
|
42
|
+
context = @adapter.prepare(devices, @options)
|
|
43
|
+
@store = @adapter.report(@options)
|
|
44
|
+
@batch = @batch_class.new(report: @store, progress: @progress, requested: devices)
|
|
45
|
+
outcome = @batch.run(present: ->(summary) { @output.json(summary) if @options.flags[:json] }) do
|
|
46
|
+
@progress.plan(devices: devices, total: @inventory_total, parallel: @options.values.fetch("parallel"), label: "#{@label} 只读采集")
|
|
47
|
+
@progress.with_updates do
|
|
48
|
+
@adapter.execute(devices, @options) do |device|
|
|
49
|
+
@batch.collect(device) { @adapter.collect(device, @options, context: context, progress: @progress) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
@adapter.after_collect(@store, @options)
|
|
53
|
+
end
|
|
54
|
+
completion(outcome)
|
|
55
|
+
rescue Interrupt
|
|
56
|
+
diagnostic "采集已取消,已保存的设备报告保留;未保存结果见报告状态"
|
|
57
|
+
130
|
|
58
|
+
rescue SystemCallError, IOError => error
|
|
59
|
+
diagnostic "#{@label} 参数或报告错误:#{report_diagnostic(error)}"
|
|
60
|
+
2
|
|
61
|
+
rescue OptionParser::ParseError, ArgumentError, LoadError => error
|
|
62
|
+
diagnostic "#{@label} 参数、依赖或报告错误:#{error.message};使用 --help 查看参数"
|
|
63
|
+
2
|
|
64
|
+
rescue StandardError => error
|
|
65
|
+
diagnostic "#{@label} 清单或执行失败:#{@failure_code.call(error)}(#{error.class.name})"
|
|
66
|
+
2
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def inspect = "#<#{self.class.name}>"
|
|
70
|
+
alias_method :to_s, :inspect
|
|
71
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Runner 决定批次结论;CLI 仅呈现安全诊断并映射退出码。
|
|
76
|
+
def completion(outcome)
|
|
77
|
+
if outcome.finalization_error
|
|
78
|
+
diagnostic "#{@label} 摘要报告错误:#{outcome.finalization_error}"
|
|
79
|
+
end
|
|
80
|
+
if outcome.recovery_failed
|
|
81
|
+
diagnostic "摘要恢复未确认;请依据退出码及报告目录检查,不能将旧摘要视为本次成功。"
|
|
82
|
+
end
|
|
83
|
+
if outcome.status == :interrupted
|
|
84
|
+
diagnostic "采集已取消,已保存的设备报告保留;未保存结果见报告状态"
|
|
85
|
+
elsif (failure = outcome.failure)
|
|
86
|
+
if failure[:kind] == :report
|
|
87
|
+
diagnostic "#{@label} 参数或报告错误:#{failure[:detail]}"
|
|
88
|
+
else
|
|
89
|
+
diagnostic "#{@label} 清单或执行失败:#{failure[:error_type]};已采集结果见批次报告"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
{ complete: 0, incomplete: 1, failed: 2, interrupted: 130 }.fetch(outcome.status)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# 错误提示失败不能覆盖取消或执行退出码;已失效的进度流不再重试。
|
|
96
|
+
def diagnostic(message)
|
|
97
|
+
@output.diagnostic(message) unless @progress&.output_error
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# 自有发布错误消息不含正文;外部文件异常只显示类型,不打印 message/cause。
|
|
101
|
+
def report_diagnostic(error)
|
|
102
|
+
error.instance_of?(@report_error) ? error.message : error.class.name
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# 预览默认显示逐设备身份;公开配置由显式开关展示。
|
|
106
|
+
def output(value)
|
|
107
|
+
if @options.flags[:json] || value.is_a?(Hash)
|
|
108
|
+
@output.json(value)
|
|
109
|
+
else
|
|
110
|
+
value.each { |device| @out.puts "#{device.fetch('host')}\t#{device.fetch('name')}\t#{device['model']}" }
|
|
111
|
+
@out.puts "设备:#{value.size} 台;未建立设备连接"
|
|
112
|
+
end
|
|
113
|
+
value.is_a?(Array) && value.empty? ? 1 : 0
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "configuration"
|
|
5
|
+
|
|
6
|
+
module CtxCli
|
|
7
|
+
# 每次解析新建值与解析器;领域参数通过普通 OptionParser 块扩展。
|
|
8
|
+
class Command
|
|
9
|
+
attr_reader :values, :parser
|
|
10
|
+
|
|
11
|
+
def initialize(banner:, defaults: {}, out: $stdout, &configure)
|
|
12
|
+
@banner, @defaults, @out, @configure = banner, Configuration.copy(defaults), out, configure
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def parse(argv)
|
|
16
|
+
@values = Configuration.copy(@defaults)
|
|
17
|
+
@parser = OptionParser.new
|
|
18
|
+
@parser.banner = @banner
|
|
19
|
+
@configure&.call(@parser, @values)
|
|
20
|
+
rest = @parser.parse(argv.dup)
|
|
21
|
+
raise ArgumentError, "不接受位置参数" unless rest.empty?
|
|
22
|
+
@values
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# 信息分支只写指定输出流,调用方在此后才读取输入或配置。
|
|
26
|
+
def informational?(examples: nil, details: nil)
|
|
27
|
+
if values[:help] || values[:help_all]
|
|
28
|
+
@out.puts parser
|
|
29
|
+
@out.puts details if values[:help_all] && details
|
|
30
|
+
elsif values[:examples] && examples
|
|
31
|
+
@out.puts examples
|
|
32
|
+
else
|
|
33
|
+
return false
|
|
34
|
+
end
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def inspect = "#<#{self.class.name}>"
|
|
39
|
+
alias_method :to_s, :inspect
|
|
40
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module CtxCli
|
|
6
|
+
# 宿主配置发现;加载文件不读取配置或建立设备连接。
|
|
7
|
+
class Configuration
|
|
8
|
+
attr_reader :data, :specific, :process_environment, :file_environment, :environment,
|
|
9
|
+
:config_files, :environment_files, :sources
|
|
10
|
+
|
|
11
|
+
def initialize(name:, env: ENV, script: $PROGRAM_NAME, cwd: Dir.pwd, home: Dir.home,
|
|
12
|
+
directories: nil, config: nil, env_file: nil, config_env: nil,
|
|
13
|
+
path_fields: [], env_path_keys: [], path_literals: {},
|
|
14
|
+
namespace: name, names: [ name.to_s ], common_keys: [])
|
|
15
|
+
@name = name.to_s.dup.freeze
|
|
16
|
+
@namespace = namespace.to_s.dup.freeze
|
|
17
|
+
@names = self.class.freeze_tree(self.class.copy(names))
|
|
18
|
+
@common_keys = self.class.freeze_tree(self.class.copy(common_keys))
|
|
19
|
+
raise ArgumentError, "unsupported configuration name: #{@name}" unless @names.include?(@name)
|
|
20
|
+
|
|
21
|
+
@cwd = File.expand_path(cwd)
|
|
22
|
+
@home = File.expand_path(home)
|
|
23
|
+
@config_env = (config_env || "#{@name.upcase}_CONFIG").to_s.dup.freeze
|
|
24
|
+
@env_path_keys = (env_path_keys.map { |key| key.to_s.dup.freeze } + [ @config_env ]).uniq.freeze
|
|
25
|
+
@path_literals = self.class.freeze_tree(self.class.copy(path_literals.transform_keys(&:to_s)))
|
|
26
|
+
@process_environment = self.class.copy(env.to_h)
|
|
27
|
+
resolve_environment_paths!(@process_environment, @cwd)
|
|
28
|
+
self.class.freeze_tree(@process_environment)
|
|
29
|
+
@user_directory = File.join(user_config_home, @namespace)
|
|
30
|
+
@directories = (directories || [ "/etc/#{@namespace}", "/usr/local/etc/#{@namespace}", @user_directory,
|
|
31
|
+
File.dirname(File.expand_path(script, @cwd)), @cwd ]).map do |path|
|
|
32
|
+
File.expand_path(path, @cwd)
|
|
33
|
+
end.uniq
|
|
34
|
+
@explicit_config = absolute(config, @cwd)
|
|
35
|
+
@explicit_env_file = absolute(env_file, @cwd)
|
|
36
|
+
@path_fields = path_fields.map { |path| path.to_s.split(".") }
|
|
37
|
+
@data = @specific = @file_environment = @environment = {}.freeze
|
|
38
|
+
@config_files = @environment_files = [].freeze
|
|
39
|
+
@sources = { "configuration_files" => @config_files, "environment_files" => @environment_files, "fields" => {}.freeze }.freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# 环境文件只形成私有快照,不修改 ENV,不读取 YAML。
|
|
43
|
+
def load_environment(allow_missing: false)
|
|
44
|
+
return self if @environment_loaded
|
|
45
|
+
|
|
46
|
+
files = candidates(".env", @explicit_env_file)
|
|
47
|
+
values = {}
|
|
48
|
+
loaded = []
|
|
49
|
+
files.each do |path|
|
|
50
|
+
next unless readable_file?(path, explicit: path == @explicit_env_file, allow_missing: allow_missing)
|
|
51
|
+
|
|
52
|
+
parsed = parse_environment(path, values.merge(@process_environment))
|
|
53
|
+
resolve_environment_paths!(parsed, File.dirname(path))
|
|
54
|
+
values.merge!(parsed)
|
|
55
|
+
loaded << path
|
|
56
|
+
end
|
|
57
|
+
@file_environment = self.class.freeze_tree(values)
|
|
58
|
+
@environment = self.class.freeze_tree(values.merge(self.class.copy(@process_environment)))
|
|
59
|
+
@environment_files = self.class.freeze_tree(loaded)
|
|
60
|
+
# 空的选择变量视为缺失,包括进程中显式的空值。
|
|
61
|
+
selector = present(@process_environment[@config_env]) || present(values[@config_env])
|
|
62
|
+
@selected_config = @explicit_config || absolute(selector, @cwd)
|
|
63
|
+
@environment_loaded = true
|
|
64
|
+
update_sources({})
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# 每批固定一份快照;重复调用不重新读取文件。
|
|
69
|
+
def load(allow_missing: false)
|
|
70
|
+
return self if @loaded
|
|
71
|
+
|
|
72
|
+
load_environment(allow_missing: allow_missing)
|
|
73
|
+
merged = {}
|
|
74
|
+
specific = {}
|
|
75
|
+
fields = {}
|
|
76
|
+
loaded = []
|
|
77
|
+
@applicable = false
|
|
78
|
+
paths = @directories.flat_map { |directory| [ File.join(directory, "#{@namespace}.yml"), File.join(directory, "#{@name}.yml") ] }
|
|
79
|
+
paths = final_candidates(paths, @selected_config)
|
|
80
|
+
paths.each do |path|
|
|
81
|
+
next unless readable_file?(path, explicit: path == @selected_config, allow_missing: allow_missing)
|
|
82
|
+
|
|
83
|
+
document = read_yaml(path)
|
|
84
|
+
if @namespace != @name && File.basename(path) == "#{@namespace}.yml"
|
|
85
|
+
common, native, applicable = shared_sections(document, path)
|
|
86
|
+
merged = merge_layer(merged, common, path, fields)
|
|
87
|
+
@applicable ||= applicable
|
|
88
|
+
else
|
|
89
|
+
native = document
|
|
90
|
+
validate_string_keys!(native, path)
|
|
91
|
+
@applicable = true
|
|
92
|
+
end
|
|
93
|
+
merged = merge_layer(merged, native, path, fields)
|
|
94
|
+
specific = self.class.merge(specific, resolve_paths(native, path))
|
|
95
|
+
loaded << path
|
|
96
|
+
end
|
|
97
|
+
@data = self.class.freeze_tree(merged)
|
|
98
|
+
@specific = self.class.freeze_tree(specific)
|
|
99
|
+
@config_files = self.class.freeze_tree(loaded)
|
|
100
|
+
update_sources(fields)
|
|
101
|
+
@loaded = true
|
|
102
|
+
self
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def config
|
|
106
|
+
@selected_config || @explicit_config || @config_files.last || init_config
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def env_file
|
|
110
|
+
@explicit_env_file || @environment_files.last || init_env_file
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def init_config
|
|
114
|
+
@selected_config || @explicit_config || File.join(@user_directory, "#{@name}.yml")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def init_env_file
|
|
118
|
+
@explicit_env_file || File.join(@user_directory, ".env")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def missing?
|
|
122
|
+
!@applicable
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# 序列化规则与目标文件的发现规则一致。
|
|
126
|
+
def self.dump(document, name:, path:, namespace: name)
|
|
127
|
+
YAML.dump(File.basename(path) == "#{namespace}.yml" && namespace.to_s != name.to_s ? { name.to_s => document } : document)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# 默认展示仅包含类型,公开来源通过 sources 显式读取。
|
|
131
|
+
def inspect = "#<#{self.class.name}>"
|
|
132
|
+
alias_method :to_s, :inspect
|
|
133
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
134
|
+
|
|
135
|
+
# 递归合并但不修改来源;数组、false、nil 按值替换。
|
|
136
|
+
def self.merge(*hashes)
|
|
137
|
+
hashes.reduce({}) do |result, hash|
|
|
138
|
+
hash.each do |key, value|
|
|
139
|
+
result[copy(key)] = result[key].is_a?(Hash) && value.is_a?(Hash) ? merge(result[key], value) : copy(value)
|
|
140
|
+
end
|
|
141
|
+
result
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# 只复制普通数据;循环或不透明对象不能进入快照并冻结调用方资源。
|
|
146
|
+
def self.copy(value, active = {}.compare_by_identity)
|
|
147
|
+
case value
|
|
148
|
+
when Hash, Array
|
|
149
|
+
raise ArgumentError, "cyclic configuration value" if active.key?(value)
|
|
150
|
+
active[value] = true
|
|
151
|
+
begin
|
|
152
|
+
if value.is_a?(Hash)
|
|
153
|
+
value.to_h { |key, entry| [ copy(key, active), copy(entry, active) ] }
|
|
154
|
+
else
|
|
155
|
+
value.map { |entry| copy(entry, active) }
|
|
156
|
+
end
|
|
157
|
+
ensure
|
|
158
|
+
active.delete(value)
|
|
159
|
+
end
|
|
160
|
+
when String then String.new(value)
|
|
161
|
+
when Integer, Float, Symbol, NilClass, TrueClass, FalseClass then value
|
|
162
|
+
else raise ArgumentError, "configuration accepts plain data only"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def self.freeze_tree(value)
|
|
167
|
+
case value
|
|
168
|
+
when Hash then value.each { |key, entry| freeze_tree(key); freeze_tree(entry) }
|
|
169
|
+
when Array then value.each { |entry| freeze_tree(entry) }
|
|
170
|
+
end
|
|
171
|
+
value.freeze
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def present(value)
|
|
177
|
+
value unless value.nil? || value.to_s.strip.empty?
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def absolute(path, directory)
|
|
181
|
+
File.expand_path(path.to_s, directory) if present(path)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def user_config_home
|
|
185
|
+
absolute(present(@process_environment["XDG_CONFIG_HOME"]), @cwd) || File.join(@home, ".config")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def candidates(filename, explicit)
|
|
189
|
+
final_candidates(@directories.map { |directory| File.join(directory, filename) }, explicit)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def final_candidates(paths, explicit)
|
|
193
|
+
paths = paths.reject { |path| path == explicit } + [ explicit ] if explicit
|
|
194
|
+
paths.uniq
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def readable_file?(path, explicit:, allow_missing:)
|
|
198
|
+
unless File.exist?(path)
|
|
199
|
+
raise ArgumentError, "configuration file does not exist: #{path}" if explicit && !allow_missing
|
|
200
|
+
|
|
201
|
+
return false
|
|
202
|
+
end
|
|
203
|
+
return true if File.file?(path) && File.readable?(path)
|
|
204
|
+
|
|
205
|
+
raise ArgumentError, "configuration path is not a readable regular file: #{path}"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def read_yaml(path)
|
|
209
|
+
contents = File.read(path)
|
|
210
|
+
validate_yaml_structure!(contents, path)
|
|
211
|
+
document = YAML.safe_load(contents, permitted_classes: [], permitted_symbols: [], aliases: false)
|
|
212
|
+
document = {} if document.nil?
|
|
213
|
+
raise ArgumentError, "configuration must contain a mapping: #{path}" unless document.is_a?(Hash)
|
|
214
|
+
|
|
215
|
+
document
|
|
216
|
+
rescue Psych::Exception, SystemCallError, IOError => error
|
|
217
|
+
raise ArgumentError, "cannot read configuration file: #{path} (#{error.class})", cause: nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# 覆盖只能来自明确文件层;同文件不能隐藏重复键
|
|
221
|
+
# 或在领域校验前静默丢弃后续文档。
|
|
222
|
+
def validate_yaml_structure!(contents, path)
|
|
223
|
+
stream = YAML.parse_stream(contents)
|
|
224
|
+
raise ArgumentError, "configuration must contain a single YAML document: #{path}" if stream.children.size > 1
|
|
225
|
+
|
|
226
|
+
stream.each do |node|
|
|
227
|
+
next unless node.is_a?(Psych::Nodes::Mapping)
|
|
228
|
+
|
|
229
|
+
keys = {}
|
|
230
|
+
node.children.each_slice(2) do |key, _|
|
|
231
|
+
next unless key.is_a?(Psych::Nodes::Scalar)
|
|
232
|
+
if keys.key?(key.value)
|
|
233
|
+
raise ArgumentError, "duplicate configuration key at line #{key.start_line + 1}: #{path}"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
keys[key.value] = true
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def validate_string_keys!(value, path)
|
|
242
|
+
case value
|
|
243
|
+
when Hash
|
|
244
|
+
raise ArgumentError, "configuration keys must be strings: #{path}" unless value.keys.all?(String)
|
|
245
|
+
|
|
246
|
+
value.each_value { |entry| validate_string_keys!(entry, path) }
|
|
247
|
+
when Array
|
|
248
|
+
value.each { |entry| validate_string_keys!(entry, path) }
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def shared_sections(document, path)
|
|
253
|
+
unknown = document.keys - [ "common", *@names ]
|
|
254
|
+
raise ArgumentError, "unknown shared configuration namespaces in #{path}: #{unknown.join(", ")}" unless unknown.empty?
|
|
255
|
+
|
|
256
|
+
common = document.fetch("common", {})
|
|
257
|
+
native = document.fetch(@name, {})
|
|
258
|
+
unless common.is_a?(Hash) && native.is_a?(Hash)
|
|
259
|
+
raise ArgumentError, "common and #{@name} configuration must contain mappings: #{path}"
|
|
260
|
+
end
|
|
261
|
+
unknown_common = common.keys - @common_keys
|
|
262
|
+
unless unknown_common.empty?
|
|
263
|
+
raise ArgumentError, "unsupported common configuration fields in #{path}: #{unknown_common.join(", ")}"
|
|
264
|
+
end
|
|
265
|
+
validate_string_keys!(common, path)
|
|
266
|
+
validate_string_keys!(native, path)
|
|
267
|
+
[ common, native, document.key?(@name) ]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def resolve_paths(document, path)
|
|
271
|
+
result = self.class.copy(document)
|
|
272
|
+
@path_fields.each { |segments| resolve_path!(result, segments, File.dirname(path), segments.join(".")) }
|
|
273
|
+
result
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def resolve_path!(value, segments, directory, field)
|
|
277
|
+
return unless value.is_a?(Hash) && !segments.empty?
|
|
278
|
+
|
|
279
|
+
key, *rest = segments
|
|
280
|
+
keys = key == "*" ? value.keys : [ key ]
|
|
281
|
+
keys.each do |entry|
|
|
282
|
+
next unless value.key?(entry)
|
|
283
|
+
|
|
284
|
+
if rest.empty?
|
|
285
|
+
value[entry] = declared_path(value[entry], directory, field) if value[entry].is_a?(String) && !value[entry].empty?
|
|
286
|
+
else
|
|
287
|
+
resolve_path!(value[entry], rest, directory, field)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# 路径也可能是远程归档 URI,不能无条件转成本机路径。
|
|
293
|
+
def declared_path(value, directory, field)
|
|
294
|
+
raise ArgumentError if value.to_s.include?("\0")
|
|
295
|
+
|
|
296
|
+
return value if @path_literals.fetch(field, []).include?(value)
|
|
297
|
+
|
|
298
|
+
value.to_s.match?(%r{\A[A-Za-z][A-Za-z0-9+.-]*://}) ? value : absolute(value, directory)
|
|
299
|
+
rescue ArgumentError
|
|
300
|
+
raise ArgumentError, "invalid path #{field}", cause: nil
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def resolve_environment_paths!(values, directory)
|
|
304
|
+
@env_path_keys.each do |key|
|
|
305
|
+
values[key] = declared_path(values[key], directory, key) if present(values[key])
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def merge_layer(previous, document, path, fields)
|
|
310
|
+
resolved = resolve_paths(document, path)
|
|
311
|
+
record_fields(resolved, path, fields, previous: previous)
|
|
312
|
+
self.class.merge(previous, resolved)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def record_fields(document, source, fields, prefix = nil, previous: {})
|
|
316
|
+
document.each do |key, value|
|
|
317
|
+
name = [ prefix, key ].compact.join(".")
|
|
318
|
+
before = previous.is_a?(Hash) ? previous[key] : nil
|
|
319
|
+
next if value.is_a?(Hash) && value.empty? && before.is_a?(Hash) && !before.empty?
|
|
320
|
+
|
|
321
|
+
if value.is_a?(Hash) && !value.empty?
|
|
322
|
+
fields.delete(name)
|
|
323
|
+
record_fields(value, source, fields, name, previous: before)
|
|
324
|
+
else
|
|
325
|
+
fields.delete_if { |field, _| field == name || field.start_with?("#{name}.") }
|
|
326
|
+
fields[name] = source
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def update_sources(fields)
|
|
332
|
+
@sources = self.class.freeze_tree({ "configuration_files" => @config_files,
|
|
333
|
+
"environment_files" => @environment_files, "fields" => fields })
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# 复用 dotenv 语法,但替换只读取本次快照:
|
|
337
|
+
# 不能使用默认的进程 ENV 读取或命令替换。
|
|
338
|
+
def parse_environment(path, inherited)
|
|
339
|
+
require "dotenv"
|
|
340
|
+
process = @process_environment
|
|
341
|
+
variable_pattern = Dotenv::Substitutions::Variable.singleton_class.const_get(:VARIABLE)
|
|
342
|
+
parser_class = Class.new(Dotenv::Parser) do
|
|
343
|
+
define_singleton_method(:substitutions) do
|
|
344
|
+
[ lambda do |value, local|
|
|
345
|
+
raise ArgumentError, "command substitution is unsupported" if value.include?("$(")
|
|
346
|
+
|
|
347
|
+
value.gsub(variable_pattern) do |variable|
|
|
348
|
+
match = Regexp.last_match
|
|
349
|
+
if match[1] == "\\"
|
|
350
|
+
variable[1..]
|
|
351
|
+
elsif match[3]
|
|
352
|
+
process.fetch(match[3]) { local.fetch(match[3]) { inherited.fetch(match[3], "") } }
|
|
353
|
+
else
|
|
354
|
+
variable
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end ]
|
|
358
|
+
end
|
|
359
|
+
define_method(:expand_newlines) do |value|
|
|
360
|
+
if (@hash["DOTENV_LINEBREAK_MODE"] || inherited["DOTENV_LINEBREAK_MODE"]) == "legacy"
|
|
361
|
+
value.gsub('\\n', "\n").gsub('\\r', "\r")
|
|
362
|
+
else
|
|
363
|
+
value.gsub('\\n', "\\\\\\n").gsub('\\r', "\\\\\\r")
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
private :expand_newlines
|
|
367
|
+
end
|
|
368
|
+
contents = File.read(path)
|
|
369
|
+
remainder = contents.gsub(Dotenv::Parser::LINE, "")
|
|
370
|
+
unless remainder.lines("\n").all? { |line| line.strip.empty? || line.lstrip.start_with?("#") }
|
|
371
|
+
raise ArgumentError, "invalid environment syntax"
|
|
372
|
+
end
|
|
373
|
+
parser_class.call(contents, overwrite: true)
|
|
374
|
+
rescue Dotenv::FormatError, ArgumentError, SystemCallError, IOError => error
|
|
375
|
+
raise ArgumentError, "cannot read environment file: #{path} (#{error.class})", cause: nil
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module CtxCli
|
|
7
|
+
# 只发布已经过宿主校验的模板,不收集凭据、不覆盖已有目录项。
|
|
8
|
+
class Initialization
|
|
9
|
+
def initialize(prefix: ".ctx-cli-init-")
|
|
10
|
+
@prefix = prefix
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# 符号链接和目录不算可保留配置;用 lstat 同时拒绝悬空链接。
|
|
14
|
+
def existing?(path)
|
|
15
|
+
stat = File.lstat(path)
|
|
16
|
+
raise ArgumentError, "config path must be a regular file" unless stat.file?
|
|
17
|
+
true
|
|
18
|
+
rescue Errno::ENOENT
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# 文件先完整同步,排他链接后再同步目录;失败不返回已确认状态。
|
|
23
|
+
def create(path:, contents:)
|
|
24
|
+
raise ArgumentError, "config path must be a nonempty String" unless path.is_a?(String) && !path.empty?
|
|
25
|
+
return :exists if existing?(path)
|
|
26
|
+
FileUtils.mkdir_p(File.dirname(File.expand_path(path)), mode: 0o700)
|
|
27
|
+
directory = File.realpath(File.dirname(File.expand_path(path)))
|
|
28
|
+
destination = File.join(directory, File.basename(path))
|
|
29
|
+
file = parent = primary = nil
|
|
30
|
+
begin
|
|
31
|
+
Thread.handle_interrupt(Exception => :never) { file = Tempfile.new([ @prefix, ".tmp" ], directory) }
|
|
32
|
+
file.binmode
|
|
33
|
+
file.chmod(0o600)
|
|
34
|
+
file.write(contents)
|
|
35
|
+
file.flush
|
|
36
|
+
file.fsync
|
|
37
|
+
begin
|
|
38
|
+
File.link(file.path, destination)
|
|
39
|
+
rescue Errno::EEXIST
|
|
40
|
+
return :exists if existing?(destination)
|
|
41
|
+
raise
|
|
42
|
+
end
|
|
43
|
+
Thread.handle_interrupt(Exception => :never) { parent = File.open(directory, File::RDONLY | File::NOFOLLOW) }
|
|
44
|
+
parent.fsync
|
|
45
|
+
:created
|
|
46
|
+
rescue Exception => failure # rubocop:disable Lint/RescueException -- 清理不能覆盖取消或发布故障。
|
|
47
|
+
primary = failure
|
|
48
|
+
raise
|
|
49
|
+
ensure
|
|
50
|
+
close_files(file, parent, primary)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# 全部句柄都尝试关闭,保留原始故障;不把清理失败变为成功。
|
|
57
|
+
def close_files(file, parent, primary)
|
|
58
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
59
|
+
error = nil
|
|
60
|
+
[ -> { parent&.close }, -> { file&.close! } ].each do |close|
|
|
61
|
+
begin
|
|
62
|
+
close.call
|
|
63
|
+
rescue Exception => failure # rubocop:disable Lint/RescueException -- 两个资源均尝试关闭。
|
|
64
|
+
error ||= failure
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
raise error if error && !primary
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CtxCli
|
|
4
|
+
# 普通参数分组只登记语法;不加载配置、不执行操作、不拥有领域校验。
|
|
5
|
+
module Options
|
|
6
|
+
def self.initialization(parser, values)
|
|
7
|
+
parser.on("--init", "创建缺失配置和环境模板后退出;已有文件不覆盖") { values[:init] = true }
|
|
8
|
+
parser.on("--interactive", "缺少配置时询问是否创建;要求真实终端") { values[:interactive] = true }
|
|
9
|
+
parser.on("--non-interactive", "禁用问答;创建文件须同时指定 --init") { values[:interactive] = false }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.output(parser, values, quiet: :quiet)
|
|
13
|
+
parser.on("--json", "stdout 输出 JSON;默认显示可读摘要") { values[:json] = true }
|
|
14
|
+
parser.on("--quiet", "--no-progress", "关闭 stderr 进度,可配合 --json") { values[quiet] = true }
|
|
15
|
+
parser.on("--verbose", "显示逐台结果及安全命令事件") { values[:verbose] = true }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.information(parser, values)
|
|
19
|
+
parser.on("-h", "--help", "查看选项帮助") { values[:help] = true }
|
|
20
|
+
parser.on("--help-all", "查看全部选项、路径与环境约定") { values[:help_all] = true }
|
|
21
|
+
parser.on("--examples", "查看常用命令范例,不执行任务") { values[:examples] = true }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# 值转换留在宿主边界,允许不同入口沿用原有存储格式。
|
|
25
|
+
def self.configuration(parser, parallel_help: "设备并发数", &store)
|
|
26
|
+
parser.on("--config FILE", "显式 YAML 最后叠加于自动发现的配置") { |value| store.call(:config, value) }
|
|
27
|
+
parser.on("--env-file FILE", "显式环境文件最后叠加;进程环境优先") { |value| store.call(:env_file, value) }
|
|
28
|
+
parser.on("-p", "--parallel N", Integer, parallel_help) { |value| store.call(:parallel, value) }
|
|
29
|
+
parser.on("--directory DIR", "按设备保存报告的目录") { |value| store.call(:directory, value) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.list(value)
|
|
33
|
+
values = value.split(",", -1).map(&:strip)
|
|
34
|
+
raise ArgumentError, "列表不能有空项" if values.any?(&:empty?)
|
|
35
|
+
values
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module CtxCli
|
|
6
|
+
# 结果显式导出;诊断不展开异常正文,输出失败不能伪装结果成功。
|
|
7
|
+
class Output
|
|
8
|
+
def initialize(out: $stdout, error: $stderr)
|
|
9
|
+
@out, @error = out, error
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def text(value) = @out.puts(value)
|
|
13
|
+
def json(value, pretty: true) = @out.puts(pretty ? JSON.pretty_generate(value) : JSON.generate(value))
|
|
14
|
+
|
|
15
|
+
# 最后的错误流失败不能覆盖原本的失败或取消状态。
|
|
16
|
+
def diagnostic(message, exception: nil)
|
|
17
|
+
@error.puts(exception ? "#{message}(#{exception.class.name})" : message)
|
|
18
|
+
rescue StandardError
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CtxCli
|
|
4
|
+
# 终端只借用标准流;确认、问答和密码读取都由调用方显式发起。
|
|
5
|
+
class Terminal
|
|
6
|
+
def initialize(input: $stdin, out: $stderr)
|
|
7
|
+
@input, @out = input, out
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.tty?(*streams) = streams.all? { |stream| stream.respond_to?(:tty?) && stream.tty? }
|
|
11
|
+
def tty?(*others) = self.class.tty?(@input, @out, *others)
|
|
12
|
+
|
|
13
|
+
def require_tty!(*others, message: "必须使用真实交互终端")
|
|
14
|
+
raise ArgumentError, message unless tty?(*others)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def question(prompt)
|
|
18
|
+
require_tty!
|
|
19
|
+
@out.print prompt
|
|
20
|
+
@out.flush
|
|
21
|
+
@input.gets("\n")&.strip
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def confirm(token:, prompt:)
|
|
25
|
+
question(prompt) == token
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def password(label:)
|
|
29
|
+
raise ArgumentError, "密码提示需要交互终端;自动化任务可使用环境变量或 --stdin-credentials" unless self.class.tty?(@input)
|
|
30
|
+
require "io/console"
|
|
31
|
+
@out.print "#{label}:"
|
|
32
|
+
@out.flush
|
|
33
|
+
value = @input.noecho { |terminal| terminal.gets("\n") }&.chomp("\n")
|
|
34
|
+
@out.puts
|
|
35
|
+
raise ArgumentError, "密码不能为空" if value.nil? || value.empty?
|
|
36
|
+
value
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/ctx_cli.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ctx_cli/version"
|
|
4
|
+
require_relative "ctx_cli/configuration"
|
|
5
|
+
require_relative "ctx_cli/initialization"
|
|
6
|
+
require_relative "ctx_cli/application"
|
|
7
|
+
require_relative "ctx_cli/command"
|
|
8
|
+
require_relative "ctx_cli/options"
|
|
9
|
+
require_relative "ctx_cli/terminal"
|
|
10
|
+
require_relative "ctx_cli/input"
|
|
11
|
+
require_relative "ctx_cli/output"
|
|
12
|
+
require_relative "ctx_cli/collection"
|
metadata
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ctx_cli
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Gatework contributors
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: dotenv
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '4'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '3.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '4'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: fileutils
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '0'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '0'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: io-console
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '0'
|
|
53
|
+
type: :runtime
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '0'
|
|
60
|
+
- !ruby/object:Gem::Dependency
|
|
61
|
+
name: json
|
|
62
|
+
requirement: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '0'
|
|
67
|
+
type: :runtime
|
|
68
|
+
prerelease: false
|
|
69
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '0'
|
|
74
|
+
- !ruby/object:Gem::Dependency
|
|
75
|
+
name: optparse
|
|
76
|
+
requirement: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
type: :runtime
|
|
82
|
+
prerelease: false
|
|
83
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '0'
|
|
88
|
+
- !ruby/object:Gem::Dependency
|
|
89
|
+
name: tempfile
|
|
90
|
+
requirement: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '0'
|
|
95
|
+
type: :runtime
|
|
96
|
+
prerelease: false
|
|
97
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - ">="
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '0'
|
|
102
|
+
- !ruby/object:Gem::Dependency
|
|
103
|
+
name: yaml
|
|
104
|
+
requirement: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - ">="
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '0'
|
|
109
|
+
type: :runtime
|
|
110
|
+
prerelease: false
|
|
111
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - ">="
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '0'
|
|
116
|
+
description: Shared configuration discovery, private initialization, terminal interaction
|
|
117
|
+
and CLI orchestration without device or application dependencies.
|
|
118
|
+
executables: []
|
|
119
|
+
extensions: []
|
|
120
|
+
extra_rdoc_files: []
|
|
121
|
+
files:
|
|
122
|
+
- CHANGELOG.md
|
|
123
|
+
- LICENSE
|
|
124
|
+
- README.md
|
|
125
|
+
- docs/migration.md
|
|
126
|
+
- examples/configuration.rb
|
|
127
|
+
- examples/offline.rb
|
|
128
|
+
- lib/ctx_cli.rb
|
|
129
|
+
- lib/ctx_cli/application.rb
|
|
130
|
+
- lib/ctx_cli/collection.rb
|
|
131
|
+
- lib/ctx_cli/command.rb
|
|
132
|
+
- lib/ctx_cli/configuration.rb
|
|
133
|
+
- lib/ctx_cli/initialization.rb
|
|
134
|
+
- lib/ctx_cli/input.rb
|
|
135
|
+
- lib/ctx_cli/options.rb
|
|
136
|
+
- lib/ctx_cli/output.rb
|
|
137
|
+
- lib/ctx_cli/terminal.rb
|
|
138
|
+
- lib/ctx_cli/version.rb
|
|
139
|
+
homepage: https://github.com/gatework/ctx_cli
|
|
140
|
+
licenses:
|
|
141
|
+
- MIT
|
|
142
|
+
metadata:
|
|
143
|
+
source_code_uri: https://github.com/gatework/ctx_cli
|
|
144
|
+
bug_tracker_uri: https://github.com/gatework/ctx_cli/issues
|
|
145
|
+
changelog_uri: https://github.com/gatework/ctx_cli/blob/main/CHANGELOG.md
|
|
146
|
+
documentation_uri: https://github.com/gatework/ctx_cli#readme
|
|
147
|
+
allowed_push_host: https://rubygems.org
|
|
148
|
+
rubygems_mfa_required: 'true'
|
|
149
|
+
rdoc_options: []
|
|
150
|
+
require_paths:
|
|
151
|
+
- lib
|
|
152
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
153
|
+
requirements:
|
|
154
|
+
- - ">="
|
|
155
|
+
- !ruby/object:Gem::Version
|
|
156
|
+
version: '3.4'
|
|
157
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
158
|
+
requirements:
|
|
159
|
+
- - ">="
|
|
160
|
+
- !ruby/object:Gem::Version
|
|
161
|
+
version: '0'
|
|
162
|
+
requirements: []
|
|
163
|
+
rubygems_version: 4.0.16
|
|
164
|
+
specification_version: 4
|
|
165
|
+
summary: Composable command-line boundaries for network automation.
|
|
166
|
+
test_files: []
|