ctx_cli 0.1.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 367e5a47fb0f653f5ef833f8efee765606717ae2c97aa6025f6116032ba1edbf
4
- data.tar.gz: b87ac1112712391f7fd1eac7fc66fd4e93e6af6dc5ceb3d08508872f0c55345c
3
+ metadata.gz: 6757c9c4e28fdbf88436ead17a364fc8dcb7fc2d4fda107654fd278c0d5229c3
4
+ data.tar.gz: a727b09657a6b79ab821c2a2f8f899c1d09b248faf659ff288306514c07d0a68
5
5
  SHA512:
6
- metadata.gz: 948f143970bc4d5a0af50470e6c35a1bdfe98c0eb63d6d3efa18531c7578f6a00ea3da4da2545091433416b119e715f69bd739c745ca763f08acf2432a0a51d1
7
- data.tar.gz: a3f788a3d3bd2e946c484594747c01912d28455a3b771f5acc7d8208cacca5d838103003fa67383bc3139afceb84e1f1555cd86981d22f355d239dffd8a8e691
6
+ metadata.gz: d0d80afb9a473f4bceff0dc6f0cfbb7b78d2987b8aed673c4b230e1c05086bdbc9f2fa950ee1f54dc95cbfe487f803178ecace433d40ac758257e7b0633c726e
7
+ data.tar.gz: ee8af08341391087d93c40df85d61b486346fcfef8c92ee8b675704749e4ad3e4dcededc8126edadb7d52fc1d82190725fa1e1774c6604cd36124c6c9f938c6e
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.1 (2026-10-23)
6
+
7
+ - Use English command help, prompts and collection/review diagnostics.
8
+ - Allow compatible dotenv versions without an upper version bound.
9
+
10
+ ## 0.2.0 (2026-10-23)
11
+
12
+ - Rename Initialization to Initializer and Application to Router; remove the old constants and require paths in the 0.2.0 candidate.
13
+ - Preserve caller argument strings during routing and publish configuration applicability only after every layer succeeds.
14
+ - Remove quadratic field-source scans for leaf configuration values.
15
+ - Bound offline review source counts and cumulative bytes, and treat batch directory names literally.
16
+ - Redact adapter argument and dependency errors at the collection boundary.
17
+ - Share offline review preflight/publication ordering and redacted diagnostics without depending on Runner or domain schemas.
18
+ - Bound input bytes and configuration nesting/nodes before deserialization; merge owned layers without recursive copies.
19
+ - Synchronize newly created parent directories before publishing configuration templates.
20
+
5
21
  ## 0.1.0 (2026-10-22)
6
22
 
7
23
  - Extract reusable configuration discovery, private initialization, command dispatch, option groups, terminal interaction, input/output and collection entrypoint orchestration.
data/README.md CHANGED
@@ -6,12 +6,13 @@
6
6
 
7
7
  | 对象 | 职责 |
8
8
  | --- | --- |
9
- | `Application` | 惰性子命令分发、全局帮助/版本、返回退出码 |
9
+ | `Router` | 惰性子命令分发、全局帮助/版本、返回退出码 |
10
10
  | `Command` / `Options` | 每次独立的参数快照、信息分支、公共 OptionParser 分组 |
11
11
  | `Configuration` | 多层文件发现、隔离环境、来源路径、纯数据快照 |
12
- | `Initialization` | 私有文件排他创建、同步、保留已有配置 |
12
+ | `Initializer` | 私有文件排他创建、同步、保留已有配置 |
13
13
  | `Terminal` | 真实终端检查、问答、隐藏密码、完整 token 确认 |
14
14
  | `Input` / `Output` | 文件/stdin、显式 JSON、仅含错误类型的诊断 |
15
+ | `Review` | 离线来源读取、全量预检后发布、安全诊断与退出码映射 |
15
16
  | `Collection` | 配置、预览、全批预检、调度交付及退出码映射 |
16
17
 
17
18
  Ruby >= 3.4。`require "ctx_cli"` 或按能力加载 `ctx_cli/configuration` 等路径;加载不读取用户文件、不修改 ENV、不建立网络连接。核心不依赖 ctx_parser、ctx_runner 或 net-connector。代码和工程规范参照 ctx_parser,保留 Ruby 3.4 是为了兼容现有 backup 宿主。
@@ -56,7 +57,7 @@ files.sources # 路径与字段来源,不包含环境值
56
57
 
57
58
  宿主把各层公共环境值转换成领域字段后,通过 `Configuration.merge(defaults, dotenv_values, yaml, process_values, cli)` 实现 CLI > 进程环境 > YAML > .env > 默认值。数组整层替换,false/nil 保留。相对路径按来源目录解释;支持 `path_literals:`(如 `system`)和 URI。`load` 重复调用不重新读文件。
