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 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"
@@ -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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CtxCli
4
+ # 只关闭自行打开的文件,stdin 的读取和关闭权仍由宿主控制。
5
+ module Input
6
+ def self.read(path: nil, input: $stdin)
7
+ path ? File.read(path) : input.read
8
+ end
9
+ end
10
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CtxCli
4
+ VERSION = "0.1.0"
5
+ 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: []