script_tracker 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: 6c4624d6b1fc0ede5e5691a6ec4b2ced79513620505434524b9d1d980e93d4ca
4
+ data.tar.gz: 427a3b57e4fe3436db63864e7a9d323f285c4ad5d1c763c15813a9da92c2d43d
5
+ SHA512:
6
+ metadata.gz: 776ff4218886ee763f1b93d8212dfae35c68fa2f685f89a2664d642b4934ace4a1a0c3260bd692b7688c3cfc58864dd812329f24161ac91462b5d2ee92cf183a
7
+ data.tar.gz: 1ce7a1b8dc3b65888dbe2e9117b4cb06b3e31d0ff24f195d2e4bdb66d9b1782a2bd2441df09b66ff2aa4e8cb9ace86068bd0bb5af9c81051b2a90d325f1be7cf
data/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2025-01-17
11
+
12
+ ### Added
13
+ - Initial release of ScriptTracker
14
+ - Script execution tracking with status management
15
+ - Transaction support for script execution
16
+ - Built-in logging and progress tracking
17
+ - Batch processing helpers
18
+ - Timeout support for long-running scripts
19
+ - Stale script cleanup functionality
20
+ - Rake tasks for managing scripts:
21
+ - `scripts:create` - Create new scripts
22
+ - `scripts:run` - Run pending scripts
23
+ - `scripts:status` - View script status
24
+ - `scripts:rollback` - Rollback scripts
25
+ - `scripts:cleanup` - Cleanup stale scripts
26
+ - Comprehensive RSpec test suite
27
+ - Full documentation and examples
28
+
29
+ [Unreleased]: https://github.com/blink-global/script_tracker/compare/v0.1.0...HEAD
30
+ [0.1.0]: https://github.com/blink-global/script_tracker/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Blink
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,276 @@
1
+ # ScriptTracker
2
+
3
+ ScriptTracker is a Ruby gem that provides a migration-like system for managing one-off scripts in Rails applications. It tracks script execution history, provides transaction support, and includes built-in logging and progress tracking.
4
+
5
+ ## Features
6
+
7
+ - **Execution Tracking**: Automatically tracks which scripts have been run and their status
8
+ - **Transaction Support**: Wraps script execution in database transactions
9
+ - **Status Management**: Track scripts as success, failed, running, or skipped
10
+ - **Built-in Logging**: Convenient logging methods with timestamps
11
+ - **Batch Processing**: Helper methods for processing large datasets efficiently
12
+ - **Timeout Support**: Configure custom timeouts for long-running scripts
13
+ - **Stale Script Cleanup**: Automatically identify and cleanup stuck scripts
14
+ - **Migration Generator**: Generate timestamped script files with templates
15
+
16
+ ## Installation
17
+
18
+ Add this line to your application's Gemfile:
19
+
20
+ ```ruby
21
+ gem 'script_tracker'
22
+ ```
23
+
24
+ And then execute:
25
+
26
+ ```bash
27
+ bundle install
28
+ ```
29
+
30
+ Or install it yourself as:
31
+
32
+ ```bash
33
+ gem install script_tracker
34
+ ```
35
+
36
+ ## Setup
37
+
38
+ 1. Install the gem using the generator:
39
+
40
+ ```bash
41
+ rails generate script_tracker:install
42
+ ```
43
+
44
+ This will:
45
+ - Create a migration for the `executed_scripts` table
46
+ - Create the `lib/scripts` directory for your scripts
47
+
48
+ 2. Run the migration:
49
+
50
+ ```bash
51
+ rails db:migrate
52
+ ```
53
+
54
+ 3. (Optional) Configure the scripts directory in an initializer:
55
+
56
+ ```ruby
57
+ # config/initializers/script_tracker.rb
58
+ ScriptTracker.configure do |config|
59
+ config.scripts_path = Rails.root.join('lib', 'scripts')
60
+ end
61
+ ```
62
+
63
+ ## Usage
64
+
65
+ ### Creating a New Script
66
+
67
+ Generate a new script with a descriptive name:
68
+
69
+ ```bash
70
+ rake scripts:create["update user preferences"]
71
+ ```
72
+
73
+ This creates a timestamped script file in `lib/scripts/`:
74
+
75
+ ```ruby
76
+ # lib/scripts/20231117120000_update_user_preferences.rb
77
+ module Scripts
78
+ class UpdateUserPreferences < ScriptTracker::Base
79
+ def self.execute
80
+ log "Starting script: update user preferences"
81
+
82
+ # Your script logic here
83
+ User.find_each do |user|
84
+ user.update!(preferences: { theme: 'dark' })
85
+ end
86
+
87
+ log "Script completed successfully"
88
+ end
89
+ end
90
+ end
91
+ ```
92
+
93
+ ### Running Scripts
94
+
95
+ Run all pending (not yet executed) scripts:
96
+
97
+ ```bash
98
+ rake scripts:run
99
+ ```
100
+
101
+ ### Checking Script Status
102
+
103
+ View the status of all scripts:
104
+
105
+ ```bash
106
+ rake scripts:status
107
+ ```
108
+
109
+ Output:
110
+ ```
111
+ Scripts:
112
+ [SUCCESS] 20231117120000_update_user_preferences.rb (2.5s)
113
+ [PENDING] 20231117130000_cleanup_old_data.rb
114
+ ```
115
+
116
+ ### Rolling Back a Script
117
+
118
+ Remove a script from execution history (allows it to be run again):
119
+
120
+ ```bash
121
+ rake scripts:rollback[20231117120000_update_user_preferences.rb]
122
+ ```
123
+
124
+ ### Cleaning Up Stale Scripts
125
+
126
+ Mark scripts stuck in "running" status as failed:
127
+
128
+ ```bash
129
+ rake scripts:cleanup
130
+ ```
131
+
132
+ ## Advanced Features
133
+
134
+ ### Skipping Scripts
135
+
136
+ Skip a script execution conditionally:
137
+
138
+ ```ruby
139
+ module Scripts
140
+ class ConditionalUpdate < ScriptTracker::Base
141
+ def self.execute
142
+ if User.where(needs_update: true).count.zero?
143
+ skip! "No users need updating"
144
+ end
145
+
146
+ # Your script logic here
147
+ end
148
+ end
149
+ end
150
+ ```
151
+
152
+ ### Custom Timeout
153
+
154
+ Override the default 5-minute timeout:
155
+
156
+ ```ruby
157
+ module Scripts
158
+ class LongRunningScript < ScriptTracker::Base
159
+ def self.timeout
160
+ 3600 # 1 hour in seconds
161
+ end
162
+
163
+ def self.execute
164
+ # Long-running logic here
165
+ end
166
+ end
167
+ end
168
+ ```
169
+
170
+ ### Batch Processing
171
+
172
+ Process large datasets efficiently:
173
+
174
+ ```ruby
175
+ module Scripts
176
+ class ProcessUsers < ScriptTracker::Base
177
+ def self.execute
178
+ users = User.where(processed: false)
179
+
180
+ process_in_batches(users, batch_size: 1000) do |user|
181
+ user.update!(processed: true)
182
+ end
183
+ end
184
+ end
185
+ end
186
+ ```
187
+
188
+ ### Progress Logging
189
+
190
+ Track progress during execution:
191
+
192
+ ```ruby
193
+ module Scripts
194
+ class DataMigration < ScriptTracker::Base
195
+ def self.execute
196
+ total = User.count
197
+ processed = 0
198
+
199
+ User.find_each do |user|
200
+ # Process user
201
+ processed += 1
202
+ log_progress(processed, total) if (processed % 100).zero?
203
+ end
204
+ end
205
+ end
206
+ end
207
+ ```
208
+
209
+ ## API Reference
210
+
211
+ ### ScriptTracker::Base
212
+
213
+ Base class for all scripts.
214
+
215
+ **Class Methods:**
216
+
217
+ - `execute` - Implement this method with your script logic (required)
218
+ - `run` - Execute the script with transaction and error handling
219
+ - `timeout` - Override to set custom timeout (default: 300 seconds)
220
+ - `skip!(reason)` - Skip script execution with optional reason
221
+ - `log(message, level: :info)` - Log a message with timestamp
222
+ - `log_progress(current, total, message = nil)` - Log progress percentage
223
+ - `process_in_batches(relation, batch_size: 1000, &block)` - Process records in batches
224
+
225
+ ### ScriptTracker::ExecutedScript
226
+
227
+ ActiveRecord model for tracking script execution.
228
+
229
+ **Scopes:**
230
+
231
+ - `successful` - Scripts that completed successfully
232
+ - `failed` - Scripts that failed
233
+ - `running` - Scripts currently running
234
+ - `skipped` - Scripts that were skipped
235
+ - `completed` - Scripts that finished (success or failed)
236
+ - `ordered` - Order by execution time ascending
237
+ - `recent_first` - Order by execution time descending
238
+
239
+ **Class Methods:**
240
+
241
+ - `executed?(filename)` - Check if a script has been executed
242
+ - `mark_as_running(filename)` - Mark a script as running
243
+ - `cleanup_stale_running_scripts(older_than: 1.hour.ago)` - Clean up stale scripts
244
+
245
+ **Instance Methods:**
246
+
247
+ - `mark_success!(output, duration)` - Mark as successful
248
+ - `mark_failed!(error, duration)` - Mark as failed
249
+ - `mark_skipped!(output, duration)` - Mark as skipped
250
+ - `success?`, `failed?`, `running?`, `skipped?` - Status predicates
251
+ - `formatted_duration` - Human-readable duration
252
+ - `formatted_output` - Truncated output text
253
+ - `timeout_seconds` - Get timeout value
254
+ - `timed_out?` - Check if script has timed out
255
+
256
+ ## Rake Tasks
257
+
258
+ - `rake scripts:create[description]` - Create a new script
259
+ - `rake scripts:run` - Run all pending scripts
260
+ - `rake scripts:status` - Show status of all scripts
261
+ - `rake scripts:rollback[filename]` - Rollback a script
262
+ - `rake scripts:cleanup` - Cleanup stale running scripts
263
+
264
+ ## Development
265
+
266
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
267
+
268
+ To install this gem onto your local machine, run `bundle exec rake install`.
269
+
270
+ ## Contributing
271
+
272
+ Bug reports and pull requests are welcome on GitHub at https://github.com/blink-global/script_tracker.
273
+
274
+ ## License
275
+
276
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScriptTracker
4
+ class Base
5
+ class ScriptSkipped < StandardError; end
6
+
7
+ class << self
8
+ # Default timeout: 5 minutes
9
+ # Override in subclass to customize:
10
+ # def self.timeout
11
+ # 3600 # 1 hour
12
+ # end
13
+ def timeout
14
+ 300 # 5 minutes in seconds
15
+ end
16
+
17
+ def run(executed_script_record = nil)
18
+ require 'timeout'
19
+ start_time = Time.current
20
+ timeout_seconds = timeout
21
+
22
+ # Check timeout before execution if we have a record
23
+ if executed_script_record&.timed_out?
24
+ duration = Time.current - start_time
25
+ error_message = "Script timed out after #{timeout_seconds} seconds"
26
+ log(error_message, level: :error)
27
+ return { success: false, skipped: false, output: error_message, duration: duration }
28
+ end
29
+
30
+ begin
31
+ result = nil
32
+
33
+ # Wrap execution in timeout if specified
34
+ if timeout_seconds && timeout_seconds > 0
35
+ Timeout.timeout(timeout_seconds, ScriptTimeoutError) do
36
+ result = execute_with_transaction
37
+ end
38
+ else
39
+ result = execute_with_transaction
40
+ end
41
+
42
+ duration = Time.current - start_time
43
+ output = "Script completed successfully in #{duration.round(2)}s"
44
+ log(output)
45
+
46
+ { success: true, skipped: false, output: output, duration: duration }
47
+ rescue ScriptSkipped => e
48
+ duration = Time.current - start_time
49
+ output = e.message.presence || 'Script was skipped (no action needed)'
50
+ { success: false, skipped: true, output: output, duration: duration }
51
+ rescue ScriptTimeoutError => e
52
+ duration = Time.current - start_time
53
+ error_message = "Script execution exceeded timeout of #{timeout_seconds} seconds"
54
+ log(error_message, level: :error)
55
+ { success: false, skipped: false, output: error_message, duration: duration }
56
+ rescue StandardError => e
57
+ duration = Time.current - start_time
58
+ error_message = "#{e.class}: #{e.message}\n#{e.backtrace.first(10).join("\n")}"
59
+ log(error_message, level: :error)
60
+
61
+ { success: false, skipped: false, output: error_message, duration: duration }
62
+ end
63
+ end
64
+
65
+ def execute_with_transaction
66
+ ActiveRecord::Base.transaction do
67
+ execute
68
+ end
69
+ end
70
+
71
+ class ScriptTimeoutError < StandardError; end
72
+
73
+ def execute
74
+ raise NotImplementedError, 'Subclasses must implement the execute method'
75
+ end
76
+
77
+ def skip!(reason = nil)
78
+ message = reason ? "Skipping: #{reason}" : 'Skipping script'
79
+ log(message)
80
+ raise ScriptSkipped, message
81
+ end
82
+
83
+ def log(message, level: :info)
84
+ timestamp = Time.current.strftime('%Y-%m-%d %H:%M:%S')
85
+ prefix = level == :error ? '[ERROR]' : '[INFO]'
86
+ puts "#{prefix} [#{timestamp}] #{message}"
87
+ end
88
+
89
+ def log_progress(current, total, message = nil)
90
+ percentage = ((current.to_f / total) * 100).round(2)
91
+ msg = message ? "#{message} (#{current}/#{total} - #{percentage}%)" : "Progress: #{current}/#{total} (#{percentage}%)"
92
+ log(msg)
93
+ end
94
+
95
+ def process_in_batches(relation, batch_size: 1000, &block)
96
+ total = relation.count
97
+ log("There are #{total} records to process")
98
+ return 0 if total.zero?
99
+
100
+ processed = 0
101
+ log("Processing #{total} records in batches of #{batch_size}")
102
+ relation.find_each(batch_size: batch_size) do |record|
103
+ block.call(record)
104
+ processed += 1
105
+ log_interval = [batch_size, (total * 0.1).to_i].max
106
+ log_progress(processed, total) if (processed % log_interval).zero?
107
+ end
108
+ log_progress(processed, total, 'Completed')
109
+ processed
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScriptTracker
4
+ class ExecutedScript < ActiveRecord::Base
5
+ self.table_name = 'executed_scripts'
6
+
7
+ # Constants
8
+ DEFAULT_TIMEOUT = 300 # 5 minutes in seconds
9
+
10
+ # Validations
11
+ validates :filename, presence: true, uniqueness: true
12
+ validates :executed_at, presence: true
13
+ validates :status, presence: true, inclusion: { in: %w[success failed running skipped] }
14
+ validates :timeout, numericality: { greater_than: 0, allow_nil: true }
15
+
16
+ # Scopes
17
+ scope :successful, -> { where(status: 'success') }
18
+ scope :failed, -> { where(status: 'failed') }
19
+ scope :running, -> { where(status: 'running') }
20
+ scope :completed, -> { where(status: %w[success failed]) }
21
+ scope :skipped, -> { where(status: 'skipped') }
22
+ scope :ordered, -> { order(executed_at: :asc) }
23
+ scope :recent_first, -> { order(executed_at: :desc) }
24
+
25
+ # Class methods
26
+ def self.executed?(filename)
27
+ exists?(filename: filename)
28
+ end
29
+
30
+ def self.mark_as_running(filename)
31
+ create!(
32
+ filename: filename,
33
+ executed_at: Time.current,
34
+ status: 'running'
35
+ )
36
+ end
37
+
38
+ def self.cleanup_stale_running_scripts(older_than: 1.hour.ago)
39
+ stale_scripts = running.where('executed_at < ?', older_than)
40
+ count = stale_scripts.count
41
+ stale_scripts.update_all(
42
+ status: 'failed',
43
+ output: 'Script was marked as failed due to stale running status'
44
+ )
45
+ count
46
+ end
47
+
48
+ # Instance methods
49
+ def mark_success!(output_text = nil, execution_duration = nil)
50
+ update!(
51
+ status: 'success',
52
+ output: output_text,
53
+ duration: execution_duration
54
+ )
55
+ end
56
+
57
+ def mark_failed!(error_message, execution_duration = nil)
58
+ update!(
59
+ status: 'failed',
60
+ output: error_message,
61
+ duration: execution_duration
62
+ )
63
+ end
64
+
65
+ def mark_skipped!(output_text = nil, execution_duration = nil)
66
+ update!(
67
+ status: 'skipped',
68
+ output: output_text,
69
+ duration: execution_duration
70
+ )
71
+ end
72
+
73
+ def success?
74
+ status == 'success'
75
+ end
76
+
77
+ def failed?
78
+ status == 'failed'
79
+ end
80
+
81
+ def running?
82
+ status == 'running'
83
+ end
84
+
85
+ def skipped?
86
+ status == 'skipped'
87
+ end
88
+
89
+ def formatted_duration
90
+ return 'N/A' if duration.nil?
91
+
92
+ if duration < 1
93
+ "#{(duration * 1000).round(2)}ms"
94
+ elsif duration < 60
95
+ "#{duration.round(2)}s"
96
+ else
97
+ minutes = (duration / 60).floor
98
+ seconds = (duration % 60).round(2)
99
+ "#{minutes}m #{seconds}s"
100
+ end
101
+ end
102
+
103
+ def formatted_output
104
+ return 'No output' if output.blank?
105
+
106
+ output.truncate(500)
107
+ end
108
+
109
+ def timeout_seconds
110
+ timeout || DEFAULT_TIMEOUT
111
+ end
112
+
113
+ def timed_out?
114
+ return false unless running? && timeout
115
+
116
+ Time.current > executed_at + timeout.seconds
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/migration'
5
+ require 'rails/generators/active_record'
6
+
7
+ module ScriptTracker
8
+ module Generators
9
+ class InstallGenerator < Rails::Generators::Base
10
+ include Rails::Generators::Migration
11
+
12
+ source_root File.expand_path('templates', __dir__)
13
+ desc "Creates ScriptTracker migration file and initializer"
14
+
15
+ class_option :uuid, type: :boolean, default: true,
16
+ desc: "Use UUID for primary keys (requires database support)"
17
+
18
+ class_option :skip_migration, type: :boolean, default: false,
19
+ desc: "Skip creating the migration file"
20
+
21
+ class_option :skip_initializer, type: :boolean, default: false,
22
+ desc: "Skip creating the initializer file"
23
+
24
+ def self.next_migration_number(dirname)
25
+ next_migration_number = current_migration_number(dirname) + 1
26
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
27
+ end
28
+
29
+ def copy_migration
30
+ return if options[:skip_migration]
31
+
32
+ migration_template(
33
+ "create_executed_scripts.rb.erb",
34
+ "db/migrate/create_executed_scripts.rb",
35
+ migration_version: migration_version
36
+ )
37
+ end
38
+
39
+ def create_initializer
40
+ return if options[:skip_initializer]
41
+
42
+ template "initializer.rb", "config/initializers/script_tracker.rb"
43
+ end
44
+
45
+ def create_scripts_directory
46
+ empty_directory "lib/scripts"
47
+ create_file "lib/scripts/.keep"
48
+ end
49
+
50
+ def show_readme
51
+ readme "README" if behavior == :invoke
52
+ end
53
+
54
+ private
55
+
56
+ def migration_version
57
+ "[#{ActiveRecord::Migration.current_version}]"
58
+ end
59
+
60
+ def use_uuid?
61
+ options[:uuid]
62
+ end
63
+
64
+ def primary_key_type
65
+ use_uuid? ? ":uuid" : "true"
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,22 @@
1
+ ===============================================================================
2
+
3
+ ScriptTracker has been installed!
4
+
5
+ Next steps:
6
+
7
+ 1. Run the migration:
8
+ rails db:migrate
9
+
10
+ 2. Create your first script:
11
+ rake scripts:create["your script description"]
12
+
13
+ 3. Run pending scripts:
14
+ rake scripts:run
15
+
16
+ 4. Check scripts status:
17
+ rake scripts:status
18
+
19
+ For more information, visit:
20
+ https://github.com/blink-global/script_tracker
21
+
22
+ ===============================================================================
@@ -0,0 +1,17 @@
1
+ class CreateExecutedScripts < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :executed_scripts<%= primary_key_type == ":uuid" ? ", id: :uuid" : "" %> do |t|
4
+ t.string :filename, null: false
5
+ t.datetime :executed_at, null: false
6
+ t.string :status, null: false, default: 'running'
7
+ t.text :output
8
+ t.float :duration
9
+ t.integer :timeout
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :executed_scripts, :filename, unique: true
14
+ add_index :executed_scripts, :status
15
+ add_index :executed_scripts, :executed_at
16
+ end
17
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ScriptTracker Configuration
4
+ # Customize the scripts directory path if needed
5
+ ScriptTracker.configure do |config|
6
+ # Default: Rails.root.join('lib', 'scripts')
7
+ # config.scripts_path = Rails.root.join('lib', 'scripts')
8
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScriptTracker
4
+ class Railtie < ::Rails::Railtie
5
+ railtie_name :script_tracker
6
+
7
+ rake_tasks do
8
+ path = File.expand_path(__dir__)
9
+ Dir.glob("#{path}/../../tasks/**/*.rake").each { |f| load f }
10
+ end
11
+
12
+ initializer "script_tracker.configure" do |app|
13
+ app.config.script_tracker = ActiveSupport::OrderedOptions.new
14
+ app.config.script_tracker.scripts_path = app.root.join('lib', 'scripts')
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScriptTracker
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "script_tracker/version"
4
+ require "script_tracker/base"
5
+ require "script_tracker/executed_script"
6
+ require "script_tracker/railtie" if defined?(Rails::Railtie)
7
+
8
+ # Load generators for Rails
9
+ if defined?(Rails)
10
+ require "rails/generators"
11
+ require_relative "script_tracker/generators/install_generator"
12
+ end
13
+
14
+ module ScriptTracker
15
+ class Error < StandardError; end
16
+
17
+ class << self
18
+ attr_accessor :configuration
19
+
20
+ def configure
21
+ self.configuration ||= Configuration.new
22
+ yield(configuration) if block_given?
23
+ end
24
+
25
+ def scripts_path
26
+ configuration&.scripts_path || Rails.root.join('lib', 'scripts')
27
+ end
28
+ end
29
+
30
+ class Configuration
31
+ attr_accessor :scripts_path
32
+
33
+ def initialize
34
+ @scripts_path = nil
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :scripts do
4
+ desc 'Create a new script'
5
+ task :create, [:description] => :environment do |_task, args|
6
+ description = args[:description]
7
+
8
+ if description.blank?
9
+ puts 'Usage: rake scripts:create["description"]'
10
+ exit 1
11
+ end
12
+
13
+ timestamp = Time.current.strftime('%Y%m%d%H%M%S')
14
+ snake_case = description.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/^_|_$/, '')
15
+ filename = "#{timestamp}_#{snake_case}.rb"
16
+ class_name = snake_case.camelize
17
+ scripts_dir = ScriptTracker.scripts_path
18
+ file_path = scripts_dir.join(filename)
19
+
20
+ FileUtils.mkdir_p(scripts_dir) unless Dir.exist?(scripts_dir)
21
+
22
+ template = File.read(File.expand_path('../templates/script_template.rb', __dir__))
23
+ content = template.gsub('<%= filename %>', filename)
24
+ .gsub('<%= description %>', description)
25
+ .gsub('<%= class_name %>', class_name)
26
+ .gsub('<%= Time.current.year %>', Time.current.year.to_s)
27
+ .gsub('<%= Time.current.strftime(\'%Y-%m-%d %H:%M:%S\') %>', Time.current.strftime('%Y-%m-%d %H:%M:%S'))
28
+
29
+ File.write(file_path, content)
30
+
31
+ puts "\nCreated #{filename}"
32
+ puts "Location: #{file_path}"
33
+ end
34
+
35
+ desc 'Run all pending scripts'
36
+ task run: :environment do
37
+ # Clean up any scripts that got stuck in "running" state
38
+ stale_count = ScriptTracker::ExecutedScript.cleanup_stale_running_scripts
39
+ puts "Cleaned up #{stale_count} stale script(s)\n\n" if stale_count > 0
40
+
41
+ # Find all scripts that haven't run yet
42
+ scripts_dir = ScriptTracker.scripts_path
43
+
44
+ unless Dir.exist?(scripts_dir)
45
+ puts "Error: Scripts directory does not exist: #{scripts_dir}"
46
+ puts "Please run: rails generate script_tracker:install"
47
+ exit 1
48
+ end
49
+
50
+ all_scripts = Dir[scripts_dir.join('*.rb')].sort
51
+ pending_scripts = all_scripts.reject { |f| ScriptTracker::ExecutedScript.executed?(File.basename(f)) }
52
+
53
+ if pending_scripts.empty?
54
+ puts "\nAll scripts are up to date. Nothing to run!"
55
+ exit 0
56
+ end
57
+
58
+ success_count = 0
59
+ failed_count = 0
60
+ skipped_count = 0
61
+
62
+ pending_scripts.each_with_index do |script_path, index|
63
+ script_name = File.basename(script_path)
64
+ puts "[#{index + 1}/#{pending_scripts.count}] Running #{script_name}..."
65
+
66
+ begin
67
+ # Load script file
68
+ load script_path
69
+
70
+ # Extract and validate class name
71
+ class_name = script_name.gsub(/^\d+_/, '').gsub('.rb', '').camelize
72
+ script_class_name = "Scripts::#{class_name}"
73
+
74
+ unless defined?(Scripts)
75
+ puts "Error: Scripts module not found. Ensure script defines 'module Scripts'"
76
+ failed_count += 1
77
+ next
78
+ end
79
+
80
+ begin
81
+ script_class = script_class_name.constantize
82
+ rescue NameError => e
83
+ puts "Error: Could not find class #{script_class_name}: #{e.message}"
84
+ failed_count += 1
85
+ next
86
+ end
87
+
88
+ # Validate script class
89
+ unless script_class < ScriptTracker::Base
90
+ puts "Error: Script class #{script_class_name} must inherit from ScriptTracker::Base"
91
+ failed_count += 1
92
+ next
93
+ end
94
+
95
+ unless script_class.respond_to?(:execute)
96
+ puts "Error: Script class #{script_class_name} must implement the execute method"
97
+ failed_count += 1
98
+ next
99
+ end
100
+
101
+ # Mark as running and get timeout
102
+ executed_script = ScriptTracker::ExecutedScript.mark_as_running(script_name)
103
+ executed_script.update(timeout: script_class.timeout) if script_class.respond_to?(:timeout)
104
+ start_time = Time.current
105
+
106
+ # Run script with timeout support
107
+ result = script_class.run(executed_script)
108
+ duration = Time.current - start_time
109
+
110
+ if result[:success]
111
+ executed_script.mark_success!(result[:output], result[:duration] || duration)
112
+ puts "Completed successfully in #{duration.round(2)}s\n\n"
113
+ success_count += 1
114
+ elsif result[:skipped]
115
+ executed_script.mark_skipped!(result[:output], result[:duration] || duration)
116
+ puts "Skipped in #{duration.round(2)}s\n\n"
117
+ skipped_count += 1
118
+ else
119
+ executed_script.mark_failed!(result[:output], result[:duration] || duration)
120
+ puts "Failed in #{duration.round(2)}s"
121
+ puts "Error: #{result[:output]}\n\n"
122
+ failed_count += 1
123
+ end
124
+ rescue LoadError => e
125
+ puts "Error: Could not load script file #{script_name}: #{e.message}"
126
+ failed_count += 1
127
+ rescue SyntaxError => e
128
+ puts "Error: Syntax error in script #{script_name}: #{e.message}"
129
+ failed_count += 1
130
+ rescue StandardError => e
131
+ duration = Time.current - start_time rescue 0
132
+ error_message = "#{e.class}: #{e.message}"
133
+ executed_script&.mark_failed!(error_message, duration)
134
+ puts "Error: #{e.message}\n\n"
135
+ failed_count += 1
136
+ end
137
+ end
138
+
139
+ puts "Summary: #{success_count} succeeded, #{failed_count} failed, #{skipped_count} skipped"
140
+
141
+ exit(1) if failed_count > 0
142
+ end
143
+
144
+ desc 'Show all scripts status'
145
+ task status: :environment do
146
+ scripts_dir = ScriptTracker.scripts_path
147
+
148
+ unless Dir.exist?(scripts_dir)
149
+ puts "Error: Scripts directory does not exist: #{scripts_dir}"
150
+ puts "Please run: rails generate script_tracker:install"
151
+ exit 1
152
+ end
153
+
154
+ script_files = Dir[scripts_dir.join('*.rb')].sort
155
+ executed_scripts = ScriptTracker::ExecutedScript.all.index_by(&:filename)
156
+
157
+ if script_files.empty?
158
+ puts "\nNo scripts found in #{scripts_dir}"
159
+ puts "Create a script with: rake scripts:create[\"description\"]"
160
+ exit 0
161
+ end
162
+
163
+ puts "\nScripts:"
164
+ script_files.each do |file|
165
+ filename = File.basename(file)
166
+ if (script = executed_scripts[filename])
167
+ status_icon = script.success? ? '[SUCCESS]' : script.failed? ? '[FAILED]' : script.skipped? ? '[SKIPPED]' : '[RUNNING]'
168
+ puts " #{status_icon} #{filename} (#{script.formatted_duration})"
169
+ else
170
+ puts " [PENDING] #{filename}"
171
+ end
172
+ end
173
+ puts
174
+ end
175
+
176
+ desc 'Rollback a script'
177
+ task :rollback, [:filename] => :environment do |_task, args|
178
+ filename = args[:filename]
179
+
180
+ if filename.blank?
181
+ puts 'Usage: rake scripts:rollback[filename.rb]'
182
+ exit 1
183
+ end
184
+
185
+ script = ScriptTracker::ExecutedScript.find_by(filename: filename)
186
+ if script.nil?
187
+ puts "Script not found: #{filename}"
188
+ exit 1
189
+ end
190
+
191
+ script.destroy!
192
+ puts "Rolled back: #{filename}"
193
+ end
194
+
195
+ desc 'Cleanup stale scripts'
196
+ task cleanup: :environment do
197
+ count = ScriptTracker::ExecutedScript.cleanup_stale_running_scripts
198
+ puts "Cleaned up #{count} stale script(s)"
199
+ end
200
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Script: <%= filename %>
4
+ # Created: <%= Time.current.strftime('%Y-%m-%d %H:%M:%S') %>
5
+ # Description: <%= description %>
6
+ #
7
+ # This script performs: [Describe what this script does]
8
+ #
9
+ # Prerequisites:
10
+ # - [List any prerequisites]
11
+ #
12
+ # Rollback plan:
13
+ # - [Describe how to rollback if needed]
14
+ #
15
+
16
+ module Scripts
17
+ class <%= class_name %> < ScriptTracker::Base
18
+ # Optional: Override timeout (default is 300 seconds / 5 minutes)
19
+ # def self.timeout
20
+ # 3600 # 1 hour
21
+ # end
22
+
23
+ def self.execute
24
+ log "Starting script: <%= filename %>"
25
+
26
+ # Example: Skip if work is already done
27
+ # if condition_already_met?
28
+ # skip! "Reason for skipping"
29
+ # end
30
+
31
+ # Your script logic here
32
+
33
+ log "Script completed successfully"
34
+ end
35
+ end
36
+ end
37
+
38
+ # Execute the script if run directly
39
+ if __FILE__ == $PROGRAM_NAME
40
+ result = Scripts::<%= class_name %>.run
41
+ exit(result[:success] ? 0 : 1)
42
+ end
metadata ADDED
@@ -0,0 +1,146 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: script_tracker
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ahmed Abd El-Latif
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-11-17 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: '6.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6.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: '6.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '6.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: database_cleaner-active_record
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '13.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '13.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: sqlite3
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.0'
97
+ description: ScriptTracker provides a migration-like system for managing one-off scripts
98
+ with execution tracking, transaction support, and built-in logging. Perfect for
99
+ data migrations, cleanup tasks, and administrative scripts.
100
+ email:
101
+ - ahmed.abdelatif@blink.com
102
+ executables: []
103
+ extensions: []
104
+ extra_rdoc_files: []
105
+ files:
106
+ - CHANGELOG.md
107
+ - LICENSE
108
+ - README.md
109
+ - lib/script_tracker.rb
110
+ - lib/script_tracker/base.rb
111
+ - lib/script_tracker/executed_script.rb
112
+ - lib/script_tracker/generators/install_generator.rb
113
+ - lib/script_tracker/generators/templates/README
114
+ - lib/script_tracker/generators/templates/create_executed_scripts.rb.erb
115
+ - lib/script_tracker/generators/templates/initializer.rb
116
+ - lib/script_tracker/railtie.rb
117
+ - lib/script_tracker/version.rb
118
+ - tasks/script_tracker.rake
119
+ - templates/script_template.rb
120
+ homepage: https://github.com/blink-global/script_tracker
121
+ licenses:
122
+ - MIT
123
+ metadata:
124
+ homepage_uri: https://github.com/blink-global/script_tracker
125
+ source_code_uri: https://github.com/a-abdellatif98/script_tracker
126
+ changelog_uri: https://github.com/a-abdellatif98/script_tracker/blob/main/CHANGELOG.md
127
+ post_install_message:
128
+ rdoc_options: []
129
+ require_paths:
130
+ - lib
131
+ required_ruby_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: 2.7.0
136
+ required_rubygems_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '0'
141
+ requirements: []
142
+ rubygems_version: 3.4.1
143
+ signing_key:
144
+ specification_version: 4
145
+ summary: A Ruby gem for tracking and managing one-off script executions in Rails applications
146
+ test_files: []