58
59
 
59
- `Initialization#create(path:, contents:)` 返回 `:created` 或 `:exists`。文件先写完并同步,再排他发布并同步目录;既有普通文件保留,符号链接/目录拒绝。发布后同步失败仍抛错,不能当作成功;不把凭据自动写入模板。例子见 [examples/configuration.rb](examples/configuration.rb)。
60
+ `Initializer#create(path:, contents:)` 返回 `:created` 或 `:exists`。文件先写完并同步,再排他发布并同步目录;既有普通文件保留,符号链接/目录拒绝。发布后同步失败仍抛错,不能当作成功;不把凭据自动写入模板。例子见 [examples/configuration.rb](examples/configuration.rb)。
60
61
 
61
62
  ## 采集协议
62
63
 
@@ -72,9 +73,22 @@ Options 协作者沿用现有入口协议:`informational?`、`validate_interac
72
73
  bundle install
73
74
  bundle exec rake
74
75
  bundle exec rake build
75
- ruby script/verify_package.rb pkg/ctx_cli-0.1.0.gem
76
+ ruby script/verify_package.rb pkg/ctx_cli-0.2.1.gem
76
77
  ```
77
78
 
78
79
  默认任务包括 RSpec、RuboCop 和离线示例。安装验证在独立 GEM_HOME/GEM_PATH 下运行普通与最小 Bundler 应用,检查实际加载来源。CI 参照 ctx_parser 的固定 action 提交和候选制品校验;发布需另行明确授权。
79
80
 
80
81
  消费仓库可用 `CTX_CLI_PATH=../ctx_cli bundle exec rake` 显式联调;普通入口须先安装候选包。不隐式探测相邻目录。baseline 只在 Gemfile 中为现有 coverage 脚本引入开发依赖,没有新增 baseline 命令或核心依赖。
82
+
83
+ Input reads default to a 16 MiB byte budget (`CtxCli::Input.read(max_bytes:)`). Configuration data is limited to 128 nesting levels and 100,000 nodes; oversized input raises `ArgumentError` before recursive deserialization.
84
+
85
+ ## 离线 review 入口
86
+
87
+ `CtxCli::Review` 接收 `command:`(`CtxCli::Command`)、`directory:`(批次内证据目录名)和 `adapter:`。入口先处理帮助,再按文件名顺序读取 JSON,通过 `Input` 限制单个文件大小,最后依次调用:
88
+
89
+ - `adapter.prepare(sources, options)`:验证全部领域记录、身份及策略并返回上下文,不创建输出。每条来源为 `[path, body, document]`;领域适配器可在本次入口拥有的来源数组中附加建议。
90
+ - `adapter.publish(sources, options, batch:, context:)`:创建报告并返回完成提示。发布及持久性状态仍由领域模块和 Runner 管理。
91
+
92
+ 失败只输出异常类型,返回 2;中断返回 130;诊断流失败不覆盖原始退出码。帮助不加载证据或调用适配器。此类不读取凭据、连接设备或判断业务健康。默认单文件 16 MiB、批次原始正文总计 64 MiB、文件数 10000;宿主可通过 `max_bytes:`、`max_total_bytes:`、`max_files:` 调整。JSON 默认嵌套限制保持开启;上述限制不是进程 RSS 的精确上限。
93
+
94
+ `Collection` 对普通参数/依赖异常只显示类型;宿主需要公开操作提示时使用 `CtxCli::Collection::Error`,其正文必须由宿主构造,不得直接包装外部异常正文、凭据或设备回显。该类型继承 ArgumentError,既有库调用方仍可按参数错误捕获。
data/docs/migration.md CHANGED
@@ -11,3 +11,10 @@
11
11
  ctx_runner 继续拥有采集/报告/执行的业务状态,net-connector Inventory 继续拥有筛选;ctx_cli 不反向加载二者。普通 Config 旧入口保留为宿主政策适配,无第二份配置发现实现。
12
12
 
13
13
  ctx_cli 初始版本为 0.1.0。首次发布依赖顺序先 ctx_cli,再 net-connector 与 CLI 消费模块;ctx_runner 的依赖链仍按其原有约定。提交、远程创建和发布按用户明确授权的范围执行。
14
+
15
+ ## 0.2.0 接口命名
16
+
17
+ - `CtxCli::Initialization` / `ctx_cli/initialization` 改为 `CtxCli::Initializer` / `ctx_cli/initializer`,仅负责模板安全发布。
18
+ - `CtxCli::Application` / `ctx_cli/application` 改为 `CtxCli::Router` / `ctx_cli/router`,仅负责全局信息和子命令分发。
19
+
20
+ 旧常量和 require 路径不保留别名。宿主需同步修改引用,并将 ctx_cli 依赖更新为 `~> 0.2.0`。`create`、`existing?`、`run` 的结果约定不变;初始化参数询问和厂商语义不迁入这两个对象。0.2.0 当前为本地候选,尚未发布。
@@ -6,7 +6,7 @@ require "tmpdir"
6
6
  # 自定义宿主命名空间;示例只操作临时目录,不读取真实用户配置。
