raygatherer 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aef304a404bc427e2d066db7cf8767e73d06dc8c765b15f18b78afc68a78ee1e
4
- data.tar.gz: e81748ac2ce4443e5a30ff55e63727db50148d7b1402ffebbee280c01f19a8a6
3
+ metadata.gz: a9324c6a164aa329514f237d51e7942f8d2399e698fcb500de86440aef9320f0
4
+ data.tar.gz: 5412fc1973d54829b42684ea37d73df8ab4cf6a85a2ecfd2b1248e9c45833817
5
5
  SHA512:
6
- metadata.gz: cc2ce523698c34711223d9c88a14360f98850e91650da419acb9b532e6d1e5a8f1bc8db47d418e83acf648878803a4f2811ca65403dbfcbfae071017e0ad6c3b
7
- data.tar.gz: bde55ec5165b8516d9e6295a2b6215db2c54f2399b9c299497591b9d0ebe212a1d2eac19c0d10ace5324816f1a37f477ca06587125110a858488c39a587f283d
6
+ metadata.gz: e34d2c21750b31b1d0e33c8334233748b6d7aca62eb6879912226e644f8cc7f2076b815937a3f05b1853595b8aa28f25b61ae06f5e3e13d0ce8fe794ee58fe73
7
+ data.tar.gz: e7e7c1bbb88a75a9b588713dfec1692fcbf2f9e0bbf126a82dc517b86d1d7279cf48ccd8a3e1fba7b210d89e2507d64777ca17b14617fba88e9bc52e27aa80f7
data/README.md CHANGED
@@ -12,27 +12,36 @@ Currently implemented:
12
12
 
13
13
  - alerts from live analysis, with severity-based exit codes
14
14
  - recording list/start/stop/delete/download
15
- - system stats
15
+ - analysis report for named or active recordings
16
16
  - analysis queue status and triggering analysis runs
17
+ - system stats and raw log output
18
+ - device clock show and sync
17
19
  - config show/set/test-notification
18
20
  - JSON output mode for scriptable commands
19
21
  - optional basic auth and config file support
22
+ - debug utilities (display-state)
20
23
 
21
24
  ## Installation
22
25
 
23
- ### Ruby version
26
+ ### Via RubyGems
24
27
 
25
- `raygatherer` requires Ruby `>= 3.1.0`.
28
+ ```bash
29
+ gem install raygatherer
30
+ ```
26
31
 
27
- ### Install from this repo
32
+ Requires Ruby >= 3.2.
33
+
34
+ ### From source
28
35
 
29
36
  ```bash
37
+ git clone https://github.com/mjstallard/raygatherer.git
38
+ cd raygatherer
30
39
  bundle install
31
40
  make build
32
41
  make install
33
42
  ```
34
43
 
35
- Or install directly with RubyGems:
44
+ Or build and install the gem directly:
36
45
 
37
46
  ```bash
38
47
  gem build raygatherer.gemspec
@@ -50,37 +59,49 @@ raygatherer --help
50
59
  Check live alerts:
51
60
 
52
61
  ```bash
53
- raygatherer --host http://rayhunter.local alerts
62
+ raygatherer --host http://192.168.1.1 alerts
54
63
  ```
55
64
 
56
65
  Check live alerts as JSON:
57
66
 
58
67
  ```bash
59
- raygatherer --host http://rayhunter.local --json alerts
68
+ raygatherer --host http://192.168.1.1 --json alerts
60
69
  ```
61
70
 
62
71
  List recordings:
63
72
 
64
73
  ```bash
65
- raygatherer --host http://rayhunter.local recording list
74
+ raygatherer --host http://192.168.1.1 recording list
66
75
  ```
67
76
 
68
77
  Download a recording:
69
78
 
70
79
  ```bash
71
- raygatherer --host http://rayhunter.local recording download 1738950000
80
+ raygatherer --host http://192.168.1.1 recording download 1738950000
81
+ ```
82
+
83
+ Show analysis report for a recording:
84
+
85
+ ```bash
86
+ raygatherer --host http://192.168.1.1 analysis report 1738950000
87
+ ```
88
+
89
+ Show analysis report for the active recording:
90
+
91
+ ```bash
92
+ raygatherer --host http://192.168.1.1 analysis report --live
72
93
  ```
73
94
 
74
95
  Show analysis queue status:
75
96
 
76
97
  ```bash
77
- raygatherer --host http://rayhunter.local analysis status
98
+ raygatherer --host http://192.168.1.1 analysis status
78
99
  ```
79
100
 
80
101
  Show system stats:
81
102
 
82
103
  ```bash
83
- raygatherer --host http://rayhunter.local stats
104
+ raygatherer --host http://192.168.1.1 stats
84
105
  ```
85
106
 
86
107
  ## Global Flags
@@ -113,7 +134,7 @@ CLI flags always override config values.
113
134
  Example:
114
135
 
115
136
  ```yaml
116
- host: http://rayhunter.local
137
+ host: http://192.168.1.1
117
138
  basic_auth_user: admin
118
139
  basic_auth_password: replace-me
119
140
  json: false
@@ -126,16 +147,21 @@ Main commands:
126
147
 
127
148
  - `alerts`
128
149
  - `recording list`
129
- - `recording download <name> [--qmdl|--pcap|--zip] [--download-dir DIR|--save-as PATH]`
130
- - `recording delete <name>`
131
- - `recording stop`
132
150
  - `recording start`
151
+ - `recording stop`
152
+ - `recording download <name> [--qmdl|--pcap|--zip] [--download-dir DIR|--save-as PATH]`
153
+ - `recording delete <name> | --all [--force]`
133
154
  - `analysis status`
134
- - `analysis run [NAME|--all]`
155
+ - `analysis run <name> | --all`
156
+ - `analysis report <name> | --live`
157
+ - `time show`
158
+ - `time sync`
135
159
  - `config show`
136
160
  - `config set` (reads JSON from stdin)
137
161
  - `config test-notification`
138
162
  - `stats`
