net-connector 0.17.0 → 0.17.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: 233cb61903727cc01241fcae91e4e540a8bae1462b01210e73aa246fd1a906c9
4
- data.tar.gz: 923cdca12fc7c38841743618a0d22527536bee646de7687c26480afbc3265a4a
3
+ metadata.gz: 33503c92ccbe8f7000652c0b47b967a8f2f40e6c9edd7081ba305c3246efe820
4
+ data.tar.gz: f2d4c434caab1b888d1b4cd9bf25a0def4e7e26eb63b2e6e5e93d8b56cc34c0d
5
5
  SHA512:
6
- metadata.gz: d3f2a794edb16faf5429967c4a25ee53a949fef194883cb5aefdc0f9a1a9968ccdf8a539e2f059a4a7d3f28d1e299ab1481c5d66823d555324bb11e2ae10ee0c
7
- data.tar.gz: a3ca7a0f4bee7e40861a700063c448f5ac918bf09d7d5b5c87510a664eb024c3fd98cee3ae9a27516d63b59ee8c7aa6af7000231d74acaf24405c2852a7b423f
6
+ metadata.gz: e8c126666632d1f4a708cbcc42c1df5ad434d37978a55fe2db363e3628f2c7e2779192d05ad7d8989933d389d92a6de520669a93a47c46209816a85d423abcca
7
+ data.tar.gz: ad704438b1ed05942371fe3f57a7ebf66f563261406682013b18f57c8cfbc8b01a190a41a685a2991f4d53ff3e2912d2cf8c8e272b593bc83e1a439c821ef349
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.17.1 - 2026-10-22
4
+
5
+ - Delegate shared configuration, private initialization, option groups and terminal interaction to ctx_cli 0.1.0.
6
+
3
7
  ## 0.17.0 - 2026-10-22
4
8
 
5
9
  - Centralize inventory selection and CLI filters in Net::Connector::Inventory; add vendor/model inclusion, exclusion and matching modes.
data/README.md CHANGED
@@ -62,3 +62,7 @@ script/ci
62
62
  The gate scans secrets, lints Ruby and workflows, runs tests and local PTY benchmarks, and verifies isolated gem installs. See [CONTRIBUTING](CONTRIBUTING.md), [security reporting](SECURITY.md), and the [changelog](CHANGELOG.md). Issues and pull requests may be written in English or Chinese.
63
63
 
64
64
  Licensed under [MIT](LICENSE).
65
+
66
+ ## ctx_cli 公共入口
67
+
68
+ 宿主配置文件读取、私有文件初始化、公共参数与终端交互由 ctx_cli 维护。原 `Net::Connector::Config`、配置路径、优先级和 backup/TFTP/topology 策略继续保留。源码联调使用 `CTX_CLI_PATH=../ctx_cli`;隔离安装可通过 `CTX_CLI_GEM=/absolute/path/ctx_cli-0.1.0.gem` 传入精确候选。
@@ -1,366 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
3
+ require "ctx_cli/configuration"
4
4
 
5
5
  module Net
6
6
  module Connector
7
- # Host-side configuration discovery. Requiring this file never loads device I/O.
8
- class Config
7
+ # 保留原配置入口和路径政策;通用发现、来源与快照由 ctx_cli 维护。
8
+ class Config < CtxCli::Configuration
9
9
  NAMES = %w[vxlan wireless backup topology].freeze
10
10
  COMMON_KEYS = %w[netdisco inventory ssh].freeze
11
11
  CONFIG_ENV = { "vxlan" => "VXLAN_CONFIG", "wireless" => "WIRELESS_CONFIG",
12
12
  "backup" => "NC_CONFIG", "topology" => "NC_CONFIG" }.freeze
13
13
 