7
7
  Dir.mktmpdir do |root|
8
8
  path = File.join(root, "audit.yml")
9
- publisher = CtxCli::Initialization.new
9
+ publisher = CtxCli::Initializer.new
10
10
  abort "Initialization failed" unless publisher.create(path: path, contents: "directory: reports\nparallel: 2\n") == :created
11
11
  files = CtxCli::Configuration.new(name: "audit", env: {}, directories: [ root ], path_fields: [ "directory" ]).load
12
12
  effective = CtxCli::Configuration.merge({ "parallel" => 1 }, files.data, { "parallel" => 4 })
@@ -2,10 +2,14 @@
2
2
 
3
3
  require "optparse"
4
4
  require_relative "output"
5
+ require_relative "configuration"
5
6
 
6
7
  module CtxCli
7
8
  # 终端采集入口的固定顺序;领域适配器拥有清单、预检、调度及采集策略。
8
9
  class Collection
10
+ # 宿主明确构造的公开操作提示,不得包装凭据或外部异常正文。
11
+ class Error < ArgumentError; end
12
+
9
13
  def initialize(adapter:, options:, progress:, batch:, label:, report_error:, failure_code:,
10
14
  out: $stdout, error: $stderr, input: $stdin)
11
15
  @adapter, @options_factory, @progress_factory, @batch_class = adapter, options, progress, batch
@@ -29,13 +33,13 @@ module CtxCli
29
33
  return 0 if @options.missing?(input: @input, error: @error)
30
34
  @options.load
31
35
  @progress = @progress_factory.call(@options)
32
- @progress.notice("配置:#{@options.config}")
36
+ @progress.notice("Configuration: #{@options.config}")
33
37
  return output(@options.public_values.merge("sources" => @options.sources)) if @options.flags[:show_config]
34
38
  @devices, @inventory_total = @adapter.inventory(@options, progress: @progress)
35
39
  devices = @devices
36
40
  return output(devices) if @options.flags[:inventory_only]
37
41
  if devices.empty?
38
- @progress.notice("没有匹配设备,未建立连接")
42
+ @progress.notice("No devices matched; no connections opened")
39
43
  @output.json({ status: :no_devices, total: 0 }, pretty: false) if @options.flags[:json]
40
44
  return 1
41
45
  end
@@ -43,7 +47,7 @@ module CtxCli
43
47
  @store = @adapter.report(@options)
44
48
  @batch = @batch_class.new(report: @store, progress: @progress, requested: devices)
45
49
  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} 只读采集")
50
+ @progress.plan(devices: devices, total: @inventory_total, parallel: @options.values.fetch("parallel"), label: "#{@label} read-only collection")
47
51
  @progress.with_updates do
48
52
  @adapter.execute(devices, @options) do |device|
49
53
  @batch.collect(device) { @adapter.collect(device, @options, context: context, progress: @progress) }
@@ -53,16 +57,19 @@ module CtxCli
53
57
  end
54
58
  completion(outcome)
55
59
  rescue Interrupt
56
- diagnostic "采集已取消,已保存的设备报告保留;未保存结果见报告状态"
60
+ diagnostic "Collection cancelled; saved device reports retained; check report status for unsaved results"
57
61
  130
58
62
  rescue SystemCallError, IOError => error
59
- diagnostic "#{@label} 参数或报告错误:#{report_diagnostic(error)}"
63
+ diagnostic "#{@label} argument or report error: #{report_diagnostic(error)}"
64
+ 2
65
+ rescue Error, Configuration::Error => error
66
+ diagnostic "#{@label} argument, dependency or report error: #{error.message}; use --help for options"
60
67
  2
61
68
  rescue OptionParser::ParseError, ArgumentError, LoadError => error
62
- diagnostic "#{@label} 参数、依赖或报告错误:#{error.message};使用 --help 查看参数"
69
+ diagnostic "#{@label} argument, dependency or report error: #{error.class.name}; use --help for options"
63
70
  2
64
71
  rescue StandardError => error
65
- diagnostic "#{@label} 清单或执行失败:#{@failure_code.call(error)}(#{error.class.name})"
72
+ diagnostic "#{@label} inventory or execution failed: #{@failure_code.call(error)} (#{error.class.name})"
66
73
  2
67
74
  end
68
75
 
