vagrant-docker-networks-manager 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.
@@ -0,0 +1,553 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ipaddr"
5
+ require "open3"
6
+ require "optparse"
7
+ require "ostruct"
8
+ require_relative "network_builder"
9
+ require_relative "helpers"
10
+ require_relative "util"
11
+ require_relative "version"
12
+
13
+ module VagrantDockerNetworksManager
14
+ class Command < Vagrant.plugin("2", :command)
15
+ def execute
16
+ argv = @argv.dup
17
+ @opts = { quiet: false, no_emoji: false, json: false, yes: false, lang: nil, with_containers: false }
18
+
19
+ UiHelpers.setup_i18n!
20
+
21
+ OptionParser.new do |o|
22
+ o.on("--quiet", "Reduce output (hide info)") { @opts[:quiet] = true }
23
+ o.on("--no-emoji", "Disable emojis in output") { @opts[:no_emoji] = true }
24
+ o.on("--json", "Normalized JSON output for all commands") { @opts[:json] = true }
25
+ o.on("--yes", "-y", "Auto-confirm destructive operations") { @opts[:yes] = true }
26
+ o.on("--lang LANG", "Force language: en|fr") { |v| @opts[:lang] = v }
27
+ o.on(
28
+ '--with-containers',
29
+ 'When destroying a network, also remove attached containers'
30
+ ) { @opts[:with_containers] = true }
31
+ end.permute!(argv)
32
+
33
+ begin
34
+ if @opts[:lang]
35
+ UiHelpers.set_locale!(@opts[:lang])
36
+ elsif ENV["VDNM_LANG"]
37
+ UiHelpers.set_locale!(ENV["VDNM_LANG"])
38
+ else
39
+ UiHelpers.setup_i18n!
40
+ end
41
+ rescue UiHelpers::UnsupportedLocaleError, UiHelpers::MissingTranslationError => ex
42
+ err(ex.message)
43
+ return 1
44
+ end
45
+
46
+ subcmd = argv[0]
47
+ network_name = argv[1]
48
+ subnet = argv[2]
49
+
50
+ needs_docker = %w[init destroy info reload list prune rename].include?(subcmd)
51
+ if needs_docker && !Util.docker_available?
52
+ msg = "#{UiHelpers.e(:error, no_emoji: @opts[:no_emoji])} #{I18n.t('errors.docker_unavailable')}"
53
+ return json_or_text("precheck", error: msg, code: 2)
54
+ end
55
+
56
+ case subcmd
57
+ when "init"
58
+ return json_or_text("init", error: I18n.t("usage.init"), code: 1) if argv.length < 3
59
+ return json_or_text(
60
+ 'init',
61
+ error: I18n.t('errors.network_exists'),
62
+ data: { name: network_name },
63
+ code: 1
64
+ ) if Util.docker_network_exists?(network_name)
65
+ return json_or_text(
66
+ 'init',
67
+ error: I18n.t('errors.invalid_subnet'),
68
+ data: { name: subnet },
69
+ code: 1
70
+ ) unless Util.valid_subnet?(subnet)
71
+ return json_or_text(
72
+ 'init',
73
+ error: I18n.t('errors.subnet_in_use'),
74
+ data: { name: subnet },
75
+ code: 1
76
+ ) if Util.docker_subnet_conflicts?(subnet)
77
+ return json_or_text("init",
78
+ error: I18n.t("errors.invalid_name"),
79
+ data: { name: network_name },
80
+ code: 1
81
+ ) unless network_name =~ /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,126}\z/
82
+ cfg = OpenStruct.new(
83
+ network_name: network_name,
84
+ network_type: "bridge",
85
+ network_subnet: subnet,
86
+ network_gateway: nil,
87
+ network_parent: nil,
88
+ network_attachable: false,
89
+ enable_ipv6: false,
90
+ ip_range: nil
91
+ )
92
+ args = NetworkBuilder.new(cfg).build_create_command_args
93
+ say "#{UiHelpers.e(:ongoing,
94
+ no_emoji: @opts[:no_emoji])} #{I18n.t('log.create_network', name: network_name, subnet: subnet)}"
95
+ ok = Util.sh!(*args)
96
+ if ok
97
+ json_or_text("init", data: { name: network_name, subnet: subnet })
98
+ else
99
+ json_or_text(
100
+ 'init',
101
+ error: I18n.t("errors.create_failed"),
102
+ data: { name: network_name, subnet: subnet },
103
+ code: 1)
104
+ end
105
+
106
+ when "destroy"
107
+ return json_or_text("destroy", error: I18n.t("usage.destroy"), code: 1) if argv.length < 2
108
+ return json_or_text("destroy", error: I18n.t("errors.network_not_found"), data: { name: network_name },
109
+ code: 1) unless Util.docker_network_exists?(network_name)
110
+
111
+ out, _e, st = Open3.capture3("docker", "network", "inspect", network_name)
112
+ containers = []
113
+ if st.success?
114
+ j = JSON.parse(out).first
115
+ containers = (j["Containers"] || {}).values.map { |c| c["Name"] }
116
+ end
117
+
118
+ prompt_key = @opts[:with_containers] ? "prompts.delete_network_with_containers" : "prompts.delete_network_only"
119
+ prompt_msg = I18n.t(prompt_key, name: network_name, count: containers.size)
120
+ unless @opts[:yes] || @opts[:json] || confirm!(
121
+ "#{UiHelpers.e(:question,
122
+ no_emoji: @opts[:no_emoji])} #{I18n.t('messages.confirm_continue', prompt: prompt_msg)}"
123
+ )
124
+ return json_or_text("destroy", error: I18n.t("errors.cancelled"), data: { name: network_name }, code: 1)
125
+ end
126
+
127
+ containers.each do |c|
128
+ say "#{UiHelpers.e(:ongoing, no_emoji: @opts[:no_emoji])} #{I18n.t('log.disconnect_container', name: c)}"
129
+ Util.sh!("network", "disconnect", "--force", network_name, c)
130
+ if @opts[:with_containers]
131
+ say "#{UiHelpers.e(:ongoing, no_emoji: @opts[:no_emoji])} #{I18n.t('log.remove_container', name: c)}"
132
+ Util.sh!("rm", "-f", c)
133
+ end
134
+ end
135
+
136
+ say "#{UiHelpers.e(:ongoing, no_emoji: @opts[:no_emoji])} #{I18n.t('log.remove_network', name: network_name)}"
137
+ ok = Util.sh!("network", "rm", network_name)
138
+ if ok
139
+ json_or_text("destroy",
140
+ data: { name: network_name, removed_containers: (@opts[:with_containers] ? containers : []) })
141
+ else
142
+ json_or_text("destroy", error: I18n.t("errors.remove_failed"),
143
+ data: { name: network_name, removed_containers: (@opts[:with_containers] ? containers : []) }, code: 1)
144
+ end
145
+
146
+ when "info"
147
+ return json_or_text("info", error: I18n.t("usage.info"), code: 1) if argv.length < 2
148
+ return json_or_text("info", error: I18n.t("errors.network_not_found"), data: { name: network_name },
149
+ code: 1) unless Util.docker_network_exists?(network_name)
150
+
151
+ out, _e, st = Open3.capture3("docker", "network", "inspect", network_name)
152
+ return json_or_text("info", error: I18n.t("errors.inspect_failed"), data: { name: network_name },
153
+ code: 1) unless st.success?
154
+ info = JSON.parse(out).first
155
+
156
+ if @opts[:json]
157
+ payload = {
158
+ network: {
159
+ "Name" => info["Name"],
160
+ "Id" => info["Id"],
161
+ "Driver" => info["Driver"],
162
+ "Subnets" => (info.dig("IPAM","Config") || []).map { |c| c["Subnet"] }.compact,
163
+ "Containers" => (info["Containers"] || {}).values.map do |c|
164
+ { "Name" => c["Name"], "IPv4" => c["IPv4Address"] }
165
+ end
166
+ }
167
+ }
168
+ return json_emit("info", status: "success", data: payload)
169
+ end
170
+
171
+ say "#{UiHelpers.e(:info, no_emoji: @opts[:no_emoji])} #{I18n.t('log.info_header', name: network_name)}"
172
+ puts " • ID: #{info['Id'][0...12]}"
173
+ puts " • Driver: #{info['Driver']}"
174
+ puts " • Subnet(s): #{(info.dig('IPAM','Config') || []).map { |c| c['Subnet'] }.compact.join(', ')}"
175
+ cons = info["Containers"] || {}
176
+ if cons.empty?
177
+ puts " • Connected containers: (none)"
178
+ else
179
+ puts " • Connected containers:"
180
+ cons.each_value { |c| puts " • #{c['Name']} (IP: #{c['IPv4Address']})" }
181
+ end
182
+ 0
183
+
184
+ when "reload"
185
+ return json_or_text("reload", error: I18n.t("usage.reload"), code: 1) if argv.length < 2
186
+
187
+ name = network_name
188
+ return json_or_text("reload", error: I18n.t("errors.network_not_found"), data: { name: name },
189
+ code: 1) unless Util.docker_network_exists?(name)
190
+
191
+ out, _e, st = Open3.capture3("docker", "network", "inspect", name)
192
+ return json_or_text("reload", error: I18n.t("errors.inspect_failed"), data: { name: name },
193
+ code: 1) unless st.success?
194
+ info = JSON.parse(out).first
195
+
196
+ driver = info["Driver"] || "bridge"
197
+ ipam_cfgs = (info.dig("IPAM","Config") || [])
198
+ subnets = ipam_cfgs.map { |c| c["Subnet"] }.compact
199
+ containers = (info["Containers"] || {}).values.map { |c| c["Name"] }
200
+ enable_ipv6 = info["EnableIPv6"]
201
+ attachable = info["Attachable"]
202
+ parent_opt = info.fetch("Options", {})["parent"]
203
+ labels_h = info["Labels"] || {}
204
+ labels_h["com.vagrant.plugin"] ||= "docker_networks_manager"
205
+
206
+ unless ENV["VDNM_SKIP_CONFLICTS"] == "1"
207
+ has_conflict = subnets.any? { |s| Util.docker_subnet_conflicts?(s, ignore_network: name) }
208
+ if has_conflict
209
+ return json_or_text("reload",
210
+ error: I18n.t("errors.subnet_in_use"),
211
+ data: { name: name, subnets: subnets }, code: 1)
212
+ end
213
+ end
214
+
215
+ unless @opts[:yes] || @opts[:json] || confirm!(
216
+ "#{UiHelpers.e(:question,
217
+ no_emoji: @opts[:no_emoji])} #{I18n.t('messages.confirm_continue', prompt: I18n.t('prompts.reload_same', name: name))}"
218
+ )
219
+ return json_or_text("reload", error: I18n.t("errors.cancelled"), data: { name: name }, code: 1)
220
+ end
221
+
222
+ containers.each do |c|
223
+ say "#{UiHelpers.e(:ongoing, no_emoji: @opts[:no_emoji])} #{I18n.t('log.disconnect_container', name: c)}"
224
+ Util.sh!("network", "disconnect", "--force", name, c)
225
+ end
226
+
227
+ say "#{UiHelpers.e(:ongoing, no_emoji: @opts[:no_emoji])} #{I18n.t('log.remove_network', name: name)}"
228
+ ok_rm = Util.sh!("network", "rm", name)
229
+ unless ok_rm
230
+ return json_or_text("reload", error: I18n.t("errors.remove_failed"), data: { name: name }, code: 1)
231
+ end
232
+
233
+ args = ["network", "create", "--driver", driver]
234
+ labels_h.each { |k,v| args += ["--label", "#{k}=#{v}"] if k && v }
235
+ ipam_cfgs.each do |c|
236
+ args += ["--subnet", c["Subnet"]] if c["Subnet"]
237
+ args += ["--gateway", c["Gateway"]] if c["Gateway"]
238
+ args += ["--ip-range", c["IPRange"]] if c["IPRange"]
239
+ end
240
+ args << "--ipv6" if enable_ipv6
241
+ args << "--attachable" if attachable
242
+ args += ["--opt", "parent=#{parent_opt}"] if parent_opt && driver == "macvlan"
243
+ say "#{UiHelpers.e(:ongoing,
244
+ no_emoji: @opts[:no_emoji])} #{I18n.t('log.create_network', name: name,
245
+ subnet: (subnets.empty? ? "-" : subnets.join(', ')))}"
246
+ ok_cr = Util.sh!(*args, name)
247
+ unless ok_cr
248
+ rendered = (["docker"] + args + [name]).map(&:to_s).shelljoin
249
+ return json_or_text("reload", error: "#{I18n.t('errors.create_failed')} (#{rendered})",
250
+ data: { name: name, subnets: subnets }, code: 1)
251
+ end
252
+
253
+ reconnected = []
254
+ failed_reconnect = []
255
+ containers.each do |c|
256
+ if Util.sh!("network", "connect", name, c)
257
+ reconnected << c
258
+ else
259
+ failed_reconnect << c
260
+ end
261
+ end
262
+
263
+ data = { name: name, subnets: subnets, reconnected: reconnected, failed_reconnect: failed_reconnect }
264
+ if failed_reconnect.any?
265
+ json_or_text("reload", error: I18n.t("errors.partial_failure"), data: data, code: 1)
266
+ else
267
+ json_or_text("reload", data: data)
268
+ end
269
+ when "prune"
270
+ nets = Util.list_plugin_networks_detailed
271
+ to_delete = nets.select { |n| n[:containers].to_i == 0 }
272
+
273
+ if to_delete.empty?
274
+ if @opts[:json]
275
+ return json_emit("prune", status: "success", data: { pruned: 0, items: [] })
276
+ else
277
+ say "#{UiHelpers.e(:info, no_emoji: @opts[:no_emoji])} #{I18n.t('messages.prune_none')}"
278
+ return 0
279
+ end
280
+ end
281
+
282
+ unless @opts[:yes] || @opts[:json] || confirm!(
283
+ "#{UiHelpers.e(:question,
284
+ no_emoji: @opts[:no_emoji])} #{I18n.t('messages.confirm_continue',
285
+ prompt: I18n.t('prompts.prune', count: to_delete.size))}"
286
+ )
287
+ return json_or_text("prune", error: I18n.t("errors.cancelled"), data: { candidates: to_delete.map do |n|
288
+ n[:name]
289
+ end }, code: 1)
290
+ end
291
+
292
+ ok_all = true
293
+ to_delete.each do |n|
294
+ say "#{UiHelpers.e(:ongoing,
295
+ no_emoji: @opts[:no_emoji])} #{I18n.t('log.remove_network', name: n[:name])}" unless @opts[:quiet]
296
+ ok_all &&= Util.sh!("network", "rm", n[:name])
297
+ end
298
+
299
+ if ok_all
300
+ json_or_text("prune", data: { pruned: to_delete.size, items: to_delete.map { |n| n[:name] } })
301
+ else
302
+ json_or_text("prune", error: I18n.t("errors.partial_failure"), data: { attempted: to_delete.map do |n|
303
+ n[:name]
304
+ end }, code: 1)
305
+ end
306
+
307
+ when "list"
308
+ nets = Util.list_plugin_networks
309
+ if @opts[:json]
310
+ return json_emit("list", status: "success", data: { count: nets.size, items: nets })
311
+ end
312
+
313
+ if nets.empty?
314
+ say "#{UiHelpers.e(:info, no_emoji: @opts[:no_emoji])} #{I18n.t('messages.no_networks')}"
315
+ return 0
316
+ end
317
+
318
+ say "#{UiHelpers.e(:info, no_emoji: @opts[:no_emoji])} #{I18n.t('messages.networks_header')}"
319
+ headers = ["Name", "Driver", "Scope", "Subnet(s)"]
320
+ name_w = ([headers[0].length] + nets.map { |r| r[:name].length }).max
321
+ driver_w = ([headers[1].length] + nets.map { |r| r[:driver].length }).max
322
+ scope_w = ([headers[2].length] + nets.map { |r| r[:scope].length }).max
323
+ subnet_w = ([headers[3].length] + nets.map { |r| r[:subnets].length }).max
324
+
325
+ header_line = [
326
+ headers[0].ljust(name_w),
327
+ headers[1].ljust(driver_w),
328
+ headers[2].ljust(scope_w),
329
+ headers[3].ljust(subnet_w)
330
+ ].join(' ')
331
+ puts " #{header_line}"
332
+ puts " #{'-' * name_w} #{'-' * driver_w} #{'-' * scope_w} #{'-' * subnet_w}"
333
+
334
+ nets.sort_by { |r| r[:name] }.each do |r|
335
+ puts " %-#{name_w}s %-#{driver_w}s %-#{scope_w}s %s" % [r[:name], r[:driver], r[:scope], r[:subnets]]
336
+ end
337
+ 0
338
+
339
+ when "rename"
340
+ return json_or_text("rename", error: I18n.t("usage.rename"), code: 1) if argv.length < 3
341
+
342
+ old_name = argv[1]
343
+ new_name = argv[2]
344
+ new_subnet = argv[3]
345
+
346
+ return json_or_text("rename", error: I18n.t("errors.network_not_found"), data: { old: old_name },
347
+ code: 1) unless Util.docker_network_exists?(old_name)
348
+ if new_name != old_name && Util.docker_network_exists?(new_name)
349
+ return json_or_text("rename", error: I18n.t("errors.target_exists"), data: { new: new_name }, code: 1)
350
+ end
351
+
352
+ o, _e, st = Open3.capture3("docker", "network", "inspect", old_name)
353
+ return json_or_text("rename", error: I18n.t("errors.inspect_failed"), data: { old: old_name },
354
+ code: 1) unless st.success?
355
+ j = JSON.parse(o).first
356
+ driver = j["Driver"] || "bridge"
357
+ old_subnets = (j.dig("IPAM","Config") || []).map { |c| c["Subnet"] }.compact
358
+ containers = (j["Containers"] || {}).values.map { |c| c["Name"] }
359
+ enable_ipv6 = j["EnableIPv6"]
360
+ attachable = j["Attachable"]
361
+ parent_opt = j.fetch("Options", {})["parent"]
362
+ labels_h = j["Labels"] || {}
363
+ labels_h["com.vagrant.plugin"] ||= "docker_networks_manager"
364
+
365
+ target_subnet = new_subnet || old_subnets.first
366
+ return json_or_text("rename", error: I18n.t("errors.invalid_subnet"), data: { subnet: target_subnet },
367
+ code: 1) unless Util.valid_subnet?(target_subnet)
368
+
369
+ old_norms = old_subnets.map { |s| Util.normalize_cidr(s) }.compact
370
+ target_norm = Util.normalize_cidr(target_subnet)
371
+ same_subnet = old_norms.include?(target_norm)
372
+
373
+ if !same_subnet && Util.docker_subnet_conflicts?(target_subnet, ignore_network: old_name)
374
+ return json_or_text("rename", error: I18n.t("errors.subnet_in_use"), data: { subnet: target_subnet }, code: 1)
375
+ end
376
+
377
+ prompt_msg =
378
+ if new_name == old_name && same_subnet
379
+ I18n.t("prompts.reload_same", name: old_name)
380
+ elsif new_name == old_name && !same_subnet
381
+ I18n.t("prompts.reload_network", name: old_name)
382
+ elsif same_subnet
383
+ I18n.t("prompts.rename_same_subnet", old: old_name, new: new_name)
384
+ else
385
+ I18n.t("prompts.rename_network", old: old_name, new: new_name)
386
+ end
387
+ unless @opts[:yes] || @opts[:json] || confirm!(
388
+ "#{UiHelpers.e(:question,
389
+ no_emoji: @opts[:no_emoji])} #{I18n.t('messages.confirm_continue', prompt: prompt_msg)}"
390
+ )
391
+ return json_or_text("rename", error: I18n.t("errors.cancelled"), data: { old: old_name, new: new_name },
392
+ code: 1)
393
+ end
394
+
395
+ reconnected = []
396
+ failed_reconnect = []
397
+
398
+ create_with_retry = lambda do |name_to_create, subnet_to_use|
399
+ say "#{UiHelpers.e(:ongoing,
400
+ no_emoji: @opts[:no_emoji])} #{I18n.t('log.create_network', name: name_to_create, subnet: subnet_to_use)}"
401
+ args = ["network", "create", "--driver", driver, "--subnet", subnet_to_use]
402
+ labels_h.each { |k,v| args += ["--label", "#{k}=#{v}"] if k && v }
403
+ args << "--ipv6" if enable_ipv6
404
+ args << "--attachable" if attachable
405
+ args += ["--opt", "parent=#{parent_opt}"] if parent_opt && driver == "macvlan"
406
+ ok = Util.sh!(*args, name_to_create)
407
+ unless ok
408
+ rendered = (["docker"] + args + [name_to_create]).map(&:to_s).shelljoin
409
+ err("#{UiHelpers.e(:error, no_emoji: @opts[:no_emoji])} #{I18n.t('errors.create_failed')} (#{rendered})")
410
+ end
411
+ ok
412
+ end
413
+
414
+ if new_name == old_name || same_subnet
415
+ target_name = new_name
416
+
417
+ containers.each do |c|
418
+ say "#{UiHelpers.e(:ongoing, no_emoji: @opts[:no_emoji])} #{I18n.t('log.disconnect_container', name: c)}"
419
+ Util.sh!("network", "disconnect", "--force", old_name, c)
420
+ end
421
+
422
+ say "#{UiHelpers.e(:ongoing, no_emoji: @opts[:no_emoji])} #{I18n.t('log.remove_network', name: old_name)}"
423
+ ok_rm = Util.sh!("network", "rm", old_name)
424
+ return json_or_text("rename", error: I18n.t("errors.remove_failed"), data: { name: old_name },
425
+ code: 1) unless ok_rm
426
+
427
+ ok_cr = create_with_retry.call(target_name, target_subnet)
428
+ return json_or_text("rename", error: I18n.t("errors.create_failed"),
429
+ data: { new: target_name, subnet: target_subnet }, code: 1) unless ok_cr
430
+
431
+ containers.each do |c|
432
+ if Util.sh!("network", "connect", target_name, c)
433
+ reconnected << c
434
+ else
435
+ failed_reconnect << c
436
+ end
437
+ end
438
+ else
439
+ ok_cr = create_with_retry.call(new_name, target_subnet)
440
+ return json_or_text("rename", error: I18n.t("errors.create_failed"),
441
+ data: { new: new_name, subnet: target_subnet }, code: 1) unless ok_cr
442
+
443
+ containers.each do |c|
444
+ if Util.sh!("network", "connect", new_name, c)
445
+ reconnected << c
446
+ say "#{UiHelpers.e(:ongoing,
447
+ no_emoji: @opts[:no_emoji])} #{I18n.t('docker_provider.network_connect')} #{c} -> #{new_name}"
448
+ else
449
+ failed_reconnect << c
450
+ end
451
+ end
452
+
453
+ reconnected.each do |c|
454
+ say "#{UiHelpers.e(:ongoing,
455
+ no_emoji: @opts[:no_emoji])} #{I18n.t('log.disconnect_container', name: c)} <- #{old_name}"
456
+ Util.sh!("network", "disconnect", "--force", old_name, c)
457
+ end
458
+
459
+ say "#{UiHelpers.e(:ongoing, no_emoji: @opts[:no_emoji])} #{I18n.t('log.remove_network', name: old_name)}"
460
+ ok_rm = Util.sh!("network", "rm", old_name)
461
+ unless ok_rm
462
+ error_data = {
463
+ old: old_name,
464
+ new: new_name,
465
+ subnet: target_subnet,
466
+ reconnected: reconnected,
467
+ failed_reconnect: failed_reconnect
468
+ }
469
+ return json_or_text('rename', error: I18n.t('errors.remove_failed'), data: error_data, code: 1)
470
+ end
471
+ end
472
+
473
+ result_data = {
474
+ old: old_name,
475
+ new: new_name,
476
+ subnet: target_subnet,
477
+ reconnected: reconnected,
478
+ failed_reconnect: failed_reconnect
479
+ }
480
+
481
+ if failed_reconnect.any?
482
+ json_or_text("rename", error: I18n.t("errors.partial_failure"), data: result_data, code: 1)
483
+ else
484
+ json_or_text("rename", data: result_data)
485
+ end
486
+
487
+ when "version"
488
+ if @opts[:json]
489
+ return json_emit("version", status: "success", data: { version: VagrantDockerNetworksManager::VERSION })
490
+ end
491
+ say "#{UiHelpers.e(:version, no_emoji: @opts[:no_emoji])} #{I18n.t('log.version_line', version: VagrantDockerNetworksManager::VERSION)}"
492
+ 0
493
+
494
+ when "help"
495
+ VagrantDockerNetworksManager::UiHelpers.print_topic_help(argv[1])
496
+ 0
497
+
498
+ else
499
+ return json_or_text("unknown", error: I18n.t("errors.unknown_command"), code: 1)
500
+ end
501
+ end
502
+
503
+ private
504
+
505
+ def json_or_text(action, error: nil, data: {}, code: 0)
506
+ if @opts[:json]
507
+ status = error ? "error" : "success"
508
+ return json_emit(action, status: status, data: data, error: error, code: code)
509
+ end
510
+
511
+ if error
512
+ err "#{UiHelpers.e(:error, no_emoji: @opts[:no_emoji])} #{error}"
513
+ return code
514
+ else
515
+ say "#{UiHelpers.e(:success, no_emoji: @opts[:no_emoji])} #{I18n.t('log.ok')}" unless @opts[:quiet]
516
+ return 0
517
+ end
518
+ end
519
+
520
+ def json_emit(action, status:, data: {}, error: nil, code: 0)
521
+ payload = { action: action, status: status, code: code }
522
+ payload[:data] = data unless data.nil? || data.empty?
523
+ payload[:error] = error if error
524
+ puts JSON.generate(payload)
525
+ code
526
+ end
527
+
528
+ def say(msg)
529
+ return if @opts[:quiet] || @opts[:json]
530
+ if defined?(@env) && @env && @env.ui
531
+ @env.ui.info(msg)
532
+ else
533
+ puts msg
534
+ end
535
+ end
536
+
537
+ def err(msg)
538
+ return if @opts[:json]
539
+ if defined?(@env) && @env && @env.ui
540
+ @env.ui.error(msg)
541
+ else
542
+ warn msg
543
+ end
544
+ end
545
+
546
+ def confirm!(prompt)
547
+ return true if @opts[:yes]
548
+ print "#{prompt} "
549
+ ans = $stdin.gets.to_s.strip.downcase
550
+ %w[y yes o oui].include?(ans)
551
+ end
552
+ end
553
+ end