14
- attr_reader :data, :specific, :process_environment, :file_environment, :environment,
15
- :config_files, :environment_files, :sources
16
-
17
- def initialize(name:, env: ENV, script: $PROGRAM_NAME, cwd: Dir.pwd, home: Dir.home,
18
- directories: nil, config: nil, env_file: nil, config_env: nil,
19
- path_fields: [], env_path_keys: [], path_literals: {})
20
- @name = name.to_s
21
- raise ArgumentError, "unsupported configuration name: #{@name}" unless NAMES.include?(@name)
22
-
23
- @cwd = File.expand_path(cwd)
24
- @home = File.expand_path(home)
25
- @config_env = config_env || CONFIG_ENV.fetch(@name)
26
- @env_path_keys = (env_path_keys.map(&:to_s) + [@config_env]).uniq
27
- @path_literals = self.class.freeze_tree(self.class.copy(path_literals.transform_keys(&:to_s)))
28
- @process_environment = self.class.copy(env.to_h)
29
- resolve_environment_paths!(@process_environment, @cwd)
30
- self.class.freeze_tree(@process_environment)
31
- @user_directory = File.join(user_config_home, "net-connector")
32
- @directories = (directories || ["/etc/net-connector", "/usr/local/etc/net-connector", @user_directory,
33
- File.dirname(File.expand_path(script, @cwd)), @cwd]).map do |path|
34
- File.expand_path(path, @cwd)
35
- end.uniq
36
- @explicit_config = absolute(config, @cwd)
37
- @explicit_env_file = absolute(env_file, @cwd)
38
- @path_fields = path_fields.map { |path| path.to_s.split(".") }
39
- @data = @specific = @file_environment = @environment = {}.freeze
40
- @config_files = @environment_files = [].freeze
41
- @sources = { "configuration_files" => @config_files, "environment_files" => @environment_files, "fields" => {}.freeze }.freeze
42
- end
43
-
44
- # Read environment files without mutating ENV or evaluating any YAML.
45
- def load_environment(allow_missing: false)
46
- return self if @environment_loaded
47
-
48
- files = candidates(".env", @explicit_env_file)
49
- values = {}
50
- loaded = []
51
- files.each do |path|
52
- next unless readable_file?(path, explicit: path == @explicit_env_file, allow_missing: allow_missing)
53
-
54
- parsed = parse_environment(path, values.merge(@process_environment))
55
- resolve_environment_paths!(parsed, File.dirname(path))
56
- values.merge!(parsed)
57
- loaded << path
58
- end
59
- @file_environment = self.class.freeze_tree(values)
60
- @environment = self.class.freeze_tree(values.merge(self.class.copy(@process_environment)))
61
- @environment_files = self.class.freeze_tree(loaded)
62
- # Empty selectors are absent, including an empty original process variable.
63
- selector = present(@process_environment[@config_env]) || present(values[@config_env])
64
- @selected_config = @explicit_config || absolute(selector, @cwd)
65
- @environment_loaded = true
66
- update_sources({})
67
- self
68
- end
69
-
70
- # Freeze one batch snapshot; repeated calls do not re-read files.
71
- def load(allow_missing: false)
72
- return self if @loaded
73
-
74
- load_environment(allow_missing: allow_missing)
75
- merged = {}
76
- specific = {}
77
- fields = {}
78
- loaded = []
79
- @applicable = false
80
- paths = @directories.flat_map { |directory| [File.join(directory, "net-connector.yml"), File.join(directory, "#{@name}.yml")] }
81
- paths = final_candidates(paths, @selected_config)
82
- paths.each do |path|
83
- next unless readable_file?(path, explicit: path == @selected_config, allow_missing: allow_missing)
84
-
85
- document = read_yaml(path)
86
- if File.basename(path) == "net-connector.yml"
87
- common, native, applicable = shared_sections(document, path)
88
- merged = merge_layer(merged, common, path, fields)
89
- @applicable ||= applicable
90
- else
91
- native = document
92
- validate_string_keys!(native, path)
93
- @applicable = true
94
- end
95
- merged = merge_layer(merged, native, path, fields)
96
- specific = self.class.merge(specific, resolve_paths(native, path))
97
- loaded << path
98
- end
99
- @data = self.class.freeze_tree(merged)
100
- @specific = self.class.freeze_tree(specific)
101
- @config_files = self.class.freeze_tree(loaded)
102
- update_sources(fields)
103
- @loaded = true
104
- self
105
- end
106
-
107
- def config
108
- @selected_config || @explicit_config || @config_files.last || init_config
109
- end
110
-
111
- def env_file
112
- @explicit_env_file || @environment_files.last || init_env_file
113
- end
114
-
115
- def init_config
116
- @selected_config || @explicit_config || File.join(@user_directory, "#{@name}.yml")
117
- end
118
-
119
- def init_env_file
120
- @explicit_env_file || File.join(@user_directory, ".env")
121
- end
122
-
123
- def missing?
124
- !@applicable
125
- end
126
-
127
- # Serialize native values using the same target-file schema as discovery.
128
- def self.dump(document, name:, path:)
14
+ def initialize(name:, config_env: nil, **options)
129
15
  name = name.to_s