163
+ - `log`
164
+ - `debug display-state <recording|paused|warning> [--severity low|medium|high]`
139
165
 
140
166
  For command-specific help:
141
167
 
@@ -164,7 +190,7 @@ raygatherer analysis run --help
164
190
  Example:
165
191
 
166
192
  ```bash
167
- raygatherer --host http://rayhunter.local alerts
193
+ raygatherer --host http://192.168.1.1 alerts
168
194
  code=$?
169
195
  [ "$code" -ge 11 ] && echo "medium or high alert"
170
196
  ```
@@ -176,7 +202,7 @@ Commands that support `--json` return machine-readable output to `stdout`. This
176
202
  Example:
177
203
 
178
204
  ```bash
179
- raygatherer --host http://rayhunter.local --json config show | jq '.analyzers'
205
+ raygatherer --host http://192.168.1.1 --json config show | jq '.analyzers'
180
206
  ```
181
207
 
182
208
  ## Development
@@ -73,10 +73,33 @@ module Raygatherer
73
73
  end
74
74
  end
75
75
 
76
+ def fetch_time
77
+ get("/api/time") do |body|
78
+ parse_json(body)
79
+ end
80
+ end
81
+
82
+ def set_time_offset(offset_seconds)
83
+ post("/api/time-offset", expected_code: "200",
84
+ body: ::JSON.generate({offset_seconds: offset_seconds}),
85
+ content_type: "application/json")
86
+ end
87
+
88
+ def fetch_log
89
+ get("/api/log") do |body|
90
+ log_verbose "Received log (#{body.bytesize} bytes)"
91
+ body
92
+ end
93
+ end
94
+
76
95
  def test_notification
77
96
  post("/api/test-notification", expected_code: "200")
78
97
  end
79
98
 
99
+ def set_display_state(body_json)
100
+ post("/api/debug/display-state", body: body_json, content_type: "application/json", expected_code: "200")
101
+ end
102
+
80
103
  def set_config(json_body)
81
104
  post("/api/config", body: json_body, content_type: "application/json")
82
105
  end
@@ -95,6 +118,10 @@ module Raygatherer
95
118
  post("/api/delete-recording/#{URI.encode_www_form_component(name)}")
96
119
  end
97
120
 
121
+ def delete_all_recordings
122
+ post("/api/delete-all-recordings")
123
+ end
124
+
98
125
  def start_analysis(name)
99
126
  encoded = URI.encode_www_form_component(name)
100
127
  body = post("/api/analysis/#{encoded}")
@@ -256,11 +283,10 @@ module Raygatherer
256
283
  end
257
284
 
258
285
  def normalize_host(host)