@@ -75,18 +82,18 @@ module CtxCli
75
82
  # Runner 决定批次结论;CLI 仅呈现安全诊断并映射退出码。
76
83
  def completion(outcome)
77
84
  if outcome.finalization_error
78
- diagnostic "#{@label} 摘要报告错误:#{outcome.finalization_error}"
85
+ diagnostic "#{@label} summary report error: #{outcome.finalization_error}"
79
86
  end
80
87
  if outcome.recovery_failed
81
- diagnostic "摘要恢复未确认;请依据退出码及报告目录检查,不能将旧摘要视为本次成功。"
88
+ diagnostic "Summary recovery is unconfirmed; check the exit code and report directory. An earlier summary does not confirm this run succeeded."
82
89
  end
83
90
  if outcome.status == :interrupted
84
- diagnostic "采集已取消,已保存的设备报告保留;未保存结果见报告状态"
91
+ diagnostic "Collection cancelled; saved device reports retained; check report status for unsaved results"
85
92
  elsif (failure = outcome.failure)
86
93
  if failure[:kind] == :report
87
- diagnostic "#{@label} 参数或报告错误:#{failure[:detail]}"
94
+ diagnostic "#{@label} argument or report error: #{failure[:detail]}"
88
95
  else
89
- diagnostic "#{@label} 清单或执行失败:#{failure[:error_type]};已采集结果见批次报告"
96
+ diagnostic "#{@label} inventory or execution failed: #{failure[:error_type]}; see the batch report for collected results"
90
97
  end
91
98
  end
92
99
  { complete: 0, incomplete: 1, failed: 2, interrupted: 130 }.fetch(outcome.status)
@@ -108,7 +115,7 @@ module CtxCli
108
115
  @output.json(value)
109
116
  else
110
117
  value.each { |device| @out.puts "#{device.fetch('host')}\t#{device.fetch('name')}\t#{device['model']}" }
111
- @out.puts "设备:#{value.size} 台;未建立设备连接"
118
+ @out.puts "Devices: #{value.size}; no device connections opened"
112
119
  end
113
120
  value.is_a?(Array) && value.empty? ? 1 : 0
114
121
  end
@@ -18,7 +18,7 @@ module CtxCli
18
18
  @parser.banner = @banner
19
19
  @configure&.call(@parser, @values)
20
20
  rest = @parser.parse(argv.dup)
21
- raise ArgumentError, "不接受位置参数" unless rest.empty?
21
+ raise ArgumentError, "Positional arguments are not accepted" unless rest.empty?
22
22
  @values
23
23
  end
24
24
 
@@ -1,10 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require_relative "input"
4
5
 
5
6
  module CtxCli
6
7
  # 宿主配置发现;加载文件不读取配置或建立设备连接。
7
8
  class Configuration
9
+ # 仅用于本类构造的公开配置诊断,不携带文件正文。
10
+ class Error < ArgumentError; end
11
+
12
+ MAX_DEPTH = 128
13
+ MAX_NODES = 100_000
8
14
  attr_reader :data, :specific, :process_environment, :file_environment, :environment,
9
15
  :config_files, :environment_files, :sources
10
16
 
@@ -74,7 +80,7 @@ module CtxCli
74
80
  specific = {}
75
81
  fields = {}
76
82
  loaded = []
77
- @applicable = false
83
+ applicable = false
78
84
  paths = @directories.flat_map { |directory| [ File.join(directory, "#{@namespace}.yml"), File.join(directory, "#{@name}.yml") ] }
79
85
  paths = final_candidates(paths, @selected_config)
80
86
  paths.each do |path|
@@ -82,13 +88,13 @@ module CtxCli
82
88
 
83
89
  document = read_yaml(path)
84
90
  if @namespace != @name && File.basename(path) == "#{@namespace}.yml"
85
- common, native, applicable = shared_sections(document, path)
91
+ common, native, native_present = shared_sections(document, path)
86
92
  merged = merge_layer(merged, common, path, fields)
87
- @applicable ||= applicable
93
+ applicable ||= native_present
88
94
  else
89
95
  native = document
90
96
  validate_string_keys!(native, path)
91
- @applicable = true
97
+ applicable = true
92
98
  end
93
99
  merged = merge_layer(merged, native, path, fields)
94
100
  specific = self.class.merge(specific, resolve_paths(native, path))
@@ -98,6 +104,7 @@ module CtxCli
98
104
  @specific = self.class.freeze_tree(specific)
99
105
  @config_files = self.class.freeze_tree(loaded)
100
106
  update_sources(fields)
107
+ @applicable = applicable
101
108
  @loaded = true
102
109
  self
103
110
  end
@@ -132,27 +139,41 @@ module CtxCli
132
139
  alias_method :to_s, :inspect