130
16
  raise ArgumentError, "unsupported configuration name: #{name}" unless NAMES.include?(name)
131
17
 
132
- YAML.dump(File.basename(path) == "net-connector.yml" ? { name => document } : document)
133
- end
134
-
135
- # Recursive, non-destructive replacement: arrays, false and nil are values.
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
- def self.copy(value)
146
- case value
147
- when Hash then value.to_h { |key, entry| [copy(key), copy(entry)] }
148
- when Array then value.map { |entry| copy(entry) }
149
- when String then value.dup
150
- else value
151
- end
152
- end
153
-
154
- def self.freeze_tree(value)
155
- case value
156
- when Hash then value.each { |key, entry| freeze_tree(key); freeze_tree(entry) }
157
- when Array then value.each { |entry| freeze_tree(entry) }
158
- end
159
- value.freeze
160
- end
161
-
162
- private
163
-
164
- def present(value)
165
- value unless value.nil? || value.to_s.strip.empty?
166
- end
167
-
168
- def absolute(path, directory)
169
- File.expand_path(path.to_s, directory) if present(path)
170
- end
171
-
172
- def user_config_home
173
- absolute(present(@process_environment["XDG_CONFIG_HOME"]), @cwd) || File.join(@home, ".config")
174
- end
175
-
176
- def candidates(filename, explicit)
177
- final_candidates(@directories.map { |directory| File.join(directory, filename) }, explicit)
178
- end
179
-
180
- def final_candidates(paths, explicit)
181
- paths = paths.reject { |path| path == explicit } + [explicit] if explicit
182
- paths.uniq
183
- end
184
-
185
- def readable_file?(path, explicit:, allow_missing:)
186
- unless File.exist?(path)
187
- raise ArgumentError, "configuration file does not exist: #{path}" if explicit && !allow_missing
188
-
189
- return false
190
- end
191
- return true if File.file?(path) && File.readable?(path)
192
-
193
- raise ArgumentError, "configuration path is not a readable regular file: #{path}"
194
- end
195
-
196
- def read_yaml(path)
197
- contents = File.read(path)
198
- validate_yaml_structure!(contents, path)
199
- document = YAML.safe_load(contents, permitted_classes: [], permitted_symbols: [], aliases: false)
200
- document = {} if document.nil?
201
- raise ArgumentError, "configuration must contain a mapping: #{path}" unless document.is_a?(Hash)
202
-
203
- document
204
- rescue Psych::Exception, SystemCallError, IOError => error
205
- raise ArgumentError, "cannot read configuration file: #{path} (#{error.class})", cause: nil
206
- end
207
-
208
- # Overrides are explicit file layers; one file cannot hide duplicate keys
209
- # or silently discard later documents before the tool validates its scope.
210
- def validate_yaml_structure!(contents, path)
211
- stream = YAML.parse_stream(contents)
212
- raise ArgumentError, "configuration must contain a single YAML document: #{path}" if stream.children.size > 1
213
-
214
- stream.each do |node|
215
- next unless node.is_a?(Psych::Nodes::Mapping)
216
-
217
- keys = {}
218
- node.children.each_slice(2) do |key, _|
219
- next unless key.is_a?(Psych::Nodes::Scalar)
220
- if keys.key?(key.value)
221
- raise ArgumentError, "duplicate configuration key at line #{key.start_line + 1}: #{path}"
222
- end
223
-
224
- keys[key.value] = true
225
- end
226
- end
227
- end
228
-
229
- def validate_string_keys!(value, path)
230
- case value
231
- when Hash
232
- raise ArgumentError, "configuration keys must be strings: #{path}" unless value.keys.all?(String)
233
-
234
- value.each_value { |entry| validate_string_keys!(entry, path) }
235
- when Array
236
- value.each { |entry| validate_string_keys!(entry, path) }
237
- end
18
+ super(name: name, config_env: config_env || CONFIG_ENV.fetch(name), namespace: "net-connector",
19
+ names: NAMES, common_keys: COMMON_KEYS, **options)
238
20
  end
239
21
 
