rails_pulse 0.4.2 → 0.5.0.pre.1
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 +4 -4
- data/CHANGELOG.md +33 -1
- data/README.md +15 -4
- data/app/assets/stylesheets/rails_pulse/components/chart.css +11 -0
- data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +19 -1
- data/app/assets/stylesheets/rails_pulse/components/row.css +3 -17
- data/app/controllers/concerns/chart_table_concern.rb +28 -69
- data/app/controllers/concerns/deployment_markers_concern.rb +3 -1
- data/app/controllers/concerns/metric_card_concern.rb +6 -7
- data/app/controllers/rails_pulse/api/v1/base_controller.rb +130 -0
- data/app/controllers/rails_pulse/api/v1/capabilities_controller.rb +27 -0
- data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +158 -0
- data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +24 -0
- data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +76 -0
- data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
- data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
- data/app/controllers/rails_pulse/api/v1/queries_controller.rb +114 -0
- data/app/controllers/rails_pulse/api/v1/requests_controller.rb +58 -0
- data/app/controllers/rails_pulse/api/v1/routes_controller.rb +98 -0
- data/app/controllers/rails_pulse/application_controller.rb +1 -1
- data/app/controllers/rails_pulse/dashboard_controller.rb +20 -20
- data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
- data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
- data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
- data/app/controllers/rails_pulse/queries_controller.rb +6 -5
- data/app/controllers/rails_pulse/requests_controller.rb +15 -14
- data/app/controllers/rails_pulse/routes_controller.rb +4 -8
- data/app/helpers/rails_pulse/application_helper.rb +15 -0
- data/app/helpers/rails_pulse/chart_helper.rb +9 -2
- data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
- data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
- data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
- data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
- data/app/models/concerns/rails_pulse/taggable.rb +5 -12
- data/app/models/rails_pulse/cards/base.rb +20 -11
- data/app/models/rails_pulse/charts/base.rb +2 -6
- data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -4
- data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
- data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
- data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +1 -2
- data/app/models/rails_pulse/dashboard/health_summary.rb +30 -3
- data/app/models/rails_pulse/dashboard/needs_attention.rb +2 -3
- data/app/models/rails_pulse/dashboard/storage_pressure.rb +34 -4
- data/app/models/rails_pulse/dashboard/storage_status.rb +36 -0
- data/app/models/rails_pulse/deployment.rb +2 -7
- data/app/models/rails_pulse/event.rb +40 -0
- data/app/models/rails_pulse/exception_occurrence.rb +1 -1
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
- data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -5
- data/app/models/rails_pulse/job_run.rb +1 -1
- data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
- data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
- data/app/models/rails_pulse/jobs/charts/duration.rb +1 -1
- data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -1
- data/app/models/rails_pulse/like_pattern.rb +29 -0
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
- data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
- data/app/models/rails_pulse/queries/charts/database_load.rb +5 -6
- data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/request.rb +1 -1
- data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
- data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
- data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
- data/app/models/rails_pulse/summary.rb +5 -0
- data/app/models/rails_pulse/tables/base.rb +5 -6
- data/app/models/rails_pulse/time_range.rb +309 -0
- data/app/models/rails_pulse/writer_heartbeat.rb +86 -0
- data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +20 -0
- data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
- data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
- data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
- data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
- data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
- data/app/serializers/rails_pulse/api/v1/request_serializer.rb +21 -0
- data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
- data/app/services/rails_pulse/exception_capture_service.rb +1 -1
- data/app/services/rails_pulse/operations/comparison.rb +1 -1
- data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
- data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
- data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
- data/app/services/rails_pulse/summary_service.rb +121 -133
- data/app/services/rails_pulse/tag_filter_service.rb +5 -5
- data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
- data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
- data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
- data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
- data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
- data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/jobs/index.html.erb +1 -1
- data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/queries/index.html.erb +1 -1
- data/app/views/rails_pulse/queries/show.html.erb +1 -1
- data/app/views/rails_pulse/requests/index.html.erb +1 -1
- data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/routes/index.html.erb +1 -1
- data/app/views/rails_pulse/routes/show.html.erb +1 -1
- data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
- data/app/views/rails_pulse/storage/show.html.erb +37 -0
- data/config/routes.rb +19 -0
- data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
- data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
- data/db/rails_pulse_schema.rb +20 -2
- data/exe/rails-pulse +7 -0
- data/lib/generators/rails_pulse/base_methods.rb +1 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +36 -13
- data/lib/rails_pulse/cleanup_service.rb +26 -20
- data/lib/rails_pulse/cli/agent_files/agents.md +87 -0
- data/lib/rails_pulse/cli/agent_files/claude_skill.md +165 -0
- data/lib/rails_pulse/cli/base_command.rb +37 -0
- data/lib/rails_pulse/cli/client.rb +64 -0
- data/lib/rails_pulse/cli/config.rb +87 -0
- data/lib/rails_pulse/cli/configure.rb +75 -0
- data/lib/rails_pulse/cli/coverage.rb +91 -0
- data/lib/rails_pulse/cli/deployments.rb +40 -0
- data/lib/rails_pulse/cli/exceptions.rb +111 -0
- data/lib/rails_pulse/cli/formatter.rb +31 -0
- data/lib/rails_pulse/cli/install.rb +145 -0
- data/lib/rails_pulse/cli/job_runs.rb +53 -0
- data/lib/rails_pulse/cli/jobs.rb +71 -0
- data/lib/rails_pulse/cli/main.rb +72 -0
- data/lib/rails_pulse/cli/mcp.rb +47 -0
- data/lib/rails_pulse/cli/queries.rb +62 -0
- data/lib/rails_pulse/cli/requests.rb +49 -0
- data/lib/rails_pulse/cli/routes.rb +66 -0
- data/lib/rails_pulse/configuration.rb +23 -1
- data/lib/rails_pulse/current.rb +11 -0
- data/lib/rails_pulse/engine.rb +12 -3
- data/lib/rails_pulse/job_run_collector.rb +15 -15
- data/lib/rails_pulse/mcp/server.rb +76 -0
- data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
- data/lib/rails_pulse/mcp/tools/deployments.rb +80 -0
- data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
- data/lib/rails_pulse/mcp/tools/errors.rb +95 -0
- data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
- data/lib/rails_pulse/mcp/tools/exceptions.rb +114 -0
- data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
- data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
- data/lib/rails_pulse/mcp/tools/queries.rb +136 -0
- data/lib/rails_pulse/mcp/tools/routes.rb +97 -0
- data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
- data/lib/rails_pulse/middleware/request_collector.rb +9 -9
- data/lib/rails_pulse/scoped_inflector.rb +35 -0
- data/lib/rails_pulse/statistics.rb +27 -0
- data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
- data/lib/rails_pulse/tasks/status_reporter.rb +26 -0
- data/lib/rails_pulse/tracker.rb +75 -14
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +0 -4
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +13 -13
- metadata +96 -24
- data/app/controllers/concerns/page_timings.rb +0 -10
- data/app/controllers/concerns/response_range_concern.rb +0 -45
- data/app/controllers/concerns/time_range_concern.rb +0 -152
- data/app/controllers/concerns/zoom_range_concern.rb +0 -86
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "openssl"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "config"
|
|
6
|
+
|
|
7
|
+
module RailsPulse
|
|
8
|
+
module CLI
|
|
9
|
+
# HTTP client for the read-only JSON API under <mount>/api/v1.
|
|
10
|
+
class Client
|
|
11
|
+
class ApiError < StandardError; end
|
|
12
|
+
|
|
13
|
+
def initialize(config = nil)
|
|
14
|
+
@config = config || Config.load
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def get(path, params = {})
|
|
18
|
+
uri = URI("#{@config.url}#{@config.mount_path}/api/v1#{path}")
|
|
19
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
20
|
+
|
|
21
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
22
|
+
req = Net::HTTP::Get.new(uri)
|
|
23
|
+
req["X-Rails-Pulse-Token"] = @config.token
|
|
24
|
+
response = http.request(req)
|
|
25
|
+
raise_for(response) unless response.is_a?(Net::HTTPSuccess)
|
|
26
|
+
parse(response, uri)
|
|
27
|
+
end
|
|
28
|
+
rescue URI::InvalidURIError => e
|
|
29
|
+
raise ApiError, "the configured URL and mount path do not form a valid URL (#{e.message})"
|
|
30
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
31
|
+
raise ApiError, "TLS failed talking to #{uri.host}:#{uri.port} (#{e.message})"
|
|
32
|
+
rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
33
|
+
raise ApiError, "could not connect to #{uri.host}:#{uri.port} (#{e.message})"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# A 200 that is not JSON is almost always the wrong URL or mount path
|
|
39
|
+
# answered by the host app itself: a login page or a catch-all route.
|
|
40
|
+
def parse(response, uri)
|
|
41
|
+
JSON.parse(response.body.to_s)
|
|
42
|
+
rescue JSON::ParserError
|
|
43
|
+
type = response["Content-Type"].to_s.split(";").first
|
|
44
|
+
raise ApiError, "#{uri} answered with #{type.to_s.empty? ? 'a non-JSON body' : type} rather than JSON. " \
|
|
45
|
+
"Check the URL and mount path ('rails-pulse configure' or RAILS_PULSE_MOUNT_PATH)"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def raise_for(response)
|
|
49
|
+
body = begin
|
|
50
|
+
JSON.parse(response.body.to_s)
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
{}
|
|
53
|
+
end
|
|
54
|
+
body = {} unless body.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
# The API explains a 400 or 401 in the body ("Invalid sort. Valid
|
|
57
|
+
# values: ..."); relay it rather than only the status line.
|
|
58
|
+
message = "#{response.code} #{response.message}".strip
|
|
59
|
+
message = "#{message}: #{body["error"]}" if body["error"].is_a?(String) && !body["error"].empty?
|
|
60
|
+
raise ApiError, message
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module RailsPulse
|
|
4
|
+
module CLI
|
|
5
|
+
# Where the CLI and MCP server find the app to talk to. Environment
|
|
6
|
+
# variables win; otherwise ~/.rails-pulse (or RAILS_PULSE_CONFIG), written
|
|
7
|
+
# by `rails-pulse configure`.
|
|
8
|
+
class Config
|
|
9
|
+
class ConfigError < StandardError; end
|
|
10
|
+
|
|
11
|
+
DEFAULT_PATH = "~/.rails-pulse".freeze
|
|
12
|
+
DEFAULT_MOUNT_PATH = "/rails_pulse".freeze
|
|
13
|
+
URL_SCHEMES = %r{\Ahttps?://\S+\z}i
|
|
14
|
+
|
|
15
|
+
# The file holds the API token, so nobody else on the machine may read it.
|
|
16
|
+
FILE_MODE = 0o600
|
|
17
|
+
|
|
18
|
+
attr_reader :url, :token, :mount_path
|
|
19
|
+
|
|
20
|
+
def initialize(url:, token:, mount_path: DEFAULT_MOUNT_PATH)
|
|
21
|
+
@url = url.to_s.chomp("/")
|
|
22
|
+
@token = token.to_s
|
|
23
|
+
# A root mount is an empty prefix, not "/", or every path gains "//".
|
|
24
|
+
trimmed = mount_path.to_s.delete_prefix("/").chomp("/")
|
|
25
|
+
@mount_path = trimmed.empty? ? "" : "/#{trimmed}"
|
|
26
|
+
|
|
27
|
+
raise ConfigError, "RAILS_PULSE_URL must start with http:// or https:// (got #{@url.inspect})" unless @url.match?(URL_SCHEMES)
|
|
28
|
+
# The API path is appended to the URL, so anything after the host
|
|
29
|
+
# belongs in the mount path instead.
|
|
30
|
+
if @url.match?(/[?#]/)
|
|
31
|
+
raise ConfigError, "RAILS_PULSE_URL must not contain a query string or fragment (got #{@url.inspect})"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.path
|
|
36
|
+
File.expand_path(ENV.fetch("RAILS_PULSE_CONFIG", DEFAULT_PATH))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.load
|
|
40
|
+
url = ENV["RAILS_PULSE_URL"]
|
|
41
|
+
token = ENV["RAILS_PULSE_TOKEN"]
|
|
42
|
+
mount_path = ENV["RAILS_PULSE_MOUNT_PATH"]
|
|
43
|
+
|
|
44
|
+
# Complete credentials in the environment never touch the file, so a
|
|
45
|
+
# broken ~/.rails-pulse cannot block a setup that does not use it.
|
|
46
|
+
if url.nil? || token.nil?
|
|
47
|
+
data = read_file
|
|
48
|
+
url ||= data["url"]
|
|
49
|
+
token ||= data["token"]
|
|
50
|
+
mount_path ||= data["mount_path"]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
url = url.to_s.chomp("/")
|
|
54
|
+
|
|
55
|
+
raise ConfigError, "RAILS_PULSE_URL is not set. Run 'rails-pulse configure'" if url.empty?
|
|
56
|
+
raise ConfigError, "RAILS_PULSE_TOKEN is not set. Run 'rails-pulse configure'" if token.to_s.empty?
|
|
57
|
+
|
|
58
|
+
new(url: url, token: token, mount_path: mount_path || DEFAULT_MOUNT_PATH)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.write!(url:, token:, mount_path: nil)
|
|
62
|
+
data = { "url" => url.to_s.chomp("/"), "token" => token.to_s }
|
|
63
|
+
data["mount_path"] = mount_path if mount_path && mount_path != DEFAULT_MOUNT_PATH
|
|
64
|
+
# perm: applies only when the file is created, so an existing file is
|
|
65
|
+
# narrowed before the token is written into it.
|
|
66
|
+
File.chmod(FILE_MODE, path) if File.exist?(path)
|
|
67
|
+
File.write(path, YAML.dump(data), perm: FILE_MODE)
|
|
68
|
+
File.chmod(FILE_MODE, path)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The parsed config file, or an empty hash when there is none yet.
|
|
72
|
+
def self.read_file
|
|
73
|
+
data = YAML.safe_load_file(path)
|
|
74
|
+
raise ConfigError, "#{path} should be a YAML mapping with url and token keys" unless data.nil? || data.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
data || {}
|
|
77
|
+
rescue Errno::ENOENT
|
|
78
|
+
{}
|
|
79
|
+
rescue Errno::EACCES, Errno::EISDIR => e
|
|
80
|
+
raise ConfigError, "#{path} cannot be read (#{e.message}). Fix its permissions or run 'rails-pulse configure'"
|
|
81
|
+
rescue Psych::SyntaxError => e
|
|
82
|
+
raise ConfigError, "#{path} is not valid YAML (#{e.message}). Fix it or run 'rails-pulse configure'"
|
|
83
|
+
end
|
|
84
|
+
private_class_method :read_file
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require_relative "base_command"
|
|
2
|
+
require_relative "config"
|
|
3
|
+
require_relative "client"
|
|
4
|
+
|
|
5
|
+
module RailsPulse
|
|
6
|
+
module CLI
|
|
7
|
+
class Configure < BaseCommand
|
|
8
|
+
default_task :setup
|
|
9
|
+
|
|
10
|
+
desc "setup", "Prompt for URL and API token, test the connection, and write ~/.rails-pulse"
|
|
11
|
+
long_desc <<~DESC
|
|
12
|
+
Interactively configure credentials for the Rails Pulse JSON API.
|
|
13
|
+
|
|
14
|
+
Prompts for:
|
|
15
|
+
- Rails Pulse URL (e.g. http://localhost:3000)
|
|
16
|
+
- API token (matches config.api_token in the app's Rails Pulse initializer)
|
|
17
|
+
- Mount path (where the engine is mounted; default /rails_pulse)
|
|
18
|
+
|
|
19
|
+
Existing values are shown as defaults — press Enter to keep them.
|
|
20
|
+
The token is not echoed as you type it.
|
|
21
|
+
|
|
22
|
+
A test request is made before saving. If it fails, no file is written.
|
|
23
|
+
Credentials are saved to ~/.rails-pulse (never to the project directory),
|
|
24
|
+
readable only by you.
|
|
25
|
+
|
|
26
|
+
You can also skip this command and set environment variables instead:
|
|
27
|
+
RAILS_PULSE_URL, RAILS_PULSE_TOKEN, RAILS_PULSE_MOUNT_PATH (optional)
|
|
28
|
+
DESC
|
|
29
|
+
def setup
|
|
30
|
+
current = begin
|
|
31
|
+
Config.load
|
|
32
|
+
rescue Config::ConfigError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
url = ask_with_default("Rails Pulse URL", current&.url)
|
|
37
|
+
token = ask_with_default("API token", current&.token, echo: false, mask: true)
|
|
38
|
+
mount_path = ask_with_default("Mount path", current&.mount_path || Config::DEFAULT_MOUNT_PATH)
|
|
39
|
+
|
|
40
|
+
say "Testing connection..."
|
|
41
|
+
begin
|
|
42
|
+
test_config = Config.new(url: url, token: token, mount_path: mount_path)
|
|
43
|
+
Client.new(test_config).get("/routes", { limit: 1 })
|
|
44
|
+
rescue Config::ConfigError, Client::ApiError => e
|
|
45
|
+
say "Connection failed: #{e.message}", :red
|
|
46
|
+
exit 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
Config.write!(url: url, token: token, mount_path: test_config.mount_path)
|
|
50
|
+
say "Configuration saved to #{Config.path}", :green
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Prompts with the current value as the default; an empty answer keeps
|
|
56
|
+
# it. A masked default shows only that a value exists, never the token.
|
|
57
|
+
def ask_with_default(label, default, echo: true, mask: false)
|
|
58
|
+
prompt = if default.to_s.empty?
|
|
59
|
+
"#{label}:"
|
|
60
|
+
else
|
|
61
|
+
"#{label} [#{mask ? 'keep current' : default}]:"
|
|
62
|
+
end
|
|
63
|
+
# Hiding input needs a terminal; piped input has nothing to hide from.
|
|
64
|
+
hide = !echo && terminal?
|
|
65
|
+
answer = ask(prompt, echo: !hide)
|
|
66
|
+
say "" if hide
|
|
67
|
+
answer.to_s.empty? ? default.to_s : answer
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def terminal?
|
|
71
|
+
$stdin.tty?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require_relative "base_command"
|
|
2
|
+
require_relative "formatter"
|
|
3
|
+
|
|
4
|
+
module RailsPulse
|
|
5
|
+
module CLI
|
|
6
|
+
class Coverage < BaseCommand
|
|
7
|
+
desc "show", "Report what has been recorded, how recently, and any gaps"
|
|
8
|
+
long_desc <<~DESC
|
|
9
|
+
Answers whether the data is there, rather than a question about the data: which
|
|
10
|
+
application and environment answered, the span of requests, job runs and exceptions
|
|
11
|
+
held, how far summaries have been generated, what retention keeps, and whether the
|
|
12
|
+
background writer has dropped anything.
|
|
13
|
+
|
|
14
|
+
Read this before concluding that nothing went wrong. An empty error list means
|
|
15
|
+
"nothing recorded", which is only the same as "nothing happened" when collection was
|
|
16
|
+
healthy over the window in question.
|
|
17
|
+
|
|
18
|
+
Use --json for the full structure.
|
|
19
|
+
DESC
|
|
20
|
+
option :json, type: :boolean, default: false, desc: "Output raw JSON"
|
|
21
|
+
def show
|
|
22
|
+
with_error_handling do
|
|
23
|
+
data = client.get("/coverage", {})
|
|
24
|
+
capabilities = client.get("/capabilities", {})
|
|
25
|
+
next say(JSON.pretty_generate(data.merge("capabilities" => capabilities))) if options[:json]
|
|
26
|
+
|
|
27
|
+
print_installation(capabilities)
|
|
28
|
+
print_telemetry(data["telemetry"] || {})
|
|
29
|
+
print_summaries(data["summaries"] || {})
|
|
30
|
+
print_retention(data["retention"] || {})
|
|
31
|
+
print_collection(data["collection"] || {})
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def print_installation(capabilities)
|
|
38
|
+
say "Installation"
|
|
39
|
+
say " application #{capabilities['application'] || '—'} (#{capabilities['environment']})"
|
|
40
|
+
say " version #{capabilities['rails_pulse_version']}"
|
|
41
|
+
say ""
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def print_telemetry(telemetry)
|
|
45
|
+
say "Telemetry"
|
|
46
|
+
telemetry.each do |kind, info|
|
|
47
|
+
if info["tracked"] == false
|
|
48
|
+
say " #{kind.ljust(12)} not tracked — #{info['reason']}"
|
|
49
|
+
elsif info["newest"]
|
|
50
|
+
say " #{kind.ljust(12)} #{info['count']} rows, #{info['oldest']} → #{info['newest']}"
|
|
51
|
+
else
|
|
52
|
+
say " #{kind.ljust(12)} nothing recorded"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def print_summaries(summaries)
|
|
58
|
+
say ""
|
|
59
|
+
say "Summaries"
|
|
60
|
+
say " hourly #{summaries['hourly_from'] || '—'} → #{summaries['hourly_through'] || '—'}#{summaries['stale'] ? ' (stale)' : ''}"
|
|
61
|
+
say " #{summaries['note']}" if summaries["note"]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def print_retention(retention)
|
|
65
|
+
say ""
|
|
66
|
+
say "Retention"
|
|
67
|
+
say " raw records #{days(retention['raw_records'])}"
|
|
68
|
+
say " hourly #{days(retention['hourly_summaries'])}"
|
|
69
|
+
say " events #{days(retention['events'])}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def print_collection(collection)
|
|
73
|
+
say ""
|
|
74
|
+
say "Collection"
|
|
75
|
+
if collection["known"] == false
|
|
76
|
+
say " unknown — #{collection['reason']}"
|
|
77
|
+
return
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
say " writers #{collection['live_writers']} live, queue #{collection['queue_depth']}/#{collection['queue_size']}"
|
|
81
|
+
say " dropped #{collection['dropped_last_hour']} in the last hour"
|
|
82
|
+
say " last beat #{collection['last_heartbeat_at'] || 'never'}"
|
|
83
|
+
say " #{collection['note']}" if collection["note"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def days(value)
|
|
87
|
+
value ? "#{value['days']} days" : "—"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require_relative "base_command"
|
|
2
|
+
require_relative "formatter"
|
|
3
|
+
|
|
4
|
+
module RailsPulse
|
|
5
|
+
module CLI
|
|
6
|
+
class Deployments < BaseCommand
|
|
7
|
+
COLUMNS = [
|
|
8
|
+
[ "Revision", 14, :short_revision ],
|
|
9
|
+
[ "Started", 25, :started_at ],
|
|
10
|
+
[ "Finished", 25, :finished_at ]
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
desc "list", "List recorded deployments"
|
|
14
|
+
long_desc <<~DESC
|
|
15
|
+
Returns deployments ordered by most recent first. A deployment still in progress has
|
|
16
|
+
no finish time.
|
|
17
|
+
|
|
18
|
+
Filter by time window (ISO 8601):
|
|
19
|
+
--since 2026-06-01T00:00:00Z
|
|
20
|
+
--until 2026-06-01T23:59:59Z
|
|
21
|
+
|
|
22
|
+
Use --json to get the full revision, duration and deployment metadata.
|
|
23
|
+
DESC
|
|
24
|
+
option :limit, type: :numeric, default: 25, desc: "Max records to return (1–500)"
|
|
25
|
+
option :offset, type: :numeric, default: 0, desc: "Number of records to skip (for pagination)"
|
|
26
|
+
option :since, type: :string, desc: "Return deployments started at or after this time (ISO 8601)"
|
|
27
|
+
option :until, type: :string, desc: "Return deployments started at or before this time (ISO 8601)"
|
|
28
|
+
option :json, type: :boolean, default: false, desc: "Output raw JSON including meta envelope"
|
|
29
|
+
def list
|
|
30
|
+
with_error_handling do
|
|
31
|
+
params = { limit: options[:limit], offset: options[:offset] }
|
|
32
|
+
params[:since] = options[:since] if options[:since]
|
|
33
|
+
params[:until] = options[:until] if options[:until]
|
|
34
|
+
result = client.get("/deployments", params)
|
|
35
|
+
Formatter.render(result, json: options[:json], columns: COLUMNS)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
require_relative "base_command"
|
|
2
|
+
require_relative "formatter"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RailsPulse
|
|
6
|
+
module CLI
|
|
7
|
+
class Exceptions < BaseCommand
|
|
8
|
+
COLUMNS = [
|
|
9
|
+
[ "ID", 6, :id ],
|
|
10
|
+
[ "Class", 36, :exception_class ],
|
|
11
|
+
[ "Location", 40, :location ],
|
|
12
|
+
[ "Status", 9, :status ],
|
|
13
|
+
[ "Count", 6, :occurrence_count ],
|
|
14
|
+
[ "Last seen", 25, :last_seen_at ]
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
desc "list", "List exception groups"
|
|
18
|
+
long_desc <<~DESC
|
|
19
|
+
Returns exception groups — one row per exception class and location — ordered by most
|
|
20
|
+
recently seen first. Each row carries the group's lifecycle status (open, resolved,
|
|
21
|
+
ignored), how many times it has occurred, and when it was first and last seen.
|
|
22
|
+
|
|
23
|
+
Filter by status:
|
|
24
|
+
--status open
|
|
25
|
+
|
|
26
|
+
Search the class name or location:
|
|
27
|
+
--search RecordNotFound
|
|
28
|
+
|
|
29
|
+
Filter by when the group was last seen (ISO 8601):
|
|
30
|
+
--since 2026-06-01T00:00:00Z
|
|
31
|
+
--until 2026-06-01T23:59:59Z
|
|
32
|
+
|
|
33
|
+
Sort by last_seen_at (default), first_seen_at, or occurrence_count:
|
|
34
|
+
--sort occurrence_count
|
|
35
|
+
|
|
36
|
+
Use --json to get the latest message, fingerprint, and the full response envelope.
|
|
37
|
+
DESC
|
|
38
|
+
option :limit, type: :numeric, default: 25, desc: "Max records to return (1–500)"
|
|
39
|
+
option :offset, type: :numeric, default: 0, desc: "Number of records to skip (for pagination)"
|
|
40
|
+
option :since, type: :string, desc: "Return groups last seen at or after this time (ISO 8601)"
|
|
41
|
+
option :until, type: :string, desc: "Return groups last seen at or before this time (ISO 8601)"
|
|
42
|
+
option :status, type: :string, desc: "Filter by status (open, resolved, ignored)"
|
|
43
|
+
option :search, type: :string, desc: "Substring match on exception class or location"
|
|
44
|
+
option :sort, type: :string, desc: "Sort by last_seen_at, first_seen_at, or occurrence_count"
|
|
45
|
+
option :json, type: :boolean, default: false, desc: "Output raw JSON including meta envelope"
|
|
46
|
+
def list
|
|
47
|
+
with_error_handling do
|
|
48
|
+
params = { limit: options[:limit], offset: options[:offset] }
|
|
49
|
+
params[:since] = options[:since] if options[:since]
|
|
50
|
+
params[:until] = options[:until] if options[:until]
|
|
51
|
+
params[:status] = options[:status] if options[:status]
|
|
52
|
+
params[:search] = options[:search] if options[:search]
|
|
53
|
+
params[:sort] = options[:sort] if options[:sort]
|
|
54
|
+
result = client.get("/exceptions", params)
|
|
55
|
+
Formatter.render(result, json: options[:json], columns: COLUMNS)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
desc "show ID", "Show one exception group with its recent occurrences and backtraces"
|
|
60
|
+
long_desc <<~DESC
|
|
61
|
+
Prints the group's class, location, status and counts, then each of its most recent
|
|
62
|
+
occurrences with request, environment, deploy SHA, filtered params, and the backtrace.
|
|
63
|
+
|
|
64
|
+
rails-pulse exceptions show 42
|
|
65
|
+
rails-pulse exceptions show 42 --occurrences 10 --json
|
|
66
|
+
DESC
|
|
67
|
+
option :occurrences, type: :numeric, default: 3, desc: "Most recent occurrences to include (1–20)"
|
|
68
|
+
option :json, type: :boolean, default: false, desc: "Output raw JSON"
|
|
69
|
+
def show(id)
|
|
70
|
+
with_error_handling do
|
|
71
|
+
result = client.get("/exceptions/#{id.to_i}", { occurrences: options[:occurrences] })
|
|
72
|
+
if options[:json]
|
|
73
|
+
puts JSON.pretty_generate(result)
|
|
74
|
+
else
|
|
75
|
+
render_group(result["data"] || {})
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
no_commands do
|
|
81
|
+
def render_group(group)
|
|
82
|
+
say ""
|
|
83
|
+
say "#{group["exception_class"]} ##{group["id"]}", :bold
|
|
84
|
+
say "═" * 60
|
|
85
|
+
say " Location: #{group["location"] || "—"}"
|
|
86
|
+
say " Status: #{group["status"]}"
|
|
87
|
+
say " Count: #{group["occurrence_count"]}"
|
|
88
|
+
say " First seen: #{group["first_seen_at"]}"
|
|
89
|
+
say " Last seen: #{group["last_seen_at"]}"
|
|
90
|
+
say " Message: #{group["message"]}" if group["message"]
|
|
91
|
+
|
|
92
|
+
Array(group["occurrences"]).each_with_index do |occurrence, index|
|
|
93
|
+
say ""
|
|
94
|
+
say "Occurrence #{index + 1} — #{occurrence["occurred_at"]}", :bold
|
|
95
|
+
say " Request: #{occurrence["request_method"]} #{occurrence["request_url"]}" if occurrence["request_url"]
|
|
96
|
+
say " Env: #{occurrence["environment"]}" if occurrence["environment"]
|
|
97
|
+
say " Deploy: #{occurrence["deploy_sha"]}" if occurrence["deploy_sha"]
|
|
98
|
+
say " Params: #{occurrence["request_params"].to_json}" if occurrence["request_params"]
|
|
99
|
+
say " Message: #{occurrence["message"]}" if occurrence["message"] && occurrence["message"] != group["message"]
|
|
100
|
+
Array(occurrence["backtrace"]).each do |frame|
|
|
101
|
+
location = frame.is_a?(Hash) ? "#{frame["file"]}:#{frame["line"]}#{frame["method"] ? " in #{frame["method"]}" : ""}" : frame.to_s
|
|
102
|
+
app = location.start_with?("app/", "lib/", "config/")
|
|
103
|
+
say " #{app ? "▸" : " "} #{location}", app ? :green : nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
say ""
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module RailsPulse
|
|
4
|
+
module CLI
|
|
5
|
+
class Formatter
|
|
6
|
+
def self.render(response, json: false, columns: [])
|
|
7
|
+
if json
|
|
8
|
+
puts JSON.pretty_generate(response)
|
|
9
|
+
else
|
|
10
|
+
render_table(response["data"], columns)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.render_table(rows, columns)
|
|
15
|
+
return puts "(no results)" if rows.empty?
|
|
16
|
+
|
|
17
|
+
header = columns.map { |header, width, _key| header.upcase.ljust(width) }.join(" ")
|
|
18
|
+
puts header
|
|
19
|
+
puts "-" * header.length
|
|
20
|
+
|
|
21
|
+
rows.each do |row|
|
|
22
|
+
line = columns.map do |_header, width, key|
|
|
23
|
+
val = row[key.to_s].to_s
|
|
24
|
+
val.length > width ? "#{val[0, width - 1]}…" : val.ljust(width)
|
|
25
|
+
end.join(" ")
|
|
26
|
+
puts line
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require_relative "base_command"
|
|
3
|
+
|
|
4
|
+
module RailsPulse
|
|
5
|
+
module CLI
|
|
6
|
+
class Install < BaseCommand
|
|
7
|
+
AGENT_FILES_DIR = File.expand_path("agent_files", __dir__)
|
|
8
|
+
|
|
9
|
+
# Claude Code loads skills from ~/.claude/skills/<name>/SKILL.md; the
|
|
10
|
+
# file carries its own name and description in YAML front matter.
|
|
11
|
+
CLAUDE_SKILL_PATH = "~/.claude/skills/rails-pulse/SKILL.md".freeze
|
|
12
|
+
|
|
13
|
+
AGENTS_FILE = "agents.md".freeze
|
|
14
|
+
|
|
15
|
+
# Delimit the section so a re-run can replace exactly what a previous
|
|
16
|
+
# run wrote, leaving everything a project put around it untouched.
|
|
17
|
+
SECTION_START = "<!-- rails-pulse:start -->".freeze
|
|
18
|
+
SECTION_END = "<!-- rails-pulse:end -->".freeze
|
|
19
|
+
# Tolerates CRLF line endings and trailing spaces after a marker, which
|
|
20
|
+
# an editor or git's autocrlf can introduce between runs.
|
|
21
|
+
SECTION_PATTERN = /^#{Regexp.escape(SECTION_START)}[ \t]*\r?\n.*?^#{Regexp.escape(SECTION_END)}[ \t]*(?:\r?\n)?/m
|
|
22
|
+
|
|
23
|
+
INTEGRATIONS = {
|
|
24
|
+
"claude" => {
|
|
25
|
+
description: "Claude Code skill → #{CLAUDE_SKILL_PATH}",
|
|
26
|
+
source: "claude_skill.md"
|
|
27
|
+
},
|
|
28
|
+
"agents" => {
|
|
29
|
+
description: "Generic agent descriptor → ./#{AGENTS_FILE}",
|
|
30
|
+
source: "agents.md"
|
|
31
|
+
}
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
default_task :perform
|
|
35
|
+
|
|
36
|
+
desc "perform [INTEGRATION]", "Install an AI agent integration file"
|
|
37
|
+
long_desc <<~DESC
|
|
38
|
+
Copy a pre-built integration file to the correct location for the given agent framework.
|
|
39
|
+
|
|
40
|
+
Available integrations:
|
|
41
|
+
claude Installs a Claude Code skill to #{CLAUDE_SKILL_PATH}.
|
|
42
|
+
Claude Code then knows when and how to use the Rails Pulse MCP tools and CLI.
|
|
43
|
+
An earlier copy of the skill is replaced.
|
|
44
|
+
agents Installs a generic agent descriptor to ./#{AGENTS_FILE} in the current directory.
|
|
45
|
+
Compatible with other AI agent frameworks, including Codex. When an agents.md
|
|
46
|
+
or AGENTS.md already exists it is never overwritten: pass --append to add a
|
|
47
|
+
delimited Rails Pulse section to the end of it, which a later --append
|
|
48
|
+
replaces in place rather than duplicating.
|
|
49
|
+
|
|
50
|
+
Run --list to see all available integrations.
|
|
51
|
+
DESC
|
|
52
|
+
option :list, type: :boolean, desc: "List all available integrations and their destinations"
|
|
53
|
+
option :append, type: :boolean, default: false,
|
|
54
|
+
desc: "For 'agents': add a delimited Rails Pulse section to an existing AGENTS.md"
|
|
55
|
+
def perform(integration = nil)
|
|
56
|
+
if options[:list]
|
|
57
|
+
say "Available integrations:"
|
|
58
|
+
INTEGRATIONS.each { |name, meta| say " #{name.ljust(10)} #{meta[:description]}" }
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
case integration
|
|
63
|
+
when "claude"
|
|
64
|
+
install_claude
|
|
65
|
+
when "agents"
|
|
66
|
+
install_agents
|
|
67
|
+
else
|
|
68
|
+
say "Usage: rails-pulse install [claude|agents]", :yellow
|
|
69
|
+
say " rails-pulse install --list"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def install_claude
|
|
76
|
+
dest = File.expand_path(CLAUDE_SKILL_PATH)
|
|
77
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
78
|
+
FileUtils.cp(File.join(AGENT_FILES_DIR, "claude_skill.md"), dest)
|
|
79
|
+
say "Installed Claude Code skill to #{dest}", :green
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A project's own AGENTS.md is compared case-insensitively: on a
|
|
83
|
+
# case-insensitive filesystem (macOS by default) writing agents.md
|
|
84
|
+
# would silently replace it.
|
|
85
|
+
def install_agents
|
|
86
|
+
source = File.join(AGENT_FILES_DIR, "agents.md")
|
|
87
|
+
existing = Dir.children(Dir.pwd).find { |name| name.casecmp?(AGENTS_FILE) }
|
|
88
|
+
|
|
89
|
+
return append_to_agents(File.join(Dir.pwd, existing), source) if existing && options[:append]
|
|
90
|
+
|
|
91
|
+
if existing
|
|
92
|
+
say "#{File.join(Dir.pwd, existing)} already exists; not overwriting it.", :yellow
|
|
93
|
+
say "Run `rails-pulse install agents --append` to add a Rails Pulse section to it instead."
|
|
94
|
+
exit 1
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Written as a delimited section from the start, so a later --append
|
|
98
|
+
# recognises it and replaces it rather than adding a second copy.
|
|
99
|
+
dest = File.join(Dir.pwd, AGENTS_FILE)
|
|
100
|
+
File.write(dest, section(source, "\n"))
|
|
101
|
+
say "Installed agent descriptor to #{dest}", :green
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The project's own instructions are never rewritten: the Rails Pulse
|
|
105
|
+
# section is delimited, so a re-run replaces it where it sits and
|
|
106
|
+
# everything around it is left exactly as the project wrote it.
|
|
107
|
+
def append_to_agents(dest, source)
|
|
108
|
+
existing = File.read(dest)
|
|
109
|
+
newline = existing.include?("\r\n") ? "\r\n" : "\n"
|
|
110
|
+
section = section(source, newline)
|
|
111
|
+
|
|
112
|
+
if existing.match?(SECTION_PATTERN)
|
|
113
|
+
File.write(dest, existing.sub(SECTION_PATTERN) { section })
|
|
114
|
+
say "Updated the Rails Pulse section in #{dest}", :green
|
|
115
|
+
elsif existing.lstrip.start_with?(descriptor_title(source))
|
|
116
|
+
# A copy written before the section markers existed. Its end cannot
|
|
117
|
+
# be told from anything the project added after it, so it is left
|
|
118
|
+
# for a person to replace rather than guessed at or duplicated.
|
|
119
|
+
say "#{dest} starts with a Rails Pulse descriptor that has no section markers; not appending a second copy.", :yellow
|
|
120
|
+
say "Delete the Rails Pulse part of it (or the file, if nothing else is in it) and run this again."
|
|
121
|
+
exit 1
|
|
122
|
+
else
|
|
123
|
+
separator = if existing.empty? || existing.end_with?("#{newline}#{newline}")
|
|
124
|
+
""
|
|
125
|
+
elsif existing.end_with?(newline)
|
|
126
|
+
newline
|
|
127
|
+
else
|
|
128
|
+
newline * 2
|
|
129
|
+
end
|
|
130
|
+
File.write(dest, "#{existing}#{separator}#{section}")
|
|
131
|
+
say "Appended a Rails Pulse section to #{dest}", :green
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def section(source, newline)
|
|
136
|
+
body = File.read(source).strip.gsub(/\r?\n/, newline)
|
|
137
|
+
"#{SECTION_START}#{newline}#{body}#{newline}#{SECTION_END}#{newline}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def descriptor_title(source)
|
|
141
|
+
File.foreach(source).first.to_s.strip
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|