259
- # Add http:// if no scheme is present
260
- if !%r{^https?://}.match?(host)
261
- "http://#{host}"
262
- else
286
+ if %r{^https?://}.match?(host)
263
287
  host
288
+ else
289
+ "http://#{host}"
264
290
  end
265
291
  end
266
292
  end
@@ -16,6 +16,7 @@ module Raygatherer
16
16
  end
17
17
 
18
18
  ROUTES = {
19
+ ["log", nil] => {file: "commands/log", klass: "Commands::Log", json: false},
19
20
  ["stats", nil] => {file: "commands/stats", klass: "Commands::Stats", json: true},
20
21
  ["alerts", nil] => {file: "commands/alerts", klass: "Commands::Alerts", json: true},
21
22
  ["recording", "list"] => {file: "commands/recording/list", klass: "Commands::Recording::List", json: true},
@@ -25,9 +26,13 @@ module Raygatherer
25
26
  ["recording", "start"] => {file: "commands/recording/start", klass: "Commands::Recording::Start", json: false},
26
27
  ["analysis", "status"] => {file: "commands/analysis/status", klass: "Commands::Analysis::Status", json: true},
27
28
  ["analysis", "run"] => {file: "commands/analysis/run", klass: "Commands::Analysis::Run", json: true},
29
+ ["analysis", "report"] => {file: "commands/analysis/report", klass: "Commands::Analysis::Report", json: true},
28
30
  ["config", "show"] => {file: "commands/config/show", klass: "Commands::Config::Show", json: true},
29
31
  ["config", "set"] => {file: "commands/config/set", klass: "Commands::Config::Set", json: false},
30
- ["config", "test-notification"] => {file: "commands/config/test_notification", klass: "Commands::Config::TestNotification", json: false}
32
+ ["config", "test-notification"] => {file: "commands/config/test_notification", klass: "Commands::Config::TestNotification", json: false},
33
+ ["debug", "display-state"] => {file: "commands/debug/display_state", klass: "Commands::Debug::DisplayState", json: false},
34
+ ["time", "show"] => {file: "commands/time/show", klass: "Commands::Time::Show", json: true},
35
+ ["time", "sync"] => {file: "commands/time/sync", klass: "Commands::Time::Sync", json: false}
31
36
  }.freeze
32
37
 
33
38
  def self.run(argv, stdout: $stdout, stderr: $stderr, config: Config.new)
@@ -44,8 +49,8 @@ module Raygatherer
44
49
 
45
50
  def run
46
51
  # Extract global flags BEFORE processing
47
- cli_verbose = @argv.delete("--verbose") ? true : nil
48
- cli_json = @argv.delete("--json") ? true : nil
52
+ cli_verbose = extract_boolean_flag("--verbose")
53
+ cli_json = extract_boolean_flag("--json")
49
54
  cli_host = extract_value_flag("--host")
50
55
  cli_username = extract_value_flag("--basic-auth-user")
51
56
  cli_password = extract_value_flag("--basic-auth-password")
@@ -145,11 +150,14 @@ module Raygatherer
145
150
  name.split("::").reduce(Raygatherer) { |mod, part| mod.const_get(part) }
146
151
  end
147
152
 
153
+ def extract_boolean_flag(flag)
154
+ @argv.delete(flag) ? true : nil
155
+ end
156
+
148
157
  def extract_value_flag(flag)
149
158
  index = @argv.index(flag)
150
159
  return nil unless index
151
- @argv.delete_at(index) # remove the flag
152
- @argv.delete_at(index) # remove the value (shifted into same index)
160
+ @argv.slice!(index, 2)&.last
153
161
  end
154
162
 
155
163
  def show_help(output = @stdout)
@@ -174,9 +182,14 @@ module Raygatherer
174
182
  output.puts " analysis status Show analysis queue status"
175
183
  output.puts " analysis run <name> Queue a recording for analysis"
176
184
  output.puts " analysis run --all Queue all recordings for analysis"
185
+ output.puts " analysis report <name> Show the full analysis report for a recording"
177
186
  output.puts " config show Show device configuration"
178
187
  output.puts " config set Update device configuration (reads JSON from stdin)"
179
188
  output.puts " config test-notification Send a test notification"
189
+ output.puts " debug display-state STATE Set device display state (recording, paused, warning)"
190
+ output.puts " time show Show device time (system, adjusted, offset)"
191
+ output.puts " time sync Sync device clock to this machine's time"
192
+ output.puts " log Download the device log"
180
193
  output.puts " stats Show device system stats"
181
194
  output.puts ""
182
195
  output.puts "Configuration:"
@@ -3,7 +3,8 @@
3
3
  require "optparse"
4
4
  require "time"
5
5
  require_relative "base"
6
- require_relative "../formatters/json"
6
+ require_relative "../formatters/alerts_json"
7
+ require_relative "../formatters/alerts_human"
7
8
 
8
9
  module Raygatherer
9
10
  module Commands
@@ -41,7 +42,7 @@ module Raygatherer
41
42
  alerts = filter_latest(alerts, data[:rows]) if @latest
42
43
 
43
44
  # Select formatter based on --json flag
44
- formatter = @json ? Formatters::JSON.new : Formatters::Human.new
45
+ formatter = @json ? Formatters::AlertsJSON.new : Formatters::AlertsHuman.new
45
46
  @stdout.puts formatter.format(alerts)
46
47
 
47
48
  # Return severity-based exit code
@@ -105,13 +106,13 @@ module Raygatherer
105
106
  end
106
107
 
107
108
  def parse_timestamp(str)
108
- Time.parse(str)
109
+ ::Time.parse(str)
109
110
  rescue ArgumentError
110
111
  raise ArgumentError, "invalid timestamp: #{str}"
111
112
  end
112
113
 
113
114
  def filter_after(alerts)
114
- alerts.select { |a| a[:packet_timestamp] && Time.parse(a[:packet_timestamp]) > @after }
115
+ alerts.select { |a| a[:packet_timestamp] && ::Time.parse(a[:packet_timestamp]) > @after }
115
116
  end
116
117
 
117
118
  def filter_latest(alerts, rows)
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "../base"
5
+ require_relative "../../formatters/analysis_report_json"
6
+ require_relative "../../formatters/analysis_report_human"
7
+
8
+ module Raygatherer
9
+ module Commands
10
+ module Analysis
11
+ class Report < Base
12
+ def initialize(argv, stdout: $stdout, stderr: $stderr, api_client: nil, json: false)
13
+ super(argv, stdout: stdout, stderr: stderr, api_client: api_client)
14
+ @json = json
15
+ @live = false
16
+ end
17
+
18
+ def run
19
+ with_error_handling do
20
+ parse_options
21
+
22
+ name = @argv.shift
23
+
24
+ if @live && name
25
+ @stderr.puts "Error: cannot use --live with a recording name"
26
+ return EXIT_CODE_ERROR
27
+ end
28
+
29
+ if !@live && name.nil?
30
+ @stderr.puts "Error: recording name or --live is required"
31
+ return EXIT_CODE_ERROR
32
+ end
33
+
34
+ data = @live ? @api_client.fetch_live_analysis_report : @api_client.fetch_analysis_report(name)
35
+
36
+ formatter = @json ? Formatters::AnalysisReportJSON.new : Formatters::AnalysisReportHuman.new
37
+ @stdout.puts formatter.format(data)
38
+
39
+ EXIT_CODE_SUCCESS
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def parse_options
46
+ OptionParser.new do |opts|
47
+ opts.banner = "Usage: raygatherer analysis report [options] NAME"
48
+ opts.separator ""
49
+ opts.separator "Options:"
50
+
51
+ opts.on("--live", "Show analysis report for the currently active recording") do
52
+ @live = true
53
+ end
54
+
55
+ opts.on("-h", "--help", "Show this help message") do
56
+ show_help
57
+ raise CLI::EarlyExit, EXIT_CODE_SUCCESS
58
+ end
59
+ end.parse!(@argv)
60
+ end
61
+
62
+ def show_help(output = @stdout)
63
+ output.puts "Usage: raygatherer [global options] analysis report [options] [NAME]"
64
+ output.puts ""
65
+ output.puts "Show the full analysis report for a named recording, or the active recording."
66
+ output.puts ""
67
+ output.puts "Options:"
68
+ output.puts " --live Show analysis report for the currently active recording"
69
+ output.puts " -h, --help Show this help message"
70
+ output.puts ""
71
+ print_global_options(output, json: true)
72
+ output.puts ""
73
+ output.puts "Examples:"
74
+ output.puts " raygatherer --host http://192.168.1.100:8080 analysis report 1738950000"
75
+ output.puts " raygatherer --host http://rayhunter --json analysis report 1738950000"
76
+ output.puts " raygatherer --host http://rayhunter analysis report --live"
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -31,7 +31,7 @@ module Raygatherer
31
31
  return EXIT_CODE_ERROR
32
32
  end
33
33
 
34
- status = @api_client.start_analysis(@all ? "" : name)
34
+ status = @all ? run_all : @api_client.start_analysis(name)
35
35
 
36
36
  formatter = @json ? Formatters::AnalysisStatusJSON.new : Formatters::AnalysisStatusHuman.new
37
37
  @stdout.puts formatter.format(status)
@@ -42,6 +42,13 @@ module Raygatherer
42
42
 
43
43
  private
44
44
 
45
+ def run_all
46
+ manifest = @api_client.fetch_manifest
47
+ names = manifest["entries"].map { |e| e["name"] }
48
+ names.each { |name| @api_client.start_analysis(name) }
49
+ @api_client.fetch_analysis_status
50
+ end
51
+
45
52
  def parse_options
46
53
  OptionParser.new do |opts|
47
54
  opts.banner = "Usage: raygatherer analysis run [options] [NAME]"
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "../base"
6
+
7
+ module Raygatherer
8
+ module Commands
9
+ module Debug
10
+ class DisplayState < Base
11
+ VALID_STATES = %w[recording paused warning].freeze
12
+ VALID_SEVERITIES = %w[low medium high].freeze
13
+
14
+ def initialize(argv, stdout: $stdout, stderr: $stderr, api_client: nil)
15
+ super
16
+ @severity = nil
17
+ end
18
+
19
+ def run
20
+ with_error_handling do
21
+ parse_options
22
+
23
+ state = @argv.shift
24
+
25
+ unless VALID_STATES.include?(state)
26
+ @stderr.puts "Error: state must be one of: #{VALID_STATES.join(", ")}"
27
+ return EXIT_CODE_ERROR
28
+ end
29
+
30
+ if state == "warning" && @severity.nil?
31
+ @stderr.puts "Error: --severity is required when state is 'warning'"
32
+ return EXIT_CODE_ERROR
33
+ end
34
+
35
+ if state != "warning" && !@severity.nil?
36
+ @stderr.puts "Error: --severity is only valid when state is 'warning'"
37
+ return EXIT_CODE_ERROR
38
+ end
39
+
40
+ @api_client.set_display_state(build_body(state))
41
+ @stdout.puts "Display state updated."
42
+
43
+ EXIT_CODE_SUCCESS
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def build_body(state)
50
+ case state
51
+ when "recording" then JSON.generate("Recording")
52
+ when "paused" then JSON.generate("Paused")
53
+ when "warning" then JSON.generate({"WarningDetected" => {"event_type" => @severity.capitalize}})
54
+ end
55
+ end
56
+
57
+ def parse_options
58
+ OptionParser.new do |opts|
59
+ opts.banner = "Usage: raygatherer debug display-state [options] STATE"
60
+ opts.separator ""
61
+ opts.separator "Options:"
62
+
63
+ opts.on("--severity LEVEL", VALID_SEVERITIES, "Severity: low, medium, high (required for 'warning' state)") do |s|
64
+ @severity = s
65
+ end
66
+
67
+ opts.on("-h", "--help", "Show this help message") do
68
+ show_help
69
+ raise CLI::EarlyExit, EXIT_CODE_SUCCESS
70
+ end
71
+ end.parse!(@argv)
72
+ end
73
+
74
+ def show_help(output = @stdout)
75
+ output.puts "Usage: raygatherer [global options] debug display-state [options] STATE"
76
+ output.puts ""
77
+ output.puts "Change the display state of the device for debugging purposes."
78
+ output.puts ""
79
+ output.puts "States:"
80
+ output.puts " recording Device is recording, no warnings"
81
+ output.puts " paused Device is not recording"
82
+ output.puts " warning Warning detected (requires --severity)"
83
+ output.puts ""
84
+ output.puts "Options:"
85
+ output.puts " --severity LEVEL Warning severity: low, medium, high"
86
+ output.puts " -h, --help Show this help message"
87
+ output.puts ""
88
+ print_global_options(output)
89
+ output.puts ""
90
+ output.puts "Examples:"
91
+ output.puts " raygatherer --host http://192.168.1.100:8080 debug display-state recording"
92
+ output.puts " raygatherer --host http://192.168.1.100:8080 debug display-state paused"
93
+ output.puts " raygatherer --host http://192.168.1.100:8080 debug display-state warning --severity high"
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "base"
5
+
6
+ module Raygatherer
7
+ module Commands
8
+ class Log < Base
9
+ def run
10
+ with_error_handling do
11
+ parse_options
12
+ @stdout.print @api_client.fetch_log
13
+ EXIT_CODE_SUCCESS
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def parse_options
20
+ OptionParser.new do |opts|
21
+ opts.on("-h", "--help", "Show this help message") do
22
+ show_help
23
+ raise CLI::EarlyExit, EXIT_CODE_SUCCESS
24
+ end
25
+ end.parse!(@argv)
26
+ end
27
+
28
+ def show_help(output = @stdout)
29
+ output.puts "Usage: raygatherer [global options] log [options]"
30
+ output.puts ""
31
+ output.puts "Options:"
32
+ output.puts " -h, --help Show this help message"
33
+ output.puts ""
34
+ print_global_options(output)
35
+ output.puts ""
36
+ output.puts "Examples:"
37
+ output.puts " raygatherer --host http://192.168.1.100:8080 log"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -7,30 +7,71 @@ module Raygatherer
7
7
  module Commands
8
8
  module Recording
9
9
  class Delete < Base
10
+ def initialize(argv, stdout: $stdout, stderr: $stderr, api_client: nil, stdin: $stdin, **_kwargs)
11
+ super(argv, stdout: stdout, stderr: stderr, api_client: api_client)
12
+ @stdin = stdin
13
+ @all = false
14
+ @force = false
15
+ end
16
+
10
17
  def run
11
18
  with_error_handling do
12
19
  parse_options
13
20
 
14
- name = @argv.shift
15
- unless name
16
- @stderr.puts "Error: recording name is required"
21
+ if @all && !@argv.empty?
22
+ @stderr.puts "Error: cannot specify both a recording name and --all"
17
23
  next EXIT_CODE_ERROR
18
24
  end
19
25
 
20
- @api_client.delete_recording(name)
21
- @stdout.puts "Deleted recording: #{name}"
22
- EXIT_CODE_SUCCESS
26
+ if @all
27
+ delete_all
28
+ else
29
+ delete_named
30
+ end
23
31
  end
24
32
  end
25
33
 
26
34
  private
27
35
 
36
+ def delete_all
37
+ unless @force
38
+ @stderr.print "Warning: This will permanently delete ALL recordings!\nAre you sure? [y/N]: "
39
+ response = @stdin.gets&.strip&.downcase || ""
40
+ unless response == "y" || response == "yes"
41
+ @stderr.puts "Aborted."
42
+ return EXIT_CODE_ERROR
43
+ end
44
+ end
45
+ @api_client.delete_all_recordings
46
+ @stdout.puts "Deleted all recordings."
47
+ EXIT_CODE_SUCCESS
48
+ end
49
+
50
+ def delete_named
51
+ name = @argv.shift
52
+ unless name
53
+ @stderr.puts "Error: recording name or --all is required"
54
+ return EXIT_CODE_ERROR
55
+ end
56
+ @api_client.delete_recording(name)
57
+ @stdout.puts "Deleted recording: #{name}"
58
+ EXIT_CODE_SUCCESS
59
+ end
60
+
28
61
  def parse_options
29
62
  OptionParser.new do |opts|
30
- opts.banner = "Usage: raygatherer recording delete <name>"
63
+ opts.banner = "Usage: raygatherer recording delete <name> | --all [--force]"
31
64
  opts.separator ""
32
65
  opts.separator "Options:"
33
66
 
67
+ opts.on("--all", "Delete all recordings (prompts for confirmation)") do
68
+ @all = true
69
+ end
70
+
71
+ opts.on("-f", "--force", "Skip confirmation prompt (use with --all)") do
72
+ @force = true
73
+ end
74
+
34
75
  opts.on("-h", "--help", "Show this help message") do
35
76
  show_help
36
77
  raise CLI::EarlyExit, EXIT_CODE_SUCCESS
@@ -40,14 +81,20 @@ module Raygatherer
40
81
 
41
82
  def show_help(output = @stdout)
42
83
  output.puts "Usage: raygatherer [global options] recording delete <name>"
84
+ output.puts " raygatherer [global options] recording delete --all [--force]"
43
85
  output.puts ""
44
86
  output.puts "Options:"
87
+ output.puts " --all Delete all recordings (prompts for confirmation)"
88
+ output.puts " -f, --force Skip confirmation prompt (use with --all)"
45
89
  output.puts " -h, --help Show this help message"
46
90
  output.puts ""
47
91
  print_global_options(output)
48
92
  output.puts ""
49
93
  output.puts "Examples:"
50
94
  output.puts " raygatherer --host http://192.168.1.100:8080 recording delete 1738950000"
95
+ output.puts " raygatherer --host http://192.168.1.100:8080 recording delete --all"
96
+ output.puts " raygatherer --host http://192.168.1.100:8080 recording delete --all --force"
97
+ output.puts " echo y | raygatherer --host http://192.168.1.100:8080 recording delete --all"
51
98
  end
52
99
  end
53
100
  end
@@ -28,21 +28,21 @@ module Raygatherer
28
28
  def run
29
29
  with_error_handling do
30
30
  parse_options
31
- next EXIT_CODE_ERROR unless validate_format_flags
32
- next EXIT_CODE_ERROR unless validate_path_flags
31
+ return EXIT_CODE_ERROR unless validate_format_flags
32
+ return EXIT_CODE_ERROR unless validate_path_flags
33
33
 
34
34
  name = @argv.shift
35
35
  unless name
36
36
  @stderr.puts "Error: recording name is required"
37
- next EXIT_CODE_ERROR
37
+ return EXIT_CODE_ERROR
38
38
  end
39
39
 
40
40
  dest_path = resolve_destination(name)
41
- next EXIT_CODE_ERROR unless dest_path
41
+ return EXIT_CODE_ERROR unless dest_path
42
42
 
43
43
  if File.exist?(dest_path)
44
44
  @stderr.puts "Error: file already exists: #{dest_path}"
45
- next EXIT_CODE_ERROR
45
+ return EXIT_CODE_ERROR
46
46
  end
47
47
 
48
48
  spinner = Spinner.new(stderr: @stderr)
@@ -13,7 +13,7 @@ module Raygatherer
13
13
 
14
14
  if @argv.any?
15
15
  @stderr.puts "Error: recording start does not take a name"
16
- next EXIT_CODE_ERROR
16
+ return EXIT_CODE_ERROR
17
17
  end
18
18
 
19
19
  @api_client.start_recording
@@ -13,7 +13,7 @@ module Raygatherer
13
13
 
14
14
  if @argv.any?
15
15
  @stderr.puts "Error: recording stop does not take a name"
16
- next EXIT_CODE_ERROR
16
+ return EXIT_CODE_ERROR
17
17
  end
18
18
 
19
19
  @api_client.stop_recording
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "../base"
5
+ require_relative "../../formatters/time_json"
6
+ require_relative "../../formatters/time_human"
7
+
8
+ module Raygatherer
9
+ module Commands
10
+ module Time
11
+ class Show < Base
12
+ def initialize(argv, stdout: $stdout, stderr: $stderr, api_client: nil, json: false)
13
+ super(argv, stdout: stdout, stderr: stderr, api_client: api_client)
14
+ @json = json
15
+ end
16
+
17
+ def run
18
+ with_error_handling do
19
+ parse_options
20
+
21
+ time = @api_client.fetch_time
22
+
23
+ formatter = @json ? Formatters::TimeJSON.new : Formatters::TimeHuman.new
24
+ @stdout.puts formatter.format(time)
25
+
26
+ EXIT_CODE_SUCCESS
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def parse_options
33
+ OptionParser.new do |opts|
34
+ opts.banner = "Usage: raygatherer time show [options]"
35
+ opts.separator ""
36
+ opts.separator "Options:"
37
+
38
+ opts.on("-h", "--help", "Show this help message") do
39
+ show_help
40
+ raise CLI::EarlyExit, EXIT_CODE_SUCCESS
41
+ end
42
+ end.parse!(@argv)
43
+ end
44
+
45
+ def show_help(output = @stdout)
46
+ output.puts "Usage: raygatherer [global options] time show [options]"
47
+ output.puts ""
48
+ output.puts "Options:"
49
+ output.puts " -h, --help Show this help message"
50
+ output.puts ""
51
+ print_global_options(output, json: true)
52
+ output.puts ""
53
+ output.puts "Examples:"
54
+ output.puts " raygatherer --host http://192.168.1.100:8080 time show"
55
+ output.puts " raygatherer --host http://192.168.1.100:8080 --json time show"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "time"
5
+ require_relative "../base"
6
+
7
+ module Raygatherer
8
+ module Commands
9
+ module Time
10
+ class Sync < Base
11
+ def run
12
+ with_error_handling do
13
+ parse_options
14
+
15
+ time_data = @api_client.fetch_time
16
+ device_system_time = ::Time.parse(time_data["system_time"])
17
+ offset = (::Time.now - device_system_time).round
18
+ @api_client.set_time_offset(offset)
19
+ @stdout.puts "Clock synced. Offset: #{offset}s"
20
+
21
+ EXIT_CODE_SUCCESS
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def parse_options
28
+ OptionParser.new do |opts|
29
+ opts.banner = "Usage: raygatherer time sync"
30
+ opts.separator ""
31
+ opts.separator "Options:"
32
+
33
+ opts.on("-h", "--help", "Show this help message") do
34
+ show_help
35
+ raise CLI::EarlyExit, EXIT_CODE_SUCCESS
36
+ end
37
+ end.parse!(@argv)
38
+ end
39
+
40
+ def show_help(output = @stdout)
41
+ output.puts "Usage: raygatherer [global options] time sync"
42
+ output.puts ""
43
+ output.puts "Syncs the device clock to this machine's time."
44
+ output.puts ""
45
+ output.puts "Options:"
46
+ output.puts " -h, --help Show this help message"
47
+ output.puts ""
48
+ print_global_options(output)
49
+ output.puts ""
50
+ output.puts "Examples:"
51
+ output.puts " raygatherer --host http://192.168.1.100:8080 time sync"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Raygatherer
4
4
  module Formatters
5
- class Human
5
+ class AlertsHuman
6
6
  def format(alerts)
7
7
  if alerts.empty?
8
8
  colorize("✓ No alerts detected", :green)
@@ -4,7 +4,7 @@ require "json"
4
4
 
5
5
  module Raygatherer
6
6
  module Formatters
7
- class JSON
7
+ class AlertsJSON
8
8
  def format(alerts)
9
9
  output = alerts.map do |alert|
10
10
  {
@@ -15,7 +15,7 @@ module Raygatherer
15
15
  }
16
16
  end
17
17
 
18
- ::JSON.generate(output)
18
+ JSON.generate(output)
19
19
  end
20
20
  end
21
21
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Raygatherer
6
+ module Formatters
7
+ class AnalysisReportHuman
8
+ def format(data)
9
+ metadata = data[:metadata] || {}
10
+ rows = data[:rows] || []
11
+
12
+ lines = []
13
+ lines << format_header(metadata)
14
+ lines << format_analyzers(metadata)
15
+ lines << ""
16
+
17
+ rows.each do |row|
18
+ lines.concat(format_row(row, metadata))
19
+ end
20
+
21
+ lines.join("\n")
22
+ end
23
+
24
+ private
25
+
26
+ def format_header(metadata)
27
+ rayhunter = metadata["rayhunter"] || {}
28
+ version = rayhunter["rayhunter_version"]
29
+ os = rayhunter["system_os"]
30
+ arch = rayhunter["arch"]
31
+ report_version = metadata["report_version"]
32
+
33
+ parts = []
34
+ parts << "Rayhunter v#{version}" if version
35
+ os_arch = [os, arch].compact.join(" ")
36
+ parts << os_arch unless os_arch.empty?
37
+ parts << "Report version #{report_version}" if report_version
38
+
39
+ parts.join(" | ")
40
+ end
41
+
42
+ def format_analyzers(metadata)
43
+ analyzers = metadata["analyzers"] || []
44
+ return "Analyzers: (none)" if analyzers.empty?
45
+
46
+ names = analyzers.filter_map do |a|
47
+ next unless a
48
+ v = a["version"] ? " (v#{a["version"]})" : ""
49
+ "#{a["name"]}#{v}"
50
+ end
51
+
52
+ "Analyzers: #{names.join(", ")}"
53
+ end
54
+
55
+ def format_row(row, metadata)
56
+ analyzers = metadata["analyzers"] || []
57
+ timestamp = format_timestamp(row["packet_timestamp"])
58
+
59
+ if (reason = row["skipped_message_reason"])
60
+ return ["#{timestamp} Skipped: #{reason}"]
61
+ end
62
+
63
+ events = row["events"] || []
64
+ present_events = events.each_with_index.reject { |e, _| e.nil? }
65
+
66
+ if present_events.empty?
67
+ return ["#{timestamp} No events"]
68
+ end
69
+
70
+ present_events.map do |event, index|
71
+ event_type = event["event_type"] || "Unknown"
72
+ message = event["message"]
73
+ analyzer = analyzers.dig(index, "name")
74
+ analyzer_part = analyzer ? "(#{analyzer}) " : ""
75
+ "#{timestamp} #{event_type.ljust(13)} #{analyzer_part}#{message}"
76
+ end
77
+ end
78
+
79
+ def format_timestamp(ts)
80
+ return "[no timestamp]" unless ts
81
+ time = Time.parse(ts).utc
82
+ "[#{time.strftime("%Y-%m-%d %H:%M:%S UTC")}]"
83
+ rescue ArgumentError
84
+ "[#{ts}]"
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Raygatherer
6
+ module Formatters
7
+ class AnalysisReportJSON
8
+ def format(data)
9
+ ::JSON.generate({"metadata" => data[:metadata], "rows" => data[:rows]})
10
+ end
11
+ end
12
+ end
13
+ end
@@ -59,13 +59,18 @@ module Raygatherer
59
59
 
60
60
  lines << " Size: #{format_size(entry["qmdl_size_bytes"])}"
61
61
 
62
+ if !active && entry["stop_reason"]
63
+ lines << " Stop reason: #{entry["stop_reason"]}"
64
+ end
65
+
62
66
  lines.join("\n")
63
67
  end
64
68
 
65
69
  def format_time(time_string)
66
70
  return "" unless time_string
67
-
68
71
  Time.parse(time_string).strftime("%Y-%m-%d %H:%M:%S")
72
+ rescue ArgumentError
73
+ time_string
69
74
  end
70
75
  end
71
76
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raygatherer
4
+ module Formatters
5
+ class TimeHuman
6
+ def format(time)
7
+ lines = []
8
+ lines << "System time: #{time["system_time"]}"
9
+ lines << "Adjusted time: #{time["adjusted_time"]}"
10
+ lines << "Offset: #{time["offset_seconds"]}s"
11
+ lines.join("\n")
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Raygatherer
6
+ module Formatters
7
+ class TimeJSON
8
+ def format(time)
9
+ ::JSON.generate(time)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raygatherer
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/raygatherer.rb CHANGED
@@ -4,16 +4,20 @@ require_relative "raygatherer/version"
4
4
  require_relative "raygatherer/config"
5
5
  require_relative "raygatherer/cli"
6
6
  require_relative "raygatherer/api_client"
7
- require_relative "raygatherer/formatters/human"
8
- require_relative "raygatherer/formatters/json"
7
+ require_relative "raygatherer/formatters/alerts_human"
8
+ require_relative "raygatherer/formatters/alerts_json"
9
9
  require_relative "raygatherer/formatters/recording_list_json"
10
10
  require_relative "raygatherer/formatters/recording_list_human"
11
11
  require_relative "raygatherer/formatters/stats_json"
12
12
  require_relative "raygatherer/formatters/stats_human"
13
13
  require_relative "raygatherer/formatters/analysis_status_json"
14
14
  require_relative "raygatherer/formatters/analysis_status_human"
15
+ require_relative "raygatherer/formatters/analysis_report_json"
16
+ require_relative "raygatherer/formatters/analysis_report_human"
15
17
  require_relative "raygatherer/formatters/config_json"
16
18
  require_relative "raygatherer/formatters/config_human"
19
+ require_relative "raygatherer/formatters/time_json"
20
+ require_relative "raygatherer/formatters/time_human"
17
21
  require_relative "raygatherer/spinner"
18
22
  require_relative "raygatherer/commands/base"
19
23
  require_relative "raygatherer/commands/alerts"
@@ -22,12 +26,17 @@ require_relative "raygatherer/commands/recording/download"
22
26
  require_relative "raygatherer/commands/recording/delete"
23
27
  require_relative "raygatherer/commands/recording/stop"
24
28
  require_relative "raygatherer/commands/recording/start"
29
+ require_relative "raygatherer/commands/log"
25
30
  require_relative "raygatherer/commands/stats"
26
31
  require_relative "raygatherer/commands/analysis/status"
27
32
  require_relative "raygatherer/commands/analysis/run"
33
+ require_relative "raygatherer/commands/analysis/report"
28
34
  require_relative "raygatherer/commands/config/show"
29
35
  require_relative "raygatherer/commands/config/set"
30
36
  require_relative "raygatherer/commands/config/test_notification"
37
+ require_relative "raygatherer/commands/debug/display_state"
38
+ require_relative "raygatherer/commands/time/show"
39
+ require_relative "raygatherer/commands/time/sync"
31
40
 
32
41
  module Raygatherer
33
42
  class Error < StandardError; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raygatherer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Stallard
@@ -47,7 +47,6 @@ extensions: []
47
47
  extra_rdoc_files: []
48
48
  files:
49
49
  - ".rspec"
50
- - CLAUDE.md
51
50
  - CODE_OF_CONDUCT.md
52
51
  - LICENSE.txt
53
52
  - Makefile
@@ -58,30 +57,39 @@ files:
58
57
  - lib/raygatherer/api_client.rb
59
58
  - lib/raygatherer/cli.rb
60
59
  - lib/raygatherer/commands/alerts.rb
60
+ - lib/raygatherer/commands/analysis/report.rb
61
61
  - lib/raygatherer/commands/analysis/run.rb
62
62
  - lib/raygatherer/commands/analysis/status.rb
63
63
  - lib/raygatherer/commands/base.rb
64
64
  - lib/raygatherer/commands/config/set.rb
65
65
  - lib/raygatherer/commands/config/show.rb
66
66
  - lib/raygatherer/commands/config/test_notification.rb
67
+ - lib/raygatherer/commands/debug/display_state.rb
68
+ - lib/raygatherer/commands/log.rb
67
69
  - lib/raygatherer/commands/recording/delete.rb
68
70
  - lib/raygatherer/commands/recording/download.rb
69
71
  - lib/raygatherer/commands/recording/list.rb
70
72
  - lib/raygatherer/commands/recording/start.rb
71
73
  - lib/raygatherer/commands/recording/stop.rb
72
74
  - lib/raygatherer/commands/stats.rb
75
+ - lib/raygatherer/commands/time/show.rb
76
+ - lib/raygatherer/commands/time/sync.rb
73
77
  - lib/raygatherer/config.rb
74
78
  - lib/raygatherer/format_helpers.rb
79
+ - lib/raygatherer/formatters/alerts_human.rb
80
+ - lib/raygatherer/formatters/alerts_json.rb
81
+ - lib/raygatherer/formatters/analysis_report_human.rb
82
+ - lib/raygatherer/formatters/analysis_report_json.rb
75
83
  - lib/raygatherer/formatters/analysis_status_human.rb
76
84
  - lib/raygatherer/formatters/analysis_status_json.rb
77
85
  - lib/raygatherer/formatters/config_human.rb
78
86
  - lib/raygatherer/formatters/config_json.rb
79
- - lib/raygatherer/formatters/human.rb
80
- - lib/raygatherer/formatters/json.rb
81
87
  - lib/raygatherer/formatters/recording_list_human.rb
82
88
  - lib/raygatherer/formatters/recording_list_json.rb
83
89
  - lib/raygatherer/formatters/stats_human.rb
84
90
  - lib/raygatherer/formatters/stats_json.rb
91
+ - lib/raygatherer/formatters/time_human.rb
92
+ - lib/raygatherer/formatters/time_json.rb
85
93
  - lib/raygatherer/spinner.rb
86
94
  - lib/raygatherer/version.rb
87
95
  homepage: https://github.com/mjstallard/raygatherer
@@ -98,7 +106,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
98
106
  requirements:
99
107
  - - ">="
100
108
  - !ruby/object:Gem::Version
101
- version: 3.1.0
109
+ version: 3.2.0
102
110
  required_rubygems_version: !ruby/object:Gem::Requirement
103
111
  requirements:
104
112
  - - ">="
data/CLAUDE.md DELETED
@@ -1,102 +0,0 @@
1
- # PROJECT OVERVIEW
2
-
3
- Raygatherer: Ruby CLI for fetching and displaying alerts from Rayhunter (cell tower / IMSI catcher analysis device). Ruby >= 3.1.0.
4
-
5
- # QUICK REFERENCE
6
-
7
- - Run tests: `bundle exec rspec`
8
- - Run CLI: `bundle exec ./exe/raygatherer`
9
- - CI: GitHub Actions on push to master and all PRs
10
- - `rayhunter` itself is checked out at `/Users/mike/workspace/rayhunter/`
11
-
12
- # ARCHITECTURE
13
-
14
- - Entry point: `exe/raygatherer` → `Raygatherer::CLI.run`
15
- - CLI routing: `lib/raygatherer/cli.rb`
16
- - Commands: `lib/raygatherer/commands/` (one class per subcommand)
17
- - API client: `lib/raygatherer/api_client.rb` (HTTP + NDJSON parsing)
18
- - Formatters: `lib/raygatherer/formatters/` (human and JSON, both accept arrays)
19
- - Tests mirror `lib/` structure under `spec/`, plus `spec/integration/` for CLI end-to-end tests
20
-
21
- # KEY CONVENTIONS
22
-
23
- - Formatters always receive and return arrays (even when empty)
24
- - JSON output is always a JSON array
25
- - Exit codes encode alert severity for scripting use (see `show_help` in status.rb)
26
- - Linter: `bundle exec standardrb` (Ruby Standard Style). Write new code in standardrb style to avoid rework.
27
-
28
- # ROLE AND EXPERTISE
29
-
30
- You are a senior software engineer who follows Kent Beck's Test-Driven Development (TDD) and Tidy First principles. Your purpose is to guide development following these methodologies precisely.
31
-
32
- # CORE DEVELOPMENT PRINCIPLES
33
-
34
- - Always follow the TDD cycle: Red → Green → Refactor
35
- - Write the simplest failing test first
36
- - Implement the minimum code needed to make tests pass
37
- - Refactor only after tests are passing
38
- - Follow Beck's "Tidy First" approach by separating structural changes from behavioral changes
39
- - Maintain high code quality throughout development
40
-
41
- # TDD METHODOLOGY GUIDANCE
42
-
43
- - Start by writing a failing test that defines a small increment of functionality
44
- - Use meaningful test names that describe behavior (e.g., "should sum two positive integers")
45
- - Make test failures clear and informative
46
- - Write just enough code to make the test pass - no more
47
- - Once tests pass, consider if refactoring is needed
48
- - Repeat the cycle for new functionality
49
- - When fixing a defect, first write an API-level failing test then write the smallest possible test that replicates the problem then get both tests to pass.
50
-
51
- # TIDY FIRST APPROACH
52
-
53
- - Separate all changes into two distinct types:
54
- 1. STRUCTURAL CHANGES: Rearranging code without changing behavior (renaming, extracting methods, moving code)
55
- 2. BEHAVIORAL CHANGES: Adding or modifying actual functionality
56
- - Never mix structural and behavioral changes in the same commit
57
- - Always make structural changes first when both are needed
58
- - Validate structural changes do not alter behavior by running tests before and after
59
-
60
- # COMMIT DISCIPLINE
61
-
62
- - Only commit when:
63
- 1. ALL tests are passing (`bundle exec rspec`)
64
- 2. ALL standardrb issues are resolved (`bundle exec standardrb`); fix any issues before committing
65
- 3. The change represents a single logical unit of work
66
- 4. Commit messages clearly state whether the commit contains structural or behavioral changes
67
- - Use small, frequent commits rather than large, infrequent ones
68
-
69
- # CODE QUALITY STANDARDS
70
-
71
- - Eliminate duplication ruthlessly
72
- - Express intent clearly through naming and structure
73
- - Make dependencies explicit
74
- - Keep methods small and focused on a single responsibility
75
- - Minimize state and side effects
76
- - Use the simplest solution that could possibly work
77
-
78
- # REFACTORING GUIDELINES
79
-
80
- - Refactor only when tests are passing (in the "Green" phase)
81
- - Use established refactoring patterns with their proper names
82
- - Make one refactoring change at a time
83
- - Run tests after each refactoring step
84
- - Prioritize refactorings that remove duplication or improve clarity
85
-
86
- # EXAMPLE WORKFLOW
87
-
88
- When approaching a new feature:
89
-
90
- 1. Write a simple failing test for a small part of the feature
91
- 2. Implement the bare minimum to make it pass
92
- 3. Run tests to confirm they pass (Green)
93
- 4. Run `bundle exec standardrb` and fix any issues
94
- 5. Make any necessary structural changes (Tidy First), running tests after each change
95
- 6. Commit structural changes separately
96
- 7. Add another test for the next small increment of functionality
97
- 8. Repeat until the feature is complete, committing behavioral changes separately from structural ones
98
-
99
- Follow this process precisely, always prioritizing clean, well-tested code over quick implementation.
100
-
101
- Always write one test at a time, make it run, then improve structure. Always run all the tests (except long-running tests) each time.
102
-