240
- def shared_sections(document, path)
241
- unknown = document.keys - ["common", *NAMES]
242
- raise ArgumentError, "unknown shared configuration namespaces in #{path}: #{unknown.join(", ")}" unless unknown.empty?
243
-
244
- common = document.fetch("common", {})
245
- native = document.fetch(@name, {})
246
- unless common.is_a?(Hash) && native.is_a?(Hash)
247
- raise ArgumentError, "common and #{@name} configuration must contain mappings: #{path}"
248
- end
249
- unknown_common = common.keys - COMMON_KEYS
250
- unless unknown_common.empty?
251
- raise ArgumentError, "unsupported common configuration fields in #{path}: #{unknown_common.join(", ")}"
252
- end
253
- validate_string_keys!(common, path)
254
- validate_string_keys!(native, path)
255
- [common, native, document.key?(@name)]
256
- end
257
-
258
- def resolve_paths(document, path)
259
- result = self.class.copy(document)
260
- @path_fields.each { |segments| resolve_path!(result, segments, File.dirname(path), segments.join(".")) }
261
- result
262
- end
263
-
264
- def resolve_path!(value, segments, directory, field)
265
- return unless value.is_a?(Hash) && !segments.empty?
266
-
267
- key, *rest = segments
268
- keys = key == "*" ? value.keys : [key]
269
- keys.each do |entry|
270
- next unless value.key?(entry)
271
-
272
- if rest.empty?
273
- value[entry] = declared_path(value[entry], directory, field) if value[entry].is_a?(String) && !value[entry].empty?
274
- else
275
- resolve_path!(value[entry], rest, directory, field)
276
- end
277
- end
278
- end
279
-
280
- # Declared paths may identify remote archive roots as well as local files.
281
- def declared_path(value, directory, field)
282
- raise ArgumentError if value.to_s.include?("\0")
283
-
284
- return value if @path_literals.fetch(field, []).include?(value)
285
-
286
- value.to_s.match?(%r{\A[A-Za-z][A-Za-z0-9+.-]*://}) ? value : absolute(value, directory)
287
- rescue ArgumentError
288
- raise ArgumentError, "invalid path #{field}", cause: nil
289
- end
290
-
291
- def resolve_environment_paths!(values, directory)
292
- @env_path_keys.each do |key|
293
- values[key] = declared_path(values[key], directory, key) if present(values[key])
294
- end
295
- end
296
-
297
- def merge_layer(previous, document, path, fields)
298
- resolved = resolve_paths(document, path)
299
- record_fields(resolved, path, fields, previous: previous)
300
- self.class.merge(previous, resolved)
301
- end
302
-
303
- def record_fields(document, source, fields, prefix = nil, previous: {})
304
- document.each do |key, value|
305
- name = [prefix, key].compact.join(".")
306
- before = previous.is_a?(Hash) ? previous[key] : nil
307
- next if value.is_a?(Hash) && value.empty? && before.is_a?(Hash) && !before.empty?
308
-
309
- if value.is_a?(Hash) && !value.empty?
310
- fields.delete(name)
311
- record_fields(value, source, fields, name, previous: before)
312
- else
313
- fields.delete_if { |field, _| field == name || field.start_with?("#{name}.") }
314
- fields[name] = source
315
- end
316
- end
317
- end
318
-
319
- def update_sources(fields)
320
- @sources = self.class.freeze_tree({ "configuration_files" => @config_files,
321
- "environment_files" => @environment_files, "fields" => fields })
322
- end
323
-
324
- # Dotenv's grammar is reused, but substitutions are deliberately isolated:
325
- # its default implementation reads real ENV and executes shell commands.
326
- def parse_environment(path, inherited)
327
- require "dotenv"
328
- process = @process_environment
329
- variable_pattern = Dotenv::Substitutions::Variable.singleton_class.const_get(:VARIABLE)
330
- parser_class = Class.new(Dotenv::Parser) do
331
- define_singleton_method(:substitutions) do
332
- [lambda do |value, local|
333
- raise ArgumentError, "command substitution is unsupported" if value.include?("$(")
22
+ # 共享文件按工具分区;各专用 YAML 保持原有字段布局。
23
+ def self.dump(document, name:, path:)
24
+ raise ArgumentError, "unsupported configuration name: #{name}" unless NAMES.include?(name.to_s)
334
25
 
335
- value.gsub(variable_pattern) do |variable|
336
- match = Regexp.last_match
337
- if match[1] == "\\"
338
- variable[1..]
339
- elsif match[3]
340
- process.fetch(match[3]) { local.fetch(match[3]) { inherited.fetch(match[3], "") } }
341
- else
342
- variable
343
- end
344
- end
345
- end]
346
- end
347
- define_method(:expand_newlines) do |value|
348
- if (@hash["DOTENV_LINEBREAK_MODE"] || inherited["DOTENV_LINEBREAK_MODE"]) == "legacy"
349
- value.gsub('\\n', "\n").gsub('\\r', "\r")
350
- else
351
- value.gsub('\\n', "\\\\\\n").gsub('\\r', "\\\\\\r")
352
- end
353
- end
354
- private :expand_newlines
355
- end
356
- contents = File.read(path)
357
- remainder = contents.gsub(Dotenv::Parser::LINE, "")
358
- unless remainder.lines.all? { |line| line.strip.empty? || line.lstrip.start_with?("#") }
359
- raise ArgumentError, "invalid environment syntax"
360
- end
361
- parser_class.call(contents, overwrite: true)
362
- rescue Dotenv::FormatError, ArgumentError, SystemCallError, IOError => error
363
- raise ArgumentError, "cannot read environment file: #{path} (#{error.class})"
26
+ super(document, name: name, path: path, namespace: "net-connector")
364
27
  end
365
28
  end
366
29
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require "ctx_cli/options"
5
+ require "ctx_cli/terminal"
4
6
  require_relative "../settings"
5
7
  require_relative "../planner"
6
8
  require_relative "../config"
@@ -66,13 +68,9 @@ module Net
66
68
  def self.add_terminal_options(opts, values)
67
69
  opts.on("--ask-password", "隐藏输入设备密码") { values[:ask_password] = true }
68
70
  opts.on("--ask-netdisco-password", "隐藏输入 Netdisco 密码") { values[:ask_netdisco_password] = true }
69
- opts.on("--quiet", "--no-progress", "关闭进度;可配合 --json") { values[:no_progress] = true }
70
- opts.on("--verbose", "显示逐条登录和命令事件") { values[:verbose] = true }
71
- opts.on("--json", "在 stdout 输出 JSON;默认显示可读摘要") { values[:json] = true }
71
+ CtxCli::Options.output(opts, values, quiet: :no_progress)
72
72
  opts.on("--stdin-credentials", "从标准输入读取凭据并等待 RUN") { values[:stdin_credentials] = true }
73
- opts.on("--init", "创建缺失配置和环境模板后退出;已有文件不覆盖") { values[:init] = true }
74
- opts.on("--interactive", "缺少文件时询问是否创建,并可交互填写") { values[:interactive] = true }
75
- opts.on("--non-interactive", "禁用问答;创建文件须同时指定 --init") { values[:interactive] = false }
73
+ CtxCli::Options.initialization(opts, values)
76
74
  end
77
75
 
78
76
  # 登记互斥的拓扑执行模式和格式化参数,应用模式必须显式选择。
@@ -257,15 +255,7 @@ module Net
257
255
  return unless values[flag]
258
256
 
259
257
  raise ArgumentError, "密码提示与显式密码不能同时使用" if values[:environment].key?(key)
260
- raise ArgumentError, "密码提示需要交互终端;自动化任务可使用环境变量或 --stdin-credentials" unless input.tty?
261
- require "io/console"
262
- output.print "#{label}:"
263
- output.flush
264
- password = input.noecho { |terminal| terminal.gets("\n") }&.chomp("\n")
265
- output.puts
266
- raise ArgumentError, "密码不能为空" if password.nil? || password.empty?
267
-
268
- values[:environment][key] = password
258
+ values[:environment][key] = CtxCli::Terminal.new(input: input, out: output).password(label: label)
269
259
  end
270
260
  private_class_method :read_password, :parser_for
271
261
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "ctx_cli/terminal"
4
5
  require_relative "../config/initializer"
5
6
  require_relative "../config/defaults"
6
7
 
@@ -98,7 +99,7 @@ module Net
98
99
 
99
100
  # 标准输入凭据协议独占输入;只有真实终端才允许配置问答。
100
101
  def terminal?
101
- !@options[:stdin_credentials] && @input.respond_to?(:tty?) && @input.tty? && @output.respond_to?(:tty?) && @output.tty?
102
+ !@options[:stdin_credentials] && CtxCli::Terminal.tty?(@input, @output)
102
103
  end
103
104
 
104
105
  # 创建和定制为独立选择;显式 --init 默认直接生成已识别值。
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
- require "tempfile"
4
+ require "ctx_cli/initialization"
5
+ require "ctx_cli/terminal"
5
6
  require_relative "../settings"
6
7
  require_relative "../../config"
7
8
 
@@ -14,6 +15,7 @@ module Net
14
15
  # 绑定配置问答的输入输出,保留调用方对 IO 的所有权。
15
16
  def initialize(input: $stdin, output: $stderr)
16
17
  @input, @output = input, output
18
+ @writer = CtxCli::Initialization.new(prefix: ".net-connector-config-")
17
19
  end
18
20
 
19
21
  # 完成默认值校验与可选问答后独占发布新配置,不覆盖已存在文件。
@@ -26,7 +28,7 @@ module Net
26
28
 
27
29
  values = YAML.safe_load(YAML.dump(document), aliases: false)
28
30
  validate!(values)
29
- interactive = @input.respond_to?(:tty?) && @input.tty? && @output.respond_to?(:tty?) && @output.tty? if interactive.nil?
31
+ interactive = CtxCli::Terminal.tty?(@input, @output) if interactive.nil?
30
32
  configure(values, mode: mode, inherited_parallel: inherited_parallel) if interactive && answer("进入交互配置?[y/N]", "n").casecmp?("y")
31
33
  validate!(values)
32
34
  name = mode == :topology ? "topology" : "backup"
@@ -43,14 +45,7 @@ module Net
43
45
  private
44
46
 
45
47
  # 判断已有配置是否为普通文件;符号链接或其他类型不作为可用配置。
46
- def existing?(path)
47
- stat = File.lstat(path)
48
- raise ArgumentError, "config path must be a regular file" unless stat.file?
49
-
50
- true
51
- rescue Errno::ENOENT
52
- false
53
- end
48
+ def existing?(path) = @writer.existing?(path)
54
49
 
55
50
  # 通过完整策略快照校验候选文档,拒绝生成不可执行的配置。
56
51
  def validate!(document)
@@ -118,51 +113,8 @@ module Net
118
113
  end
119
114
  end
120
115
 
121
- # 尝试关闭目录与临时文件全部句柄,清理失败不覆盖原始异常。
122
- def close_files(file, parent, primary)
123
- Thread.handle_interrupt(Exception => :never) do
124
- error = nil
125
- [-> { parent&.close }, -> { file&.close! }].each do |close|
126
- begin
127
- close.call
128
- rescue Exception => failure # rubocop:disable Lint/RescueException -- 两个资源均尝试关闭,保留首个故障。
129
- error ||= failure
130
- end
131
- end
132
- raise error if error && !primary
133
- end
134
- end
135
-
136
- # 先写入并同步私有临时文件,再以独占硬链接发布完整配置并同步目录。
137
- # 最终路径只在内容写全并同步后出现;已有目标由独占链接保护,取消也不能留下半份配置。
138
- def write(path, contents)
139
- directory = File.realpath(File.dirname(File.expand_path(path)))
140
- destination = File.join(directory, File.basename(path))
141
- file = parent = nil
142
- begin
143
- Thread.handle_interrupt(Exception => :never) { file = Tempfile.new([".net-connector-config-", ".tmp"], directory) }
144
- file.binmode
145
- file.chmod(0o600)
146
- file.write(contents)
147
- file.flush
148
- file.fsync
149
- begin
150
- File.link(file.path, destination)
151
- rescue Errno::EEXIST
152
- return :exists if existing?(destination)
153
-
154
- raise
155
- end
156
- Thread.handle_interrupt(Exception => :never) { parent = File.open(directory, File::RDONLY | File::NOFOLLOW) }
157
- parent.fsync
158
- :created
159
- rescue Exception => failure # rubocop:disable Lint/RescueException -- 清理不能覆盖输入取消或发布故障。
160
- primary = failure
161
- raise
162
- ensure
163
- close_files(file, parent, primary)
164
- end
165
- end
116
+ # 公开文档已校验;排他发布、同步与清理由公共初始化器负责。
117
+ def write(path, contents) = @writer.create(path: path, contents: contents)
166
118
  end
167
119
  end
168
120
  end
@@ -3,6 +3,6 @@
3
3
  module Net
4
4
  module Connector
5
5
  # gemspec、CLI 与发布工具共用此版本,运行时读取不触发设备依赖加载。
6
- VERSION = "0.17.0"
6
+ VERSION = "0.17.1"
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,14 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: net-connector
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.0
4
+ version: 0.17.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - net-connector contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-21 00:00:00.000000000 Z
10
+ date: 2026-10-22 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ctx_cli
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.0
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: expect-pty
14
28
  requirement: !ruby/object:Gem::Requirement