console_think_twice 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dccea38c9288544ae02eea01191a0ed7187ed9187f85e90fdfd37387ca656aa6
4
+ data.tar.gz: 8bfd0ed5ca4daa26412dba1a43bbf9f2cb3090d49fc83aa9a99454771d20598b
5
+ SHA512:
6
+ metadata.gz: 869e288cb34127645618c7d0326ae421c1836f44c95f12295dbd057be52e178868397200559b544c4cca06fe02ba4dbf3ede34b90690d400585982d9cf172127
7
+ data.tar.gz: effa7750ad3ba639698a3e37a8ecd4c789a2f3ce2abbe74bfc093150d91cbe6d23bafb37424938a3239720198d14a79611509b20b8d94428ed73fb35389dbd7e
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0]
6
+
7
+ Initial release.
8
+
9
+ - Confirmation prompts for `destroy`, `destroy!` and `delete` on a record, and for
10
+ `destroy_all` and `delete_all` on a relation or `has_many` collection.
11
+ - `force: true` on any guarded call skips the prompt.
12
+ - A railtie arms the guard from the Rails `console` hook, leaving web requests, jobs and
13
+ `rails runner` untouched.
14
+ - Nested prompts are suppressed for the duration of a confirmed call, so a
15
+ `dependent: :destroy` cascade asks once rather than once per record.
16
+ - A declined call raises `ConsoleThinkTwice::Aborted`, stopping a loop on the first refusal.
17
+ - Non-interactive input refuses rather than assuming yes.
18
+ - Configurable via `ConsoleThinkTwice.configure`, or turned off with `CONSOLE_THINK_TWICE=0`.
19
+
20
+ [Unreleased]: https://github.com/agomezcampero/console-think-twice/compare/v0.1.0...HEAD
21
+ [0.1.0]: https://github.com/agomezcampero/console-think-twice/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Agustin Gomez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # console_think_twice
2
+
3
+ [![CI](https://github.com/agomezcampero/console-think-twice/actions/workflows/ci.yml/badge.svg)](https://github.com/agomezcampero/console-think-twice/actions/workflows/ci.yml)
4
+
5
+ Confirm before you destroy. `console_think_twice` intercepts destructive Active Record
6
+ calls made from a console and asks first, so a stray `User.destroy_all` in production
7
+ costs you a keystroke instead of a database.
8
+
9
+ ```
10
+ >> User.first.destroy!
11
+
12
+ This will permanently destroy User #1 in production.
13
+ Cascades to: sessions, comments
14
+ Confirm? (y/N) n
15
+ ConsoleThinkTwice::Aborted: Aborted. Nothing was destroyed.
16
+
17
+ >> User.destroy_all
18
+
19
+ This will permanently destroy 200 Users in production.
20
+ Cascades to: sessions, comments
21
+ Confirm? (y/N) y
22
+ => [#<User id: 1, ...>, ...]
23
+
24
+ >> User.first.destroy!(force: true)
25
+ => #<User id: 2, ...>
26
+ ```
27
+
28
+ ## Installation
29
+
30
+ ```ruby
31
+ gem "console_think_twice"
32
+ ```
33
+
34
+ Not on RubyGems yet — until it is, point at the repository:
35
+
36
+ ```ruby
37
+ gem "console_think_twice", github: "agomezcampero/console-think-twice"
38
+ ```
39
+
40
+ In a Rails app that is all — a railtie installs the guard from the `console` hook, which
41
+ runs for `rails console` only. Web requests, background jobs and `rails runner` are
42
+ untouched, and the gem never patches anything until a console actually boots.
43
+
44
+ Outside Rails, call `ConsoleThinkTwice.install!` once your models are loaded.
45
+
46
+ ## What is guarded
47
+
48
+ | Call | Prompt |
49
+ | --- | --- |
50
+ | `record.destroy`, `record.destroy!` | `This will permanently destroy User #1.` |
51
+ | `record.delete` | `This will permanently delete, skipping callbacks, User #1.` |
52
+ | `Model.destroy_all`, `relation.destroy_all`, `company.users.destroy_all` | `This will permanently destroy 200 Users.` |
53
+ | `Model.delete_all`, `relation.delete_all` | `This will permanently delete, skipping callbacks, 200 Users.` |
54
+
55
+ `Model.destroy(id)`, `destroy_by` and `delete_by` route through those methods, so they are
56
+ covered too.
57
+
58
+ Anything other than `y`/`yes` raises `ConsoleThinkTwice::Aborted`, which also stops a
59
+ loop like `users.each(&:destroy!)` on the first refusal rather than asking 200 times.
60
+
61
+ Every guarded method takes `force: true` to skip the prompt.
62
+
63
+ ### One prompt per call
64
+
65
+ A `dependent: :destroy` cascade destroys child records, and `destroy_all` destroys its
66
+ records one at a time — both would otherwise prompt again for every record. Nested prompts
67
+ are suppressed for the duration of a confirmed call, so you are asked exactly once.
68
+
69
+ ### Nobody to ask
70
+
71
+ When the input stream is not a terminal — piped input, a script fed to `rails console` —
72
+ there is no one to answer, so the guard refuses the call instead of assuming yes. Pass
73
+ `force: true` for destructive calls that are meant to run unattended.
74
+
75
+ ## Configuration
76
+
77
+ ```ruby
78
+ ConsoleThinkTwice.configure do |config|
79
+ config.enabled = Rails.env.production? # default: true, unless CONSOLE_THINK_TWICE is 0/false/no/off
80
+ config.label = "production (europe)" # shown in the prompt; defaults to Rails.env
81
+ config.affirmative_answers = %w[y yes si] # default: %w[y yes]
82
+ config.interactive = true # default: auto-detected from config.input.tty?
83
+ config.input = $stdin
84
+ config.output = $stdout
85
+ end
86
+ ```
87
+
88
+ Set `CONSOLE_THINK_TWICE=0` in the environment to turn the guard off without touching
89
+ code. `ConsoleThinkTwice.disable!` turns it off for the rest of the current session.
90
+
91
+ ## Development
92
+
93
+ ```bash
94
+ bundle install
95
+ bundle exec rake # specs and linter
96
+ bundle exec rspec
97
+ bundle exec standardrb
98
+ ```
99
+
100
+ ## License
101
+
102
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConsoleThinkTwice
4
+ class Configuration
5
+ DISABLING_ENV_VALUES = %w[0 false no off].freeze
6
+
7
+ attr_writer :enabled, :input, :interactive, :label, :output
8
+
9
+ # Answers accepted as a confirmation; anything else aborts.
10
+ attr_accessor :affirmative_answers
11
+
12
+ def initialize
13
+ @affirmative_answers = %w[y yes]
14
+ end
15
+
16
+ def input
17
+ @input || $stdin
18
+ end
19
+
20
+ def output
21
+ @output || $stdout
22
+ end
23
+
24
+ def enabled?
25
+ return @enabled unless @enabled.nil?
26
+
27
+ !DISABLING_ENV_VALUES.include?(ENV["CONSOLE_THINK_TWICE"].to_s.strip.downcase)
28
+ end
29
+
30
+ # Whether there is a human on the other end who can answer the prompt. Auto-detected from
31
+ # the input stream unless set, so piped input aborts instead of destroying unattended.
32
+ def interactive?
33
+ return @interactive unless @interactive.nil?
34
+
35
+ input.respond_to?(:tty?) && input.tty?
36
+ end
37
+
38
+ # Shown in the prompt to make the stakes obvious, e.g. "in production".
39
+ def label
40
+ return @label unless @label.nil?
41
+
42
+ ::Rails.env.to_s if defined?(::Rails) && ::Rails.respond_to?(:env)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module ConsoleThinkTwice
6
+ class Railtie < ::Rails::Railtie
7
+ console { ConsoleThinkTwice.install! }
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConsoleThinkTwice
4
+ module RecordGuard
5
+ def destroy(force: false)
6
+ ConsoleThinkTwice.guard(self, action: :destroy, force: force) { super() }
7
+ end
8
+
9
+ def destroy!(force: false)
10
+ ConsoleThinkTwice.guard(self, action: :destroy, force: force) { super() }
11
+ end
12
+
13
+ def delete(force: false)
14
+ ConsoleThinkTwice.guard(self, action: :delete, force: force) { super() }
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConsoleThinkTwice
4
+ module RelationGuard
5
+ def destroy_all(force: false)
6
+ ConsoleThinkTwice.guard(self, action: :destroy, force: force) { super() }
7
+ end
8
+
9
+ # CollectionProxy#delete_all takes an optional dependency strategy; Relation#delete_all takes none.
10
+ def delete_all(*args, force: false)
11
+ ConsoleThinkTwice.guard(self, action: :delete, force: force) { super(*args) }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConsoleThinkTwice
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ require_relative "console_think_twice/version"
7
+ require_relative "console_think_twice/configuration"
8
+ require_relative "console_think_twice/record_guard"
9
+ require_relative "console_think_twice/relation_guard"
10
+
11
+ # Confirmation prompts for destructive Active Record calls typed into a console.
12
+ #
13
+ # >> User.first.destroy!
14
+ # This will permanently destroy User #1 in production.
15
+ # Cascades to: sessions, company_memberships, comments, api_keys
16
+ # Confirm? (y/N)
17
+ #
18
+ # Every guarded call takes `force: true` to skip the prompt. In a Rails app the railtie
19
+ # installs this on console boot only, so the web app, jobs and `rails runner` are untouched.
20
+ module ConsoleThinkTwice
21
+ # Raised when a destructive call is declined, or when there is nobody to ask.
22
+ Aborted = Class.new(StandardError)
23
+
24
+ CASCADING_STRATEGIES = %i[destroy destroy_async delete_all].freeze
25
+ SUPPRESSION_KEY = :console_think_twice_suppressed
26
+
27
+ class << self
28
+ def configuration
29
+ @configuration ||= Configuration.new
30
+ end
31
+
32
+ def configure
33
+ yield configuration
34
+ end
35
+
36
+ def install!
37
+ return false unless configuration.enabled?
38
+
39
+ unless @installed
40
+ ActiveRecord::Base.prepend(RecordGuard)
41
+ ActiveRecord::Relation.prepend(RelationGuard)
42
+ ActiveRecord::Associations::CollectionProxy.prepend(RelationGuard)
43
+ @installed = true
44
+ end
45
+
46
+ @active = true
47
+ announce
48
+ true
49
+ end
50
+
51
+ def disable!
52
+ @active = false
53
+ end
54
+
55
+ def active?
56
+ !!@active
57
+ end
58
+
59
+ # Confirms the call, then runs it with nested prompts suppressed so that a `dependent:`
60
+ # cascade, or a relation destroying its records one by one, only ever asks once.
61
+ def guard(target, action:, force:)
62
+ return yield if !active? || suppressed?
63
+
64
+ count = affected_count(target)
65
+ return yield if count.zero?
66
+
67
+ confirm!(target, action, count) unless force
68
+ suppressed { yield }
69
+ end
70
+
71
+ private
72
+
73
+ def confirm!(target, action, count)
74
+ summary = summarize(target, action, count)
75
+ unless configuration.interactive?
76
+ raise Aborted, "#{summary} No terminal to confirm on — pass `force: true` to proceed."
77
+ end
78
+
79
+ output = configuration.output
80
+ output.puts
81
+ output.puts highlight(summary)
82
+ cascades = cascading_associations(model_of(target))
83
+ output.puts "Cascades to: #{cascades.join(", ")}" if cascades.any?
84
+ output.print "Confirm? (y/N) "
85
+ output.flush
86
+
87
+ answer = configuration.input.gets.to_s.strip.downcase
88
+ return if configuration.affirmative_answers.include?(answer)
89
+
90
+ raise Aborted, "Aborted. Nothing was #{(action == :delete) ? "deleted" : "destroyed"}."
91
+ end
92
+
93
+ def summarize(target, action, count)
94
+ verb = (action == :delete) ? "permanently delete, skipping callbacks," : "permanently destroy"
95
+
96
+ subject = if target.is_a?(ActiveRecord::Base)
97
+ "#{target.class.name} ##{target.id}"
98
+ else
99
+ model = model_of(target).name
100
+ "#{count} #{(count == 1) ? model : model.pluralize}"
101
+ end
102
+
103
+ ["This will #{verb} #{subject}", configuration.label && " in #{configuration.label}", "."].compact.join
104
+ end
105
+
106
+ def affected_count(target)
107
+ return target.persisted? ? 1 : 0 if target.is_a?(ActiveRecord::Base)
108
+
109
+ target.size
110
+ end
111
+
112
+ def model_of(target)
113
+ target.is_a?(ActiveRecord::Base) ? target.class : target.klass
114
+ end
115
+
116
+ def cascading_associations(model)
117
+ model.reflect_on_all_associations
118
+ .select { |reflection| CASCADING_STRATEGIES.include?(reflection.options[:dependent]) }
119
+ .map(&:name)
120
+ end
121
+
122
+ def suppressed?
123
+ Thread.current[SUPPRESSION_KEY]
124
+ end
125
+
126
+ def suppressed
127
+ previous = Thread.current[SUPPRESSION_KEY]
128
+ Thread.current[SUPPRESSION_KEY] = true
129
+ yield
130
+ ensure
131
+ Thread.current[SUPPRESSION_KEY] = previous
132
+ end
133
+
134
+ def announce
135
+ return unless configuration.interactive?
136
+
137
+ configuration.output.puts highlight(
138
+ "Think twice: destroy and delete calls ask for confirmation. Pass `force: true` to skip."
139
+ )
140
+ end
141
+
142
+ def highlight(text)
143
+ output = configuration.output
144
+ (output.respond_to?(:tty?) && output.tty?) ? "\e[1;31m#{text}\e[0m" : text
145
+ end
146
+ end
147
+ end
148
+
149
+ require_relative "console_think_twice/railtie" if defined?(::Rails)
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: console_think_twice
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Agustin Gomez
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.0'
41
+ description: 'Prompts before destroy, destroy!, delete, destroy_all and delete_all
42
+ when they are called from a Rails console, so a stray User.destroy_all cannot wipe
43
+ production. Pass force: true to skip the prompt.'
44
+ email:
45
+ - agustin@gomezcampero.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE
52
+ - README.md
53
+ - lib/console_think_twice.rb
54
+ - lib/console_think_twice/configuration.rb
55
+ - lib/console_think_twice/railtie.rb
56
+ - lib/console_think_twice/record_guard.rb
57
+ - lib/console_think_twice/relation_guard.rb
58
+ - lib/console_think_twice/version.rb
59
+ homepage: https://github.com/agomezcampero/console-think-twice
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ allowed_push_host: https://rubygems.org
64
+ source_code_uri: https://github.com/agomezcampero/console-think-twice
65
+ changelog_uri: https://github.com/agomezcampero/console-think-twice/blob/main/CHANGELOG.md
66
+ rubygems_mfa_required: 'true'
67
+ post_install_message:
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '3.1'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.5.23
83
+ signing_key:
84
+ specification_version: 4
85
+ summary: Ask for confirmation before destructive Active Record calls in a console.
86
+ test_files: []