kitsune-kit 0.4.1 → 0.6.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.
Files changed (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -2
  3. data/CONTRIBUTING.md +83 -0
  4. data/README.md +112 -135
  5. data/SECURITY.md +25 -0
  6. data/bin/kit +4 -1
  7. data/docs/architecture/decisions/0001-product-boundary.md +23 -0
  8. data/docs/architecture/decisions/0002-core-and-interfaces.md +23 -0
  9. data/docs/architecture/decisions/0003-configuration-state-and-secrets.md +23 -0
  10. data/docs/architecture/decisions/0004-supported-platforms.md +19 -0
  11. data/docs/architecture/decisions/0005-operation-semantics.md +19 -0
  12. data/docs/architecture/decisions/0006-tui-backend.md +21 -0
  13. data/docs/architecture/decisions/0007-compose-customization.md +30 -0
  14. data/docs/architecture/provider-adapters.md +49 -0
  15. data/docs/architecture.md +93 -0
  16. data/docs/commands.md +224 -0
  17. data/docs/configuration.md +226 -0
  18. data/docs/getting-started.md +133 -0
  19. data/docs/providers/digitalocean.md +52 -0
  20. data/docs/releasing.md +43 -0
  21. data/docs/roadmap.md +46 -0
  22. data/docs/security-audit.md +130 -0
  23. data/docs/security.md +116 -0
  24. data/docs/services/compose.md +164 -0
  25. data/docs/services/postgres.md +78 -0
  26. data/docs/services/redis.md +53 -0
  27. data/docs/testing.md +200 -0
  28. data/docs/troubleshooting.md +123 -0
  29. data/docs/tui.md +57 -0
  30. data/lib/kitsune/kit/adapters/confirming_host_key_verifier.rb +42 -0
  31. data/lib/kitsune/kit/adapters/digitalocean_provider.rb +217 -0
  32. data/lib/kitsune/kit/adapters/fake_clock.rb +34 -0
  33. data/lib/kitsune/kit/adapters/fake_provider.rb +107 -0
  34. data/lib/kitsune/kit/adapters/fake_reporter.rb +34 -0
  35. data/lib/kitsune/kit/adapters/fake_secret_store.rb +35 -0
  36. data/lib/kitsune/kit/adapters/fake_state_store.rb +91 -0
  37. data/lib/kitsune/kit/adapters/fake_transport.rb +56 -0
  38. data/lib/kitsune/kit/adapters/net_ssh_transport.rb +155 -0
  39. data/lib/kitsune/kit/adapters/provider.rb +34 -0
  40. data/lib/kitsune/kit/adapters/transport.rb +22 -0
  41. data/lib/kitsune/kit/adapters/transport_factory.rb +116 -0
  42. data/lib/kitsune/kit/application.rb +170 -0
  43. data/lib/kitsune/kit/cancellation.rb +21 -0
  44. data/lib/kitsune/kit/cli.rb +879 -64
  45. data/lib/kitsune/kit/clock.rb +11 -0
  46. data/lib/kitsune/kit/configuration.rb +577 -0
  47. data/lib/kitsune/kit/errors.rb +98 -0
  48. data/lib/kitsune/kit/events.rb +55 -0
  49. data/lib/kitsune/kit/operations/ensure_dns_records.rb +165 -0
  50. data/lib/kitsune/kit/operations/ensure_server.rb +135 -0
  51. data/lib/kitsune/kit/operations/ensure_service.rb +302 -0
  52. data/lib/kitsune/kit/operations/remote_script.rb +214 -0
  53. data/lib/kitsune/kit/operations/service_backup.rb +71 -0
  54. data/lib/kitsune/kit/operations/service_files.rb +131 -0
  55. data/lib/kitsune/kit/operations/service_firewall.rb +190 -0
  56. data/lib/kitsune/kit/operations/service_state.rb +59 -0
  57. data/lib/kitsune/kit/plan.rb +72 -0
  58. data/lib/kitsune/kit/reporters/human.rb +89 -0
  59. data/lib/kitsune/kit/reporters/json.rb +65 -0
  60. data/lib/kitsune/kit/reporters/reporter.rb +11 -0
  61. data/lib/kitsune/kit/result.rb +28 -0
  62. data/lib/kitsune/kit/run_journal.rb +102 -0
  63. data/lib/kitsune/kit/run_logger.rb +37 -0
  64. data/lib/kitsune/kit/scripts/docker.sh +142 -0
  65. data/lib/kitsune/kit/scripts/firewall.sh +210 -0
  66. data/lib/kitsune/kit/scripts/metrics.sh +59 -0
  67. data/lib/kitsune/kit/scripts/ssh.sh +95 -0
  68. data/lib/kitsune/kit/scripts/swap.sh +86 -0
  69. data/lib/kitsune/kit/scripts/unattended.sh +86 -0
  70. data/lib/kitsune/kit/scripts/user.sh +114 -0
  71. data/lib/kitsune/kit/secret_filter.rb +54 -0
  72. data/lib/kitsune/kit/secret_store.rb +32 -0
  73. data/lib/kitsune/kit/secret_stores/store.rb +12 -0
  74. data/lib/kitsune/kit/service_compose.rb +272 -0
  75. data/lib/kitsune/kit/state_store.rb +158 -0
  76. data/lib/kitsune/kit/state_stores/store.rb +15 -0
  77. data/lib/kitsune/kit/tui/actions.rb +72 -0
  78. data/lib/kitsune/kit/tui/application.rb +35 -0
  79. data/lib/kitsune/kit/tui/controller.rb +162 -0
  80. data/lib/kitsune/kit/tui/renderer.rb +134 -0
  81. data/lib/kitsune/kit/tui/state.rb +18 -0
  82. data/lib/kitsune/kit/tui/store.rb +88 -0
  83. data/lib/kitsune/kit/tui/terminal.rb +95 -0
  84. data/lib/kitsune/kit/version.rb +1 -1
  85. data/lib/kitsune/kit/workflows/apply_plan.rb +145 -0
  86. data/lib/kitsune/kit/workflows/base.rb +31 -0
  87. data/lib/kitsune/kit/workflows/build_plan.rb +33 -0
  88. data/lib/kitsune/kit/workflows/destroy_server.rb +84 -0
  89. data/lib/kitsune/kit/workflows/doctor.rb +225 -0
  90. data/lib/kitsune/kit/workflows/eject_compose.rb +82 -0
  91. data/lib/kitsune/kit/workflows/environment_selection.rb +70 -0
  92. data/lib/kitsune/kit/workflows/import_server.rb +100 -0
  93. data/lib/kitsune/kit/workflows/initialize_project.rb +129 -0
  94. data/lib/kitsune/kit/workflows/inspect_environment.rb +44 -0
  95. data/lib/kitsune/kit/workflows/rollback.rb +54 -0
  96. data/lib/kitsune/kit/workflows/support_bundle.rb +82 -0
  97. data/lib/kitsune/kit.rb +42 -2
  98. metadata +125 -79
  99. data/.rspec +0 -3
  100. data/Rakefile +0 -8
  101. data/kitsune-kit-logo.jpg +0 -0
  102. data/lib/kitsune/blueprints/.env.template +0 -31
  103. data/lib/kitsune/blueprints/docker/postgres.yml +0 -27
  104. data/lib/kitsune/blueprints/docker/redis.yml +0 -23
  105. data/lib/kitsune/blueprints/kit.env.template +0 -1
  106. data/lib/kitsune/kit/ansi_color.rb +0 -78
  107. data/lib/kitsune/kit/commands/bootstrap.rb +0 -134
  108. data/lib/kitsune/kit/commands/bootstrap_docker.rb +0 -66
  109. data/lib/kitsune/kit/commands/dns.rb +0 -112
  110. data/lib/kitsune/kit/commands/init.rb +0 -148
  111. data/lib/kitsune/kit/commands/install_docker_engine.rb +0 -146
  112. data/lib/kitsune/kit/commands/postinstall_docker.rb +0 -142
  113. data/lib/kitsune/kit/commands/provision.rb +0 -43
  114. data/lib/kitsune/kit/commands/setup_do_metrics.rb +0 -123
  115. data/lib/kitsune/kit/commands/setup_docker_prereqs.rb +0 -151
  116. data/lib/kitsune/kit/commands/setup_firewall.rb +0 -132
  117. data/lib/kitsune/kit/commands/setup_postgres_docker.rb +0 -246
  118. data/lib/kitsune/kit/commands/setup_redis_docker.rb +0 -241
  119. data/lib/kitsune/kit/commands/setup_swap.rb +0 -151
  120. data/lib/kitsune/kit/commands/setup_unattended.rb +0 -132
  121. data/lib/kitsune/kit/commands/setup_user.rb +0 -189
  122. data/lib/kitsune/kit/commands/ssh.rb +0 -46
  123. data/lib/kitsune/kit/commands/switch_env.rb +0 -42
  124. data/lib/kitsune/kit/defaults.rb +0 -91
  125. data/lib/kitsune/kit/env_loader.rb +0 -41
  126. data/lib/kitsune/kit/options_builder.rb +0 -26
  127. data/lib/kitsune/kit/provisioner.rb +0 -107
  128. data/sig/kitsune/kit.rbs +0 -6
@@ -1,95 +1,910 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "thor"
2
- require_relative "ansi_color"
3
- require_relative "env_loader"
4
- require_relative "commands/init"
5
- require_relative "commands/switch_env"
6
- require_relative "commands/provision"
7
- require_relative "commands/dns"
8
- require_relative "commands/setup_user"
9
- require_relative "commands/setup_firewall"
10
- require_relative "commands/setup_unattended"
11
- require_relative "commands/setup_swap"
12
- require_relative "commands/setup_do_metrics"
13
- require_relative "commands/bootstrap"
14
- require_relative "commands/setup_docker_prereqs"
15
- require_relative "commands/install_docker_engine"
16
- require_relative "commands/postinstall_docker"
17
- require_relative "commands/bootstrap_docker"
18
- require_relative "commands/setup_postgres_docker"
19
- require_relative "commands/setup_redis_docker"
20
- require_relative "commands/ssh"
4
+ require_relative "../kit"
21
5
 
22
6
  module Kitsune
23
7
  module Kit
24
8
  class CLI < Thor
25
- def self.dispatch(m, args, options, config)
26
- if args.include?("-v") || args.include?("--version")
27
- puts "Kitsune Kit v#{Kitsune::Kit::VERSION}"
28
- exit(0)
9
+ package_name "Kitsune Kit"
10
+
11
+ # Thor may add framework-level commands between minor releases. Keep the
12
+ # Keep the Kitsune Kit public surface intentional instead of inheriting those commands.
13
+ remove_task :tree if all_tasks.key?("tree")
14
+
15
+ class_option :env, type: :string, aliases: "-e", desc: "Environment name"
16
+ class_option :root, type: :string, default: Dir.pwd, desc: "Project root"
17
+ class_option :config, type: :string, desc: "Alternative project configuration file"
18
+ class_option :format, type: :string, default: "human", enum: %w[human json], desc: "Output format"
19
+ class_option :no_color, type: :boolean, default: false, desc: "Disable ANSI colors"
20
+ class_option :no_input, type: :boolean, default: false, desc: "Fail instead of prompting"
21
+ class_option :yes, type: :boolean, default: false, desc: "Approve non-destructive changes"
22
+ class_option :dry_run, type: :boolean, default: false, desc: "Build and display a plan without applying"
23
+ class_option :quiet, type: :boolean, default: false, desc: "Show only warnings, errors and final results"
24
+ class_option :verbose, type: :boolean, default: false, desc: "Show additional operational details"
25
+ class_option :debug, type: :boolean, default: false, desc: "Show technical error details"
26
+ class_option :log, type: :boolean, default: true, desc: "Write redacted local execution logs"
27
+ class_option :timeout, type: :numeric, desc: "Maximum seconds allowed for each remote step"
28
+ class_option :trust_host_key, type: :string, desc: "Trust this exact SHA256 SSH host-key fingerprint"
29
+
30
+ def self.exit_on_failure? = true
31
+
32
+ def self.start(given_args = ARGV, config = {})
33
+ config[:shell] ||= Thor::Base.shell.new
34
+ dispatch(nil, normalized_arguments(given_args), nil, config)
35
+ rescue Thor::Error => e
36
+ config[:debug] || ENV["THOR_DEBUG"] == "1" ? raise(e) : config[:shell].error(e.message)
37
+
38
+ exit(2)
39
+ rescue Errno::EPIPE
40
+ exit(0)
41
+ end
42
+
43
+ def self.normalized_arguments(arguments)
44
+ values = arguments.dup
45
+ return ["help", values.first] if values.length == 2 && values.last == "help" && tasks.key?(values.first)
46
+
47
+ values
48
+ end
49
+
50
+ desc "version", "Print the Kitsune Kit version"
51
+ def version
52
+ puts "Kitsune Kit #{VERSION}"
53
+ end
54
+
55
+ map %w[-v --version] => :version
56
+
57
+ desc "init", "Create a new .kitsune project configuration"
58
+ option :force, type: :boolean, default: false, desc: "Replace generated configuration files"
59
+ def init
60
+ execute do
61
+ files = Workflows::InitializeProject.new(root: options[:root]).call(force: options[:force])
62
+ if json?
63
+ flush_json(build_reporter(build_secret_filter), Result.success(files), "init", "development")
64
+ else
65
+ puts "Created Kitsune Kit configuration:"
66
+ files.each { |file| puts " #{file}" }
67
+ puts "Next: edit .kitsune/config.yml, export DO_API_TOKEN, then run `kit doctor`."
68
+ end
69
+ 0
29
70
  end
30
-
31
- unless ["version", "init", "switch_env", "help", nil].include?(args.first)
32
- Kitsune::Kit::EnvLoader.load!
71
+ end
72
+
73
+ desc "doctor", "Check configuration, credentials and managed resources without changing them"
74
+ def doctor
75
+ execute do
76
+ app, reporter = build_application
77
+ result = Workflows::Doctor.new(
78
+ config: app.config,
79
+ provider: app.provider,
80
+ state_store: app.state_store,
81
+ transport_factory: app.transport_factory,
82
+ operations: app.operations,
83
+ event_bus: app.event_bus
84
+ ).call
85
+ if json?
86
+ flush_json(reporter, result, "doctor", app.config.environment)
87
+ else
88
+ puts
89
+ print_checks(result.value)
90
+ end
91
+ result.success? ? 0 : 1
33
92
  end
93
+ end
34
94
 
35
- super
95
+ desc "plan", "Inspect infrastructure changes without applying them"
96
+ def plan
97
+ execute do
98
+ app, reporter = build_application
99
+ result = Workflows::BuildPlan.new(
100
+ config: app.config,
101
+ operations: app.operations,
102
+ event_bus: app.event_bus
103
+ ).call
104
+ json? ? flush_json(reporter, result, "plan", app.config.environment) : print_plan(result.value)
105
+ 0
106
+ end
36
107
  end
37
108
 
38
- desc "version", "Show Kitsune Kit version"
39
- def version
40
- say "Kitsune Kit v#{Kitsune::Kit::VERSION}", :green
109
+ desc "apply", "Apply the desired infrastructure configuration"
110
+ def apply
111
+ execute do
112
+ app, reporter = build_application
113
+ plan_result = Workflows::BuildPlan.new(
114
+ config: app.config,
115
+ operations: app.operations,
116
+ event_bus: app.event_bus
117
+ ).call
118
+ plan = plan_result.value
119
+ print_plan(plan) unless json?
120
+
121
+ if options[:dry_run]
122
+ render_apply_terminal(reporter, plan_result, app, "Dry run: no changes were applied.")
123
+ next 0
124
+ end
125
+
126
+ unless plan.changed?
127
+ result = Result.success([], metadata: { unchanged: true })
128
+ render_apply_terminal(reporter, result, app, "No changes to apply.")
129
+ next 0
130
+ end
131
+
132
+ confirm_apply!(plan)
133
+ result = Workflows::ApplyPlan.new(
134
+ config: app.config,
135
+ operations: app.operations,
136
+ state_store: app.state_store,
137
+ event_bus: app.event_bus
138
+ ).call(plan: plan)
139
+ render_apply_success(reporter, result, app)
140
+ 0
141
+ end
142
+ end
143
+
144
+ desc "resume [RUN_ID]", "Resume the latest failed or interrupted apply run"
145
+ def resume(run_id = nil)
146
+ execute do
147
+ app, reporter = build_application
148
+ confirm_resume!(run_id)
149
+ result = Workflows::ApplyPlan.new(
150
+ config: app.config,
151
+ operations: app.operations,
152
+ state_store: app.state_store,
153
+ event_bus: app.event_bus
154
+ ).call(resume_from: run_id || true)
155
+ flush_json(reporter, result, "resume", app.config.environment) if json?
156
+ 0
157
+ end
158
+ end
159
+
160
+ desc "status", "Show the current server and managed-resource state"
161
+ def status
162
+ execute do
163
+ app, reporter = build_application
164
+ result = Workflows::InspectEnvironment.new(
165
+ config: app.config,
166
+ provider: app.provider,
167
+ state_store: app.state_store,
168
+ event_bus: app.event_bus
169
+ ).call
170
+ if json?
171
+ flush_json(reporter, result, "status", app.config.environment)
172
+ else
173
+ print_environment_status(result.value)
174
+ end
175
+ 0
176
+ end
41
177
  end
42
178
 
43
- desc "init", "Initialize Kitsune Kit project structure"
44
- subcommand "init", Kitsune::Kit::Commands::Init
179
+ desc "rollback", "Restore managed configuration while preserving the server and service data"
180
+ def rollback
181
+ execute do
182
+ app, reporter = build_application
183
+ confirm_named_action!("rollback", app.config.environment, option: :yes)
184
+ result = Workflows::Rollback.new(
185
+ config: app.config,
186
+ operations: app.operations.drop(1),
187
+ state_store: app.state_store,
188
+ event_bus: app.event_bus
189
+ ).call
190
+ flush_json(reporter, result, "rollback", app.config.environment) if json?
191
+ 0
192
+ end
193
+ end
194
+
195
+ desc "server ACTION", "Manage the server: show, create, configure, ssh, import, destroy"
196
+ option :confirm_destroy, type: :string, desc: "Exact server name required for destruction"
197
+ option :provider_id, type: :string, desc: "Exact provider ID required for state recovery"
198
+ option :confirm_import, type: :string, desc: "Exact server name required for state import"
199
+ def server(action)
200
+ execute do
201
+ app, reporter = build_application
202
+ result = dispatch_server_action(app, action)
203
+ flush_json(reporter, result, "server.#{action}", app.config.environment) if json? && result.is_a?(Result)
204
+ 0
205
+ end
206
+ end
207
+
208
+ desc "service TYPE ACTION [SUBACTION]",
209
+ "Manage postgres or redis, including Compose show, validate, diff and eject"
210
+ option :confirm_destroy, type: :string, desc: "Exact TYPE@ENV value required for data destruction"
211
+ option :backup_before_destroy, type: :boolean, default: false,
212
+ desc: "Create a restricted remote data backup before destroy-data"
213
+ option :force, type: :boolean, default: false, desc: "Replace an existing ejected Compose file and backup"
214
+ def service(type, action, subaction = nil)
215
+ execute do
216
+ app, reporter = action == "compose" ? build_local_application : build_application
217
+ result = if action == "compose"
218
+ dispatch_service_compose(app, type, subaction)
219
+ else
220
+ operation = find_service_operation(app, type, action)
221
+ dispatch_service_action(app, operation, type, action)
222
+ end
223
+ if json? && result.is_a?(Result)
224
+ command = ["service", type, action, subaction].compact.join(".")
225
+ flush_json(reporter, result, command, app.config.environment)
226
+ end
227
+ 0
228
+ end
229
+ end
230
+
231
+ desc "dns ACTION", "Manage configured DNS records: list, plan, apply, remove"
232
+ def dns(action)
233
+ execute do
234
+ app, reporter = build_application
235
+ operation = app.operations.find { |candidate| candidate.resource == "dns" }
236
+ raise Errors::ConfigurationError, "no DNS domains are configured" unless operation
237
+
238
+ result = dispatch_resource_action(app, operation, action)
239
+ flush_json(reporter, result, "dns.#{action}", app.config.environment) if json? && result.is_a?(Result)
240
+ 0
241
+ end
242
+ end
243
+
244
+ desc "env ACTION [NAME]", "Manage environments: list, current, use NAME"
245
+ def env(action, name = nil)
246
+ execute do
247
+ selection = Workflows::EnvironmentSelection.new(root: options[:root])
248
+ value = case action
249
+ when "list" then selection.list
250
+ when "current" then selection.current
251
+ when "use" then selection.use(name)
252
+ else raise Errors::ConfigurationError, "unknown environment action: #{action}"
253
+ end
254
+ if json?
255
+ flush_json(
256
+ build_reporter(build_secret_filter), Result.success(value), "env.#{action}", selection.current
257
+ )
258
+ elsif value.is_a?(Array)
259
+ value.each { |environment| puts environment }
260
+ else
261
+ puts value
262
+ end
263
+ 0
264
+ end
265
+ end
266
+
267
+ desc "support ACTION", "Create local diagnostics: bundle"
268
+ def support(action)
269
+ execute do
270
+ raise Errors::ConfigurationError, "unknown support action: #{action}" unless action == "bundle"
271
+
272
+ app, = build_application
273
+ filter = configured_secret_filter(app.config)
274
+ doctor = Workflows::Doctor.new(
275
+ config: app.config,
276
+ provider: app.provider,
277
+ state_store: app.state_store,
278
+ transport_factory: app.transport_factory,
279
+ operations: app.operations
280
+ )
281
+ path = Workflows::SupportBundle.new(
282
+ root: options[:root], config: app.config, state_store: app.state_store, doctor: doctor,
283
+ secret_filter: filter
284
+ ).call
285
+ if json?
286
+ flush_json(
287
+ build_reporter(filter), Result.success({ path: path }), "support.bundle", app.config.environment
288
+ )
289
+ else
290
+ puts "Support bundle created: #{path}"
291
+ puts "\nRedacted contents (inspect before sharing):"
292
+ puts File.read(path)
293
+ puts "Kitsune Kit did not upload anything."
294
+ end
295
+ 0
296
+ end
297
+ end
298
+
299
+ desc "docker ACTION", "Manage Docker: status, install, uninstall"
300
+ def docker(action)
301
+ execute do
302
+ app, reporter = build_application
303
+ operation = app.operations.find { |candidate| candidate.resource == "docker" }
304
+ result = dispatch_docker_action(app, operation, action)
305
+ flush_json(reporter, result, "docker.#{action}", app.config.environment) if json? && result.is_a?(Result)
306
+ 0
307
+ end
308
+ end
309
+
310
+ desc "ui", "Open the interactive terminal interface"
311
+ def ui
312
+ execute do
313
+ build_tui.call
314
+ end
315
+ end
316
+
317
+ default_task :help
318
+
319
+ no_commands do
320
+ def execute
321
+ status = yield
322
+ exit(status) if status.is_a?(Integer) && !status.zero?
323
+ status
324
+ rescue Errors::Error => e
325
+ render_error(e)
326
+ exit(Errors.exit_status(e))
327
+ rescue Interrupt
328
+ warn "Cancelled."
329
+ exit(130)
330
+ rescue StandardError => e
331
+ render_unexpected_error(e)
332
+ exit(1)
333
+ ensure
334
+ puts "Log: #{@run_log_path}" if @run_log_path && !json?
335
+ end
336
+
337
+ def build_application
338
+ filter = @secret_filter = build_secret_filter
339
+ bus = Events::Bus.new
340
+ reporter = build_reporter(filter)
341
+ bus.subscribe(reporter)
342
+ subscribe_logger(bus, filter)
343
+ app = Application.build(
344
+ root: options[:root],
345
+ environment: options[:env],
346
+ config_path: options[:config],
347
+ event_bus: bus,
348
+ host_key_confirmation: method(:confirm_host_key),
349
+ maximum_timeout: options[:timeout]
350
+ )
351
+ register_configured_secrets(filter, app.config)
352
+ [app, reporter]
353
+ end
354
+
355
+ def build_local_application
356
+ filter = @secret_filter = build_secret_filter
357
+ bus = Events::Bus.new
358
+ reporter = build_reporter(filter)
359
+ bus.subscribe(reporter)
360
+ subscribe_logger(bus, filter)
361
+ config = Configuration::Loader.new(
362
+ root: options[:root], env: ENV, config_path: options[:config], validate_secrets: false
363
+ ).load(environment: options[:env])
364
+ app = Application.new(config: config, provider: nil, state_store: StateStore.new(root: options[:root]),
365
+ event_bus: bus)
366
+ register_configured_secrets(filter, config)
367
+ [app, reporter]
368
+ end
369
+
370
+ def build_tui
371
+ unless $stdin.tty? && $stdout.tty?
372
+ raise Errors::ConfigurationError.new(
373
+ "the TUI requires an interactive terminal",
374
+ hint: "Use `kit doctor`, `kit plan`, `kit apply`, or --format json in automation."
375
+ )
376
+ end
377
+
378
+ filter = @secret_filter = build_secret_filter
379
+ bus = Events::Bus.new
380
+ loader = Configuration::Loader.new(root: options[:root], env: ENV, config_path: options[:config])
381
+ config = loader.load(environment: options[:env])
382
+ store = Tui::Store.new(environment: config.environment, secret_filter: filter)
383
+ bus.subscribe(store)
384
+ subscribe_logger(bus, filter)
385
+ app = Application.build(
386
+ root: options[:root], environment: options[:env], config_path: options[:config], event_bus: bus,
387
+ host_key_confirmation: method(:confirm_host_key), maximum_timeout: options[:timeout]
388
+ )
389
+ register_configured_secrets(filter, app.config)
390
+ controller = Tui::Controller.new(store: store, actions: Tui::Actions.new(application: app))
391
+ Tui::Application.new(store: store, controller: controller)
392
+ end
393
+
394
+ def build_secret_filter
395
+ SecretFilter.new([ENV["DO_API_TOKEN"], ENV["POSTGRES_PASSWORD"], ENV["REDIS_PASSWORD"]])
396
+ end
397
+
398
+ def configured_secret_filter(config)
399
+ build_secret_filter.tap { |filter| register_configured_secrets(filter, config) }
400
+ end
401
+
402
+ def build_reporter(filter)
403
+ return Reporters::Json.new(secret_filter: filter) if json?
404
+
405
+ Reporters::Human.new(color: !options[:no_color] && !ENV.key?("NO_COLOR"), quiet: options[:quiet],
406
+ verbose: options[:verbose], secret_filter: filter)
407
+ end
408
+
409
+ def flush_json(reporter, result, command, environment)
410
+ reporter.flush(result: result, command: command, environment: environment)
411
+ end
412
+
413
+ def render_apply_terminal(reporter, result, app, human_message)
414
+ return flush_json(reporter, result, "apply", app.config.environment) if json?
415
+
416
+ puts human_message
417
+ end
418
+
419
+ def render_apply_success(reporter, result, app)
420
+ return flush_json(reporter, result, "apply", app.config.environment) if json?
421
+
422
+ puts "Next: run `kit doctor`; a subsequent `kit plan` should report zero changes."
423
+ end
424
+
425
+ def subscribe_logger(bus, filter)
426
+ return unless options[:log]
427
+
428
+ logger = RunLogger.new(root: options[:root], run_id: "session-#{SecureRandom.uuid}",
429
+ secret_filter: filter)
430
+ @run_log_path = logger.path
431
+ bus.subscribe(logger)
432
+ end
433
+
434
+ def register_configured_secrets(filter, config)
435
+ filter.register(ENV[config.provider.token_env])
436
+ %w[postgres redis].each do |type|
437
+ filter.register(ENV[config.services.public_send(type).password_env])
438
+ end
439
+ end
440
+
441
+ def confirm_apply!(plan)
442
+ if plan.destructive?
443
+ raise Errors::UnsafeOperationError.new(
444
+ "the plan contains destructive changes",
445
+ hint: "Resolve or explicitly perform the required destructive operation first."
446
+ )
447
+ end
448
+ return if options[:yes]
449
+ if options[:no_input] || !$stdin.tty?
450
+ raise Errors::UnsafeOperationError.new(
451
+ "apply requires confirmation",
452
+ hint: "Review `kit plan`, then pass --yes for a non-interactive apply."
453
+ )
454
+ end
455
+ return if yes?("Apply #{plan.changed_count} change(s)? [y/N]")
456
+
457
+ raise Errors::UnsafeOperationError, "apply was not confirmed"
458
+ end
459
+
460
+ def confirm_resume!(run_id)
461
+ return if options[:yes]
462
+
463
+ label = run_id || "the latest incomplete run"
464
+ if options[:no_input] || !$stdin.tty?
465
+ raise Errors::UnsafeOperationError.new(
466
+ "resume requires confirmation",
467
+ hint: "Inspect `kit status`, then pass --yes to resume #{label}."
468
+ )
469
+ end
470
+ return if yes?("Resume #{label}? [y/N]")
471
+
472
+ raise Errors::UnsafeOperationError, "resume was not confirmed"
473
+ end
474
+
475
+ def dispatch_server_action(app, action)
476
+ case action
477
+ when "show", "status" then inspect_environment(app)
478
+ when "create" then apply_operations(app, [app.operations.first])
479
+ when "configure" then apply_operations(app, app.operations.drop(1))
480
+ when "import" then import_server(app)
481
+ when "destroy" then destroy_server(app)
482
+ when "ssh" then open_ssh(app)
483
+ else raise Errors::ConfigurationError, "unknown server action: #{action}"
484
+ end
485
+ end
486
+
487
+ def dispatch_service_action(app, operation, type, action)
488
+ case action
489
+ when "status" then service_status(app, type)
490
+ when "install" then apply_operations(app, [operation])
491
+ when "backup" then with_mutation_lock(app) { service_backup(operation) }
492
+ when "remove"
493
+ confirm_named_action!("remove #{type}", "#{type}@#{app.config.environment}", option: :yes)
494
+ with_mutation_lock(app) { Result.success(operation.remove) }
495
+ when "destroy-data" then destroy_service_data(app, operation, type)
496
+ else raise Errors::ConfigurationError, "unknown service action: #{action}"
497
+ end
498
+ end
499
+
500
+ def dispatch_service_compose(app, type, action)
501
+ raise Errors::ConfigurationError, "unknown service type: #{type}" unless %w[postgres redis].include?(type)
502
+
503
+ service = app.config.services.public_send(type)
504
+ if service.mode == "external"
505
+ raise Errors::UnsafeOperationError, "Compose is not managed for an external #{type} service"
506
+ end
507
+
508
+ compose = ServiceCompose.new(config: app.config, type: type, service: service)
509
+ case action
510
+ when "show" then compose_show(compose)
511
+ when "validate" then compose_validate(type, compose)
512
+ when "diff" then compose_diff(app, type, compose)
513
+ when "eject" then compose_eject(app, type)
514
+ else
515
+ raise Errors::ConfigurationError,
516
+ "unknown Compose action: #{action || '(missing)'}; expected show, validate, diff or eject"
517
+ end
518
+ end
519
+
520
+ def compose_show(compose)
521
+ puts compose.display unless json?
522
+ Result.success(compose.metadata.merge(content: compose.display, fingerprint: compose.fingerprint))
523
+ end
524
+
525
+ def compose_validate(type, compose)
526
+ puts "#{type} Compose customization is valid (#{compose.mode})." unless json?
527
+ Result.success(compose.metadata.merge(valid: true, fingerprint: compose.fingerprint))
528
+ end
45
529
 
46
- desc "switch_env SUBCOMMAND", "Switch the active Kitsune environment"
47
- subcommand "switch_env", Kitsune::Kit::Commands::SwitchEnv
530
+ def compose_eject(app, type)
531
+ value = Workflows::EjectCompose.new(
532
+ root: options[:root], config: app.config, type: type, config_path: options[:config]
533
+ ).call(force: options[:force])
534
+ puts "Created #{value[:compose_file]} and updated #{value[:config_file]}." unless json?
535
+ Result.success(value)
536
+ end
537
+
538
+ def compose_diff(app, type, compose)
539
+ state = app.state_store.read(app.config.environment).dig("resources", "service.#{type}")
540
+ installed = state&.fetch("compose_fingerprint", nil)
541
+ changed = installed != compose.fingerprint
542
+ value = { changed: changed, desired_fingerprint: compose.fingerprint,
543
+ installed_fingerprint: installed, mode: compose.mode }
544
+ unless json?
545
+ label = if installed
546
+ changed ? "changed" : "unchanged"
547
+ else
548
+ "not installed or from an older state"
549
+ end
550
+ puts "#{type} Compose: #{label}"
551
+ end
552
+ Result.success(value)
553
+ end
554
+
555
+ def destroy_service_data(app, operation, type)
556
+ expected = "#{type}@#{app.config.environment}"
557
+ state = app.state_store.read(app.config.environment).dig("resources", "service.#{type}")
558
+ unless state
559
+ raise Errors::UnsafeOperationError.new(
560
+ "no managed #{type} data exists in #{app.config.environment}",
561
+ hint: "Run `kit service #{type} status`; restore state before attempting data destruction."
562
+ )
563
+ end
564
+ context = { environment: app.config.environment, resource: "service.#{type}",
565
+ volume: state.fetch("volume", nil), recoverable: false }
566
+ print_destructive_summary(
567
+ environment: context[:environment], provider: app.config.provider.name, resource: context[:resource],
568
+ identifier: context[:volume], recoverable: context[:recoverable]
569
+ )
570
+ backup_path = create_pre_destroy_backup?(type) ? operation.backup : nil
571
+ confirmation = options[:confirm_destroy]
572
+ confirmation ||= ask("Type #{expected} to permanently destroy its data:") unless options[:no_input]
573
+ confirm_exact!(confirmation, expected, "service data destruction", context: context)
574
+ destroyed = with_mutation_lock(app) { operation.destroy_data }
575
+ Result.success({ destroyed: destroyed, backup: backup_path })
576
+ end
48
577
 
49
- desc "provision SUBCOMMAND", "Provisioning tasks"
50
- subcommand "provision", Kitsune::Kit::Commands::Provision
578
+ def create_pre_destroy_backup?(type)
579
+ return true if options[:backup_before_destroy]
580
+ return false if options[:no_input] || !$stdin.tty?
51
581
 
52
- desc "dns SUBCOMMAND", "Manage DNS"
53
- subcommand "dns", Kitsune::Kit::Commands::Dns
582
+ yes?("Create a #{type} data backup on the server before destruction? [y/N]")
583
+ end
54
584
 
55
- desc "setup_user SUBCOMMAND", "Create or rollback deploy user on remote server"
56
- subcommand "setup_user", Kitsune::Kit::Commands::SetupUser
585
+ def service_backup(operation)
586
+ path = operation.backup
587
+ puts "Backup created: #{path}" unless json?
588
+ Result.success({ path: path })
589
+ end
57
590
 
58
- desc "setup_firewall SUBCOMMAND", "Configure or rollback UFW firewall rules"
59
- subcommand "setup_firewall", Kitsune::Kit::Commands::SetupFirewall
591
+ def dispatch_resource_action(app, operation, action)
592
+ case action
593
+ when "list" then dns_list(app)
594
+ when "plan" then build_plan(app, [operation])
595
+ when "apply" then apply_operations(app, [operation])
596
+ when "remove"
597
+ confirm_named_action!("remove DNS", app.config.environment, option: :yes)
598
+ with_mutation_lock(app) { Result.success(operation.rollback) }
599
+ else raise Errors::ConfigurationError, "unknown DNS action: #{action}"
600
+ end
601
+ end
60
602
 
61
- desc "setup_unattended SUBCOMMAND", "Configure or rollback unattended-upgrades"
62
- subcommand "setup_unattended", Kitsune::Kit::Commands::SetupUnattended
603
+ def dns_list(app)
604
+ values = app.config.dns.domains
605
+ values.each { |domain| puts domain } unless json?
606
+ Result.success(values)
607
+ end
63
608
 
64
- desc "setup_swap SUBCOMMAND", "Configure or rollback swap memory"
65
- subcommand "setup_swap", Kitsune::Kit::Commands::SetupSwap
609
+ def dispatch_docker_action(app, operation, action)
610
+ case action
611
+ when "status" then resource_status(app, "docker")
612
+ when "install" then apply_operations(app, [operation])
613
+ when "uninstall"
614
+ active_services = app.state_store.read(app.config.environment)["resources"].keys.grep(/\Aservice\./)
615
+ unless active_services.empty?
616
+ raise Errors::UnsafeOperationError.new(
617
+ "Docker cannot be removed while managed services exist",
618
+ hint: "Remove services first: #{active_services.join(', ')}"
619
+ )
620
+ end
621
+ confirm_named_action!("uninstall Docker", app.config.environment, option: :yes)
622
+ with_mutation_lock(app) { Result.success(operation.rollback) }
623
+ else raise Errors::ConfigurationError, "unknown Docker action: #{action}"
624
+ end
625
+ end
66
626
 
67
- desc "setup_do_metrics SUBCOMMAND", "Install or rollback DigitalOcean Metrics Agent"
68
- subcommand "setup_do_metrics", Kitsune::Kit::Commands::SetupDoMetrics
627
+ def inspect_environment(app)
628
+ result = Workflows::InspectEnvironment.new(
629
+ config: app.config,
630
+ provider: app.provider,
631
+ state_store: app.state_store,
632
+ event_bus: app.event_bus
633
+ ).call
634
+ print_environment_status(result.value) unless json?
635
+ result
636
+ end
69
637
 
70
- desc "bootstrap SUBCOMMAND", "Run full server setup or rollback"
71
- subcommand "bootstrap", Kitsune::Kit::Commands::Bootstrap
638
+ def destroy_server(app)
639
+ expected = app.config.server.name
640
+ managed = app.state_store.read(app.config.environment).dig("resources", "server")
641
+ unless managed&.fetch("id", nil)
642
+ raise Errors::UnsafeOperationError.new(
643
+ "no managed server ID exists in state",
644
+ hint: "Restore state or use the verified `kit server import` recovery flow first."
645
+ )
646
+ end
647
+ print_destructive_summary(
648
+ environment: app.config.environment, provider: app.config.provider.name,
649
+ resource: expected, identifier: managed&.fetch("id", nil), recoverable: false
650
+ )
651
+ confirmation = options[:confirm_destroy]
652
+ confirmation ||= ask("Type #{expected} to permanently destroy the server:") unless options[:no_input]
653
+ dns_operation = app.operations.find { |operation| operation.resource == "dns" }
654
+ Workflows::DestroyServer.new(
655
+ config: app.config,
656
+ provider: app.provider,
657
+ state_store: app.state_store,
658
+ dns_operation: dns_operation,
659
+ event_bus: app.event_bus
660
+ ).call(confirmation: confirmation)
661
+ end
72
662
 
73
- desc "setup_docker_prereqs SUBCOMMAND", "Install or rollback docker prerequisites"
74
- subcommand "setup_docker_prereqs", Kitsune::Kit::Commands::SetupDockerPrereqs
663
+ def import_server(app)
664
+ result = Workflows::ImportServer.new(
665
+ config: app.config,
666
+ provider: app.provider,
667
+ state_store: app.state_store,
668
+ event_bus: app.event_bus
669
+ ).call(provider_id: options[:provider_id], confirmation: options[:confirm_import])
670
+ unless json?
671
+ if result.metadata[:unchanged]
672
+ puts "State already tracks provider ID #{result.value}."
673
+ else
674
+ puts "Imported server #{result.value.name} (provider ID #{result.value.id}) " \
675
+ "into #{app.config.environment}."
676
+ puts "Only the server identity was imported; run `kit doctor` before applying remote resources."
677
+ end
678
+ end
679
+ result
680
+ end
75
681
 
76
- desc "install_docker_engine SUBCOMMAND", "Install or rollback Docker Engine"
77
- subcommand "install_docker_engine", Kitsune::Kit::Commands::InstallDockerEngine
682
+ def open_ssh(app)
683
+ current = app.provider.find_server(name: app.config.server.name, tags: app.config.server.tags)
684
+ raise Errors::ConnectionError, "server has no public IP" unless current&.public_ip
78
685
 
79
- desc "postinstall_docker SUBCOMMAND", "Apply or rollback Docker post-installation tasks"
80
- subcommand "postinstall_docker", Kitsune::Kit::Commands::PostinstallDocker
686
+ known_hosts = File.expand_path(".kitsune/known_hosts", options[:root])
687
+ unless app.transport_factory.deploy.reachable?
688
+ raise Errors::ConnectionError, "the deploy SSH connection could not be verified"
689
+ end
81
690
 
82
- desc "bootstrap_docker SUBCOMMAND", "Run full docker setup or rollback"
83
- subcommand "bootstrap_docker", Kitsune::Kit::Commands::BootstrapDocker
691
+ Kernel.exec(
692
+ "ssh", "-i", app.config.ssh.key_path, "-p", app.config.ssh.port.to_s,
693
+ "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile=#{known_hosts}",
694
+ "#{app.config.ssh.user}@#{current.public_ip}"
695
+ )
696
+ end
84
697
 
85
- desc "setup_postgres_docker SUBCOMMAND", "Setup PostgreSQL via Docker Compose on remote server"
86
- subcommand "setup_postgres_docker", Kitsune::Kit::Commands::SetupPostgresDocker
698
+ def confirm_host_key(host:, fingerprint:, key_type:)
699
+ expected = options[:trust_host_key]
700
+ if expected
701
+ return true if expected == fingerprint
87
702
 
88
- desc "setup_redis_docker SUBCOMMAND", "Setup Redis via Docker Compose on remote server"
89
- subcommand "setup_redis_docker", Kitsune::Kit::Commands::SetupRedisDocker
703
+ raise Errors::UnsafeOperationError.new(
704
+ "SSH host-key fingerprint does not match --trust-host-key",
705
+ context: { host: host, expected: expected, actual: fingerprint }
706
+ )
707
+ end
708
+ if options[:no_input] || !$stdin.tty? || json?
709
+ raise Errors::UnsafeOperationError.new(
710
+ "SSH host key is not trusted",
711
+ hint: "Verify the provider-console fingerprint, then pass --trust-host-key #{fingerprint}.",
712
+ context: { host: host, fingerprint: fingerprint, key_type: key_type }
713
+ )
714
+ end
90
715
 
91
- desc "ssh connect", "SSH into the server"
92
- subcommand "ssh", Kitsune::Kit::Commands::Ssh
716
+ puts "SSH host key for #{host} (#{key_type}): #{fingerprint}"
717
+ yes?("Trust and save this host key? [y/N]")
718
+ end
719
+
720
+ def find_service_operation(app, type, action)
721
+ raise Errors::ConfigurationError, "unknown service type: #{type}" unless %w[postgres redis].include?(type)
722
+
723
+ service = app.config.services.public_send(type)
724
+ if service.enabled && service.mode == "external"
725
+ return nil if action == "status"
726
+
727
+ raise Errors::UnsafeOperationError.new(
728
+ "#{type} is configured as an external service",
729
+ hint: "Manage its lifecycle with its provider; Kitsune Kit only reports the configured endpoint."
730
+ )
731
+ end
732
+ enabled = service.enabled
733
+ return app.service_operation(type) if enabled || action != "install"
734
+
735
+ raise Errors::ConfigurationError.new(
736
+ "#{type} is not enabled in configuration",
737
+ hint: "Set services.#{type}.enabled to true and configure its secret."
738
+ )
739
+ end
740
+
741
+ def apply_operations(app, operations)
742
+ plan_result = build_plan(app, operations)
743
+ plan = plan_result.value
744
+ return Result.success([], metadata: { unchanged: true }) unless plan.changed?
745
+ return plan_result if options[:dry_run]
746
+
747
+ confirm_apply!(plan)
748
+ Workflows::ApplyPlan.new(
749
+ config: app.config,
750
+ operations: operations,
751
+ state_store: app.state_store,
752
+ event_bus: app.event_bus
753
+ ).call(plan: plan)
754
+ end
755
+
756
+ def build_plan(app, operations)
757
+ result = Workflows::BuildPlan.new(
758
+ config: app.config,
759
+ operations: operations,
760
+ event_bus: app.event_bus
761
+ ).call
762
+ print_plan(result.value) unless json?
763
+ result
764
+ end
765
+
766
+ def service_status(app, type)
767
+ service = app.config.services.public_send(type)
768
+ return resource_status(app, "service.#{type}") unless service.enabled && service.mode == "external"
769
+
770
+ value = { mode: "external", host: service.host, port: service.port, managed: false }
771
+ puts("service.#{type}: external (#{service.host}:#{service.port})") unless json?
772
+ Result.success(value)
773
+ end
774
+
775
+ def resource_status(app, resource)
776
+ value = app.state_store.read(app.config.environment).dig("resources", resource)
777
+ if json?
778
+ Result.success(value)
779
+ else
780
+ status = if !value
781
+ "not managed"
782
+ elsif value["installed"] == false
783
+ "removed (data preserved)"
784
+ else
785
+ "managed"
786
+ end
787
+ puts("#{resource}: #{status}")
788
+ end
789
+ Result.success(value)
790
+ end
791
+
792
+ def confirm_named_action!(action, expected, option:)
793
+ return if options[option]
794
+ if options[:no_input] || !$stdin.tty?
795
+ raise Errors::UnsafeOperationError.new(
796
+ "#{action} requires confirmation",
797
+ hint: "Pass --yes after reviewing the affected environment (#{expected})."
798
+ )
799
+ end
800
+ return if yes?("#{action.capitalize} in #{expected}? [y/N]")
801
+
802
+ raise Errors::UnsafeOperationError, "#{action} was not confirmed"
803
+ end
804
+
805
+ def confirm_exact!(actual, expected, action, context: {})
806
+ return if actual == expected
807
+
808
+ raise Errors::UnsafeOperationError.new(
809
+ "#{action} was not confirmed",
810
+ hint: "Pass --confirm-destroy #{expected} after creating a backup.",
811
+ context: context
812
+ )
813
+ end
814
+
815
+ def print_destructive_summary(environment:, provider:, resource:, identifier:, recoverable:)
816
+ return if json?
817
+
818
+ puts
819
+ puts "Permanent destruction"
820
+ puts " Environment: #{environment}"
821
+ puts " Provider: #{provider}"
822
+ puts " Resource: #{resource}"
823
+ puts " Identifier: #{identifier || 'not recorded'}"
824
+ puts " Recoverable: #{recoverable ? 'yes' : 'no; restore only from an independent backup'}"
825
+ puts
826
+ end
827
+
828
+ def with_mutation_lock(app, &)
829
+ app.state_store.with_execution_lock(app.config.environment, &)
830
+ end
831
+
832
+ def print_plan(plan)
833
+ puts
834
+ puts "Plan for environment: #{plan.environment}"
835
+ puts
836
+ if options[:quiet]
837
+ puts "#{plan.changed_count} change(s), #{plan.changes.length - plan.changed_count} unchanged"
838
+ return
839
+ end
840
+
841
+ plan.changes.each do |change|
842
+ marker = { "create" => "+", "update" => "~", "delete" => "-", "no_change" => "=" }.fetch(change.action)
843
+ puts " #{marker} #{change.summary}"
844
+ end
845
+ puts
846
+ puts "#{plan.changed_count} change(s), #{plan.changes.length - plan.changed_count} unchanged"
847
+ end
848
+
849
+ def print_checks(checks)
850
+ checks.each do |check|
851
+ puts format("%<name>-24s %<status>-5s %<message>s",
852
+ name: check.name, status: check.status.upcase, message: check.message)
853
+ puts "#{' ' * 31}#{check.hint}" if check.hint
854
+ end
855
+ end
856
+
857
+ def print_environment_status(status)
858
+ puts
859
+ puts "Environment: #{status.environment}"
860
+ if status.server
861
+ puts "Server: #{status.server.name} (#{status.server.status}, #{status.server.public_ip || 'no IP'})"
862
+ else
863
+ puts "Server: not created"
864
+ end
865
+ puts "Managed resources:"
866
+ if status.managed_resources.empty?
867
+ puts " none"
868
+ else
869
+ status.managed_resources.each_key { |resource| puts " - #{resource}" }
870
+ end
871
+ end
872
+
873
+ def render_error(error)
874
+ filter = @secret_filter || build_secret_filter
875
+ safe_message = filter.filter(error.message)
876
+ safe_hint = filter.filter(error.hint)
877
+ safe_context = filter.filter(error.context)
878
+ if json?
879
+ warn ::JSON.generate(
880
+ schema_version: 1,
881
+ status: "failure",
882
+ error: { code: error.code, message: safe_message, hint: safe_hint, context: safe_context }
883
+ )
884
+ else
885
+ warn "Error [#{error.code}]: #{safe_message}"
886
+ warn "Hint: #{safe_hint}" if safe_hint
887
+ warn "Context: #{safe_context.inspect}" if options[:debug] && !safe_context.empty?
888
+ end
889
+ end
890
+
891
+ def render_unexpected_error(error)
892
+ filter = @secret_filter || build_secret_filter
893
+ safe_message = filter.filter(error.message)
894
+ if json?
895
+ warn ::JSON.generate(
896
+ schema_version: 1,
897
+ status: "failure",
898
+ error: { code: "unexpected_error", message: safe_message }
899
+ )
900
+ else
901
+ warn "Unexpected error: #{safe_message}"
902
+ warn filter.filter(error.full_message) if options[:debug]
903
+ end
904
+ end
905
+
906
+ def json? = options[:format] == "json"
907
+ end
93
908
  end
94
909
  end
95
910
  end