133
140
  def pretty_print(printer) = printer.text(inspect)
134
141
 
135
- # 递归合并但不修改来源;数组、false、nil 按值替换。
142
+ # 每层只复制一次,再合并拥有的映射;数组、false、nil 按值替换。
136
143
  def self.merge(*hashes)
137
144
  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)
145
+ raise ArgumentError, "configuration layers must be mappings" unless hash.is_a?(Hash)
146
+ pending = [ [ result, copy(hash) ] ]
147
+ until pending.empty?
148
+ target, source = pending.pop
149
+ source.each do |key, value|
150
+ if target[key].is_a?(Hash) && value.is_a?(Hash)
151
+ pending << [ target[key], value ]
152
+ else
153
+ target[key] = value
154
+ end
155
+ end
140
156
  end
141
157
  result
142
158
  end
143
159
  end
144
160
 
145
161
  # 只复制普通数据;循环或不透明对象不能进入快照并冻结调用方资源。
146
- def self.copy(value, active = {}.compare_by_identity)
162
+ def self.copy(value) = copy_value(value, {}.compare_by_identity, 0, [ MAX_NODES ])
163
+
164
+ def self.copy_value(value, active, depth, budget)
165
+ raise ArgumentError, "configuration nesting exceeds budget" if depth > MAX_DEPTH
166
+ budget[0] -= 1
167
+ raise ArgumentError, "configuration node budget exceeded" if budget[0].negative?
147
168
  case value
148
169
  when Hash, Array
149
170
  raise ArgumentError, "cyclic configuration value" if active.key?(value)
150
171
  active[value] = true
151
172
  begin
152
173
  if value.is_a?(Hash)
153
- value.to_h { |key, entry| [ copy(key, active), copy(entry, active) ] }
174
+ value.to_h { |key, entry| [ copy_value(key, active, depth + 1, budget), copy_value(entry, active, depth + 1, budget) ] }
154
175
  else
155
- value.map { |entry| copy(entry, active) }
176
+ value.map { |entry| copy_value(entry, active, depth + 1, budget) }
156
177
  end
157
178
  ensure
158
179
  active.delete(value)
@@ -162,6 +183,7 @@ module CtxCli
162
183
  else raise ArgumentError, "configuration accepts plain data only"
163
184
  end
164
185
  end
186
+ private_class_method :copy_value
165
187
 
166
188
  def self.freeze_tree(value)
167
189
  case value
@@ -196,7 +218,7 @@ module CtxCli
196
218
 
197
219
  def readable_file?(path, explicit:, allow_missing:)
198
220
  unless File.exist?(path)
199
- raise ArgumentError, "configuration file does not exist: #{path}" if explicit && !allow_missing
221
+ raise Error, "configuration file does not exist: #{path}" if explicit && !allow_missing
200
222
 
201
223
  return false
202
224
  end
@@ -206,7 +228,7 @@ module CtxCli
206
228
  end
207
229
 
208
230
  def read_yaml(path)
209
- contents = File.read(path)
231
+ contents = Input.read(path: path)
210
232
  validate_yaml_structure!(contents, path)
211
233
  document = YAML.safe_load(contents, permitted_classes: [], permitted_symbols: [], aliases: false)
212
234
  document = {} if document.nil?
@@ -223,7 +245,14 @@ module CtxCli
223
245
  stream = YAML.parse_stream(contents)
224
246
  raise ArgumentError, "configuration must contain a single YAML document: #{path}" if stream.children.size > 1
225
247
 
226
- stream.each do |node|
248
+ pending = [ [ stream, 0 ] ]
249
+ remaining = MAX_NODES
250
+ until pending.empty?
251
+ node, depth = pending.pop
252
+ raise ArgumentError, "configuration nesting exceeds budget" if depth > MAX_DEPTH
253
+ remaining -= 1
254
+ raise ArgumentError, "configuration node budget exceeded" if remaining.negative?
255
+ node.children&.reverse_each { |child| pending << [ child, depth + 1 ] }
227
256
  next unless node.is_a?(Psych::Nodes::Mapping)
228
257
 
229
258
  keys = {}
@@ -322,7 +351,11 @@ module CtxCli
322
351
  fields.delete(name)
323
352
  record_fields(value, source, fields, name, previous: before)
324
353
  else
325
- fields.delete_if { |field, _| field == name || field.start_with?("#{name}.") }
354
+ # 只有旧映射被整体替换才需要删除后代;新增/覆盖叶子直接赋值。
355
+ if before.is_a?(Hash) && !before.empty?
356
+ descendant_prefix = "#{name}."
357
+ fields.delete_if { |field, _| field.start_with?(descendant_prefix) }
358
+ end
326
359
  fields[name] = source
327
360
  end
328
361
  end
@@ -365,7 +398,7 @@ module CtxCli
365
398
  end
366
399
  private :expand_newlines
367
400
  end
368
- contents = File.read(path)
401
+ contents = Input.read(path: path)
369
402
  remainder = contents.gsub(Dotenv::Parser::LINE, "")
370
403
  unless remainder.lines("\n").all? { |line| line.strip.empty? || line.lstrip.start_with?("#") }
371
404
  raise ArgumentError, "invalid environment syntax"
@@ -5,7 +5,7 @@ require "tempfile"
5
5
 
6
6
  module CtxCli
7
7
  # 只发布已经过宿主校验的模板,不收集凭据、不覆盖已有目录项。
8
- class Initialization
8
+ class Initializer
9
9
  def initialize(prefix: ".ctx-cli-init-")
10
10
  @prefix = prefix
11
11
  end
@@ -23,7 +23,7 @@ module CtxCli
23
23
  def create(path:, contents:)
24
24
  raise ArgumentError, "config path must be a nonempty String" unless path.is_a?(String) && !path.empty?
25
25
  return :exists if existing?(path)
26
- FileUtils.mkdir_p(File.dirname(File.expand_path(path)), mode: 0o700)
26
+ prepare_directory(File.dirname(File.expand_path(path)))
27
27
  directory = File.realpath(File.dirname(File.expand_path(path)))
28
28
  destination = File.join(directory, File.basename(path))
29
29
  file = parent = primary = nil
@@ -53,6 +53,19 @@ module CtxCli
53
53
 
54
54
  private
55
55
 
56
+ # 先确认新目录在各父级的名字已同步,再发布其中的配置文件。
57
+ def prepare_directory(path)
58
+ missing, parent = [], path
59
+ until File.directory?(parent)
60
+ missing << parent
61
+ parent = File.dirname(parent)
62
+ end
63
+ FileUtils.mkdir_p(path, mode: 0o700)
64
+ missing.reverse_each do |created|
65
+ File.open(File.realpath(File.dirname(created)), File::RDONLY | File::NOFOLLOW) { |directory| directory.fsync }
66
+ end
67
+ end
68
+
56
69
  # 全部句柄都尝试关闭,保留原始故障;不把清理失败变为成功。
57
70
  def close_files(file, parent, primary)
58
71
  Thread.handle_interrupt(Exception => :never) do
data/lib/ctx_cli/input.rb CHANGED
@@ -3,8 +3,14 @@
3
3
  module CtxCli
4
4
  # 只关闭自行打开的文件,stdin 的读取和关闭权仍由宿主控制。
5
5
  module Input
6
- def self.read(path: nil, input: $stdin)
7
- path ? File.read(path) : input.read
6
+ MAX_BYTES = 16 * 1024 * 1024
7
+
8
+ def self.read(path: nil, input: $stdin, max_bytes: MAX_BYTES)
9
+ raise ArgumentError, "input byte budget must be positive" unless max_bytes.is_a?(Integer) && max_bytes.positive?
10
+ return File.open(path, "r") { |file| read(input: file, max_bytes: max_bytes) } if path
11
+ contents = input.read(max_bytes + 1) || +""
12
+ raise ArgumentError, "input exceeds byte budget" if contents.bytesize > max_bytes
13
+ contents.force_encoding(input.external_encoding || Encoding.default_external)
8
14
  end
9
15
  end
10
16
  end
@@ -4,34 +4,34 @@ module CtxCli
4
4
  # 普通参数分组只登记语法;不加载配置、不执行操作、不拥有领域校验。
5
5
  module Options
6
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 }
7
+ parser.on("--init", "Create missing configuration and environment templates, then exit; preserve existing files") { values[:init] = true }
8
+ parser.on("--interactive", "Prompt to create missing configuration; requires an interactive terminal") { values[:interactive] = true }
9
+ parser.on("--non-interactive", "Disable prompts; specify --init to create files") { values[:interactive] = false }
10
10
  end
11
11
 
12
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 }
13
+ parser.on("--json", "Write JSON to stdout; defaults to a readable summary") { values[:json] = true }
14
+ parser.on("--quiet", "--no-progress", "Disable progress on stderr; can be combined with --json") { values[quiet] = true }
15
+ parser.on("--verbose", "Show per-device results and safe command events") { values[:verbose] = true }
16
16
  end
17
17
 
18
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 }
19
+ parser.on("-h", "--help", "Show option help") { values[:help] = true }
20
+ parser.on("--help-all", "Show all options, paths and environment conventions") { values[:help_all] = true }
21
+ parser.on("--examples", "Show common command examples without executing tasks") { values[:examples] = true }
22
22
  end
23
23
 
24
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) }
25
+ def self.configuration(parser, parallel_help: "Device concurrency", &store)
26
+ parser.on("--config FILE", "Overlay explicit YAML after automatically discovered configuration") { |value| store.call(:config, value) }
27
+ parser.on("--env-file FILE", "Load explicit environment files last; process environment takes precedence") { |value| store.call(:env_file, value) }
28
28
  parser.on("-p", "--parallel N", Integer, parallel_help) { |value| store.call(:parallel, value) }
29
- parser.on("--directory DIR", "按设备保存报告的目录") { |value| store.call(:directory, value) }
29
+ parser.on("--directory DIR", "Directory for per-device reports") { |value| store.call(:directory, value) }
30
30
  end
31
31
 
32
32
  def self.list(value)
33
33
  values = value.split(",", -1).map(&:strip)
34
- raise ArgumentError, "列表不能有空项" if values.any?(&:empty?)
34
+ raise ArgumentError, "Lists must not contain empty entries" if values.any?(&:empty?)
35
35
  values
36
36
  end
37
37
  end
@@ -14,7 +14,7 @@ module CtxCli
14
14
 
15
15
  # 最后的错误流失败不能覆盖原本的失败或取消状态。
16
16
  def diagnostic(message, exception: nil)
17
- @error.puts(exception ? "#{message}(#{exception.class.name})" : message)
17
+ @error.puts(exception ? "#{message} (#{exception.class.name})" : message)
18
18
  rescue StandardError
19
19
  nil
20
20
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "input"
5
+ require_relative "output"
6
+
7
+ module CtxCli
8
+ # 离线入口先读取并预检全部来源,再允许领域适配器创建报告。
9
+ class Review
10
+ MAX_FILES = 10_000
11
+ MAX_TOTAL_BYTES = 64 * 1024 * 1024
12
+
13
+ def initialize(command:, directory:, adapter:, out: $stdout, error: $stderr,
14
+ max_files: MAX_FILES, max_bytes: Input::MAX_BYTES, max_total_bytes: MAX_TOTAL_BYTES)
15
+ unless [ max_files, max_bytes, max_total_bytes ].all? { |limit| limit.is_a?(Integer) && limit.positive? }
16
+ raise ArgumentError, "review budgets must be positive integers"
17
+ end
18
+ @max_files, @max_bytes, @max_total_bytes = max_files, max_bytes, max_total_bytes
19
+ @command, @directory, @adapter = command, directory, adapter
20
+ @output = Output.new(out: out, error: error)
21
+ end
22
+
23
+ def run(argv)
24
+ options = @command.parse(argv)
25
+ return 0 if @command.informational?
26
+ raise ArgumentError, "A batch directory is required" unless options[:batch]
27
+ batch = File.expand_path(options[:batch])
28
+ paths = source_paths(File.join(batch, @directory))
29
+ raise ArgumentError, "No device reports found" if paths.empty?
30
+ remaining = @max_total_bytes
31
+ sources = paths.map do |path|
32
+ raise ArgumentError, "review byte budget exceeded" unless remaining.positive?
33
+ body = Input.read(path: path, max_bytes: [ @max_bytes, remaining ].min)
34
+ remaining -= body.bytesize
35
+ [ path, body, JSON.parse(body) ]
36
+ end
37
+ context = @adapter.prepare(sources, options)
38
+ @output.text(@adapter.publish(sources, options, batch: batch, context: context))
39
+ 0
40
+ rescue Interrupt
41
+ @output.diagnostic("Review cancelled; existing reports retained; no device changes executed.")
42
+ 130
43
+ rescue StandardError => error
44
+ @output.diagnostic("Review failed (#{error.class.name}); check the input structure, directory permissions and --help; no device changes executed.")
45
+ 2
46
+ end
47
+
48
+ def inspect = "#<#{self.class.name}>"
49
+ alias_method :to_s, :inspect
50
+ def pretty_print(printer) = printer.text(inspect)
51
+
52
+ private
53
+
54
+ # 目录名按字面处理;边枚举边限制数量,避免先创建无限制的路径数组。
55
+ def source_paths(directory)
56
+ paths = []
57
+ Dir.each_child(directory) do |name|
58
+ next if name.start_with?(".") || !name.end_with?(".json")
59
+ raise ArgumentError, "review file budget exceeded" if paths.size >= @max_files
60
+ paths << File.join(directory, name)
61
+ end
62
+ paths.sort
63
+ end
64
+ end
65
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module CtxCli
4
4
  # 命令工厂按需执行;帮助和版本不加载领域实现或配置。
5
- class Application
5
+ class Router
6
6
  def initialize(name:, version:, usage:, commands:, out: $stdout, error: $stderr)
7
7
  @name, @version, @usage, @commands = name, version, usage, commands.dup.freeze
8
8
  @out, @error = out, error
@@ -10,7 +10,7 @@ module CtxCli
10
10
 
11
11
  # 退出行为归调用方;保留 argv 并只返回命令退出码。
12
12
  def run(argv)
13
- arguments = argv.dup
13
+ arguments = argv.map { |argument| String.new(argument) }
14
14
  command = arguments.shift
15
15
  if [ nil, "--help", "--help-all", "-h" ].include?(command) && arguments.empty?
16
16
  @out.puts @usage
@@ -21,7 +21,7 @@ module CtxCli
21
21
  return 0
22
22
  end
23
23
  return @commands.fetch(command).call(arguments) if @commands.key?(command)
24
- @error.puts "未知命令或多余参数;使用 #{@name} --help 查看用法"
24
+ @error.puts "Unknown command or extra arguments; use #{@name} --help for usage"
25
25
  2
26
26
  end
27
27
  end
@@ -10,7 +10,7 @@ module CtxCli
10
10
  def self.tty?(*streams) = streams.all? { |stream| stream.respond_to?(:tty?) && stream.tty? }
11
11
  def tty?(*others) = self.class.tty?(@input, @out, *others)
12
12
 
13
- def require_tty!(*others, message: "必须使用真实交互终端")
13
+ def require_tty!(*others, message: "An interactive terminal is required")
14
14
  raise ArgumentError, message unless tty?(*others)
15
15
  end
16
16
 
@@ -26,13 +26,13 @@ module CtxCli
26
26
  end
27
27
 
28
28
  def password(label:)
29
- raise ArgumentError, "密码提示需要交互终端;自动化任务可使用环境变量或 --stdin-credentials" unless self.class.tty?(@input)
29
+ raise ArgumentError, "Password prompts require an interactive terminal; use environment variables or --stdin-credentials for automation" unless self.class.tty?(@input)
30
30
  require "io/console"
31
- @out.print "#{label}:"
31
+ @out.print "#{label}: "
32
32
  @out.flush
33
33
  value = @input.noecho { |terminal| terminal.gets("\n") }&.chomp("\n")
34
34
  @out.puts
35
- raise ArgumentError, "密码不能为空" if value.nil? || value.empty?
35
+ raise ArgumentError, "Password must not be empty" if value.nil? || value.empty?
36
36
  value
37
37
  end
38
38
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CtxCli
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/ctx_cli.rb CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  require_relative "ctx_cli/version"
4
4
  require_relative "ctx_cli/configuration"
5
- require_relative "ctx_cli/initialization"
6
- require_relative "ctx_cli/application"
5
+ require_relative "ctx_cli/initializer"
6
+ require_relative "ctx_cli/router"
7
7
  require_relative "ctx_cli/command"
8
8
  require_relative "ctx_cli/options"
9
9
  require_relative "ctx_cli/terminal"
10
10
  require_relative "ctx_cli/input"
11
11
  require_relative "ctx_cli/output"
12
12
  require_relative "ctx_cli/collection"
13
+ require_relative "ctx_cli/review"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ctx_cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gatework contributors
@@ -15,20 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '3.0'
19
- - - "<"
20
- - !ruby/object:Gem::Version
21
- version: '4'
18
+ version: '0'
22
19
  type: :runtime
23
20
  prerelease: false
24
21
  version_requirements: !ruby/object:Gem::Requirement
25
22
  requirements:
26
23
  - - ">="
27
24
  - !ruby/object:Gem::Version
28
- version: '3.0'
29
- - - "<"
30
- - !ruby/object:Gem::Version
31
- version: '4'
25
+ version: '0'
32
26
  - !ruby/object:Gem::Dependency
33
27
  name: fileutils
34
28
  requirement: !ruby/object:Gem::Requirement
@@ -126,14 +120,15 @@ files:
126
120
  - examples/configuration.rb
127
121
  - examples/offline.rb
128
122
  - lib/ctx_cli.rb
129
- - lib/ctx_cli/application.rb
130
123
  - lib/ctx_cli/collection.rb
131
124
  - lib/ctx_cli/command.rb
132
125
  - lib/ctx_cli/configuration.rb
133
- - lib/ctx_cli/initialization.rb
126
+ - lib/ctx_cli/initializer.rb
134
127
  - lib/ctx_cli/input.rb
135
128
  - lib/ctx_cli/options.rb
136
129
  - lib/ctx_cli/output.rb
130
+ - lib/ctx_cli/review.rb
131
+ - lib/ctx_cli/router.rb
137
132
  - lib/ctx_cli/terminal.rb
138
133
  - lib/ctx_cli/version.rb
139
134
  homepage: https://github.com/gatework/ctx_cli