browserctl 0.11.0 → 0.12.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.
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "../logger"
6
+ require_relative "../redactor"
7
+ require_relative "../secret_resolver_registry"
8
+
9
+ module Browserctl
10
+ module Commands
11
+ # `browserctl trace [<session>] [--no-redact]` — pretty timeline of
12
+ # structured log events across cli.log + daemon.log. Defaults to most
13
+ # recent session.
14
+ #
15
+ # Loose categorisation by inspecting common keys (event/snapshot/request/
16
+ # error). No schema is enforced — this command is tolerant of any JSONL
17
+ # produced by Browserctl::JsonlFormatter.
18
+ #
19
+ # Redaction: ON by default. Secret values are sourced from current ENV
20
+ # patterns (`*_TOKEN`, `*_KEY`, `*_SECRET`, `*_PASSWORD`) and any values
21
+ # captured by `SecretResolverRegistry` during this process. Pass
22
+ # `--no-redact` to disable (local debugging only). Note: when replaying
23
+ # historical traces from a previous process, registry-captured values are
24
+ # gone — only current ENV patterns apply.
25
+ module Trace
26
+ USAGE = "Usage: browserctl trace [<session>] [--no-redact]"
27
+ NO_REDACT_WARNING = "[browserctl] traces include unredacted secret values; " \
28
+ "do not paste this output publicly."
29
+
30
+ LEVEL_COLORS = {
31
+ "DEBUG" => "\e[2;37m", # dim grey
32
+ "INFO" => "\e[36m", # cyan
33
+ "WARN" => "\e[33m", # yellow
34
+ "ERROR" => "\e[31m" # red
35
+ }.freeze
36
+ RESET = "\e[0m"
37
+
38
+ CATEGORY_ICONS = {
39
+ error: "!",
40
+ snapshot: "S",
41
+ network: "N",
42
+ event: "."
43
+ }.freeze
44
+
45
+ OMIT_KEYS = %w[ts level component event msg].freeze
46
+
47
+ def self.run(args, log_dir: Browserctl.log_dir, out: $stdout, err: $stderr)
48
+ abort USAGE if args.include?("-h") || args.include?("--help")
49
+ args = args.dup
50
+ redact = !args.delete("--no-redact")
51
+ session_filter = args.shift
52
+
53
+ if redact
54
+ redactor = build_redactor
55
+ else
56
+ redactor = nil
57
+ warn_no_redact(err)
58
+ end
59
+
60
+ records = load_records(log_dir)
61
+ if records.empty?
62
+ out.puts "No log entries found in #{log_dir}"
63
+ return
64
+ end
65
+
66
+ records = filter_session(records, session_filter)
67
+ if records.empty?
68
+ out.puts "No entries match session=#{session_filter}"
69
+ return
70
+ end
71
+
72
+ render(records, out: out, redactor: redactor)
73
+ end
74
+
75
+ def self.build_redactor
76
+ extra = if defined?(Browserctl::SecretResolverRegistry)
77
+ Browserctl::SecretResolverRegistry.resolved_values
78
+ else
79
+ []
80
+ end
81
+ Browserctl::Redactor.from_env(extra: extra)
82
+ rescue StandardError
83
+ Browserctl::Redactor.new(secrets: [])
84
+ end
85
+
86
+ def self.warn_no_redact(err)
87
+ err&.puts NO_REDACT_WARNING
88
+ end
89
+
90
+ def self.load_records(log_dir)
91
+ paths = Dir.glob(File.join(log_dir, "{cli,daemon}.log"))
92
+ records = paths.flat_map do |path|
93
+ File.foreach(path).filter_map { |line| parse_line(line) }
94
+ end
95
+ records.sort_by { |r| r["ts"].to_s }
96
+ end
97
+
98
+ def self.parse_line(line)
99
+ line = line.strip
100
+ return nil if line.empty?
101
+
102
+ JSON.parse(line)
103
+ rescue JSON::ParserError
104
+ nil
105
+ end
106
+
107
+ # Session resolution. When session_id is stamped on records (future PR),
108
+ # filter/select by it. Otherwise, treat the entire merged stream as one
109
+ # session — caller can scope by tailing/rotating logs.
110
+ # TODO: stamp session_id on every log line so this scopes correctly.
111
+ def self.filter_session(records, session_filter)
112
+ if session_filter
113
+ records.select { |r| r["session_id"].to_s == session_filter }
114
+ else
115
+ ids = records.map { |r| r["session_id"] }.compact.uniq
116
+ if ids.empty?
117
+ records
118
+ else
119
+ recent = ids.last
120
+ records.select { |r| r["session_id"] == recent }
121
+ end
122
+ end
123
+ end
124
+
125
+ def self.render(records, out:, redactor: nil)
126
+ tty = out.respond_to?(:tty?) && out.tty?
127
+ records.each { |r| out.puts(format_line(r, tty: tty, redactor: redactor)) }
128
+ end
129
+
130
+ def self.format_line(record, tty:, redactor: nil)
131
+ level = (record["level"] || "INFO").to_s
132
+ line = format("%-12<ts>s %<icon>s %-5<level>s %-7<comp>s %-22<label>s %<ctx>s",
133
+ ts: format_ts(record["ts"]),
134
+ icon: CATEGORY_ICONS.fetch(categorise(record), "."),
135
+ level: level,
136
+ comp: (record["component"] || "?").to_s,
137
+ label: event_label(record),
138
+ ctx: context_snippet(record)).rstrip
139
+
140
+ line = redactor.redact(line) if redactor
141
+ tty ? colourise(line, level) : line
142
+ end
143
+
144
+ def self.format_ts(timestamp)
145
+ Time.iso8601(timestamp.to_s).strftime("%H:%M:%S.%L")
146
+ rescue ArgumentError, TypeError
147
+ "??:??:??.???"
148
+ end
149
+
150
+ def self.categorise(record)
151
+ return :error if record["level"] == "ERROR" || record["error"]
152
+ return :snapshot if record["snapshot"]
153
+ return :network if record["request"] || record["response"] || record["url"]
154
+
155
+ :event
156
+ end
157
+
158
+ def self.event_label(record)
159
+ (record["event"] || record["snapshot"] || record["request"] ||
160
+ record["msg"] || "-").to_s.slice(0, 22)
161
+ end
162
+
163
+ # Compact "k=v k=v" snippet of remaining structured keys, capped to keep
164
+ # the timeline scannable. Skips fields already shown in fixed columns.
165
+ def self.context_snippet(record)
166
+ pairs = record.except(*OMIT_KEYS)
167
+ return "" if pairs.empty?
168
+
169
+ pairs.map { |k, v| "#{k}=#{format_value(v)}" }.join(" ").slice(0, 120)
170
+ end
171
+
172
+ def self.format_value(value)
173
+ case value
174
+ when String then value.length > 40 ? "#{value[0, 37]}..." : value
175
+ when Array then "[#{value.length}]"
176
+ when Hash then "{#{value.keys.length}}"
177
+ else value.to_s
178
+ end
179
+ end
180
+
181
+ def self.colourise(line, level)
182
+ colour = LEVEL_COLORS[level] || ""
183
+ "#{colour}#{line}#{RESET}"
184
+ end
185
+ end
186
+ end
187
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "errors"
4
+
3
5
  module Browserctl
4
6
  BROWSERCTL_DIR = File.expand_path("~/.browserctl")
5
7
  IDLE_TTL = 30 * 60
@@ -26,7 +28,7 @@ module Browserctl
26
28
  1.upto(99) do |i|
27
29
  return "d#{i}" unless File.exist?(socket_path("d#{i}"))
28
30
  end
29
- raise "too many running daemons (limit: 99)"
31
+ raise Browserctl::Error, "too many running daemons (limit: 99)"
30
32
  end
31
33
 
32
34
  def self.all_daemon_sockets
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "fileutils"
6
+ require "etc"
7
+
8
+ require_relative "version"
9
+ require_relative "logger"
10
+
11
+ module Browserctl
12
+ # Writes a local crash report JSON file when the daemon panics. No telemetry,
13
+ # no upload — purely a local artifact users can attach to bug reports.
14
+ #
15
+ # The writer is intentionally defensive: it must never raise. If anything
16
+ # goes wrong while writing the file, it falls back to a single
17
+ # `[crash-report-failed]` line on stderr and returns nil.
18
+ module CrashReport
19
+ SCHEMA_VERSION = 1
20
+ LAST_EVENTS_LIMIT = 50
21
+
22
+ # @param error [Exception] the unhandled exception that took the daemon down
23
+ # @param log_path [String, nil] path to the daemon's JSONL log; the last
24
+ # {LAST_EVENTS_LIMIT} valid records are tailed in
25
+ # @return [String, nil] the path of the written crash file, or nil on failure
26
+ def self.write(error:, log_path: nil)
27
+ ts = Time.now.utc
28
+ filename = "crash-#{ts.iso8601(3).gsub(':', '-')}.json"
29
+ dir = Browserctl.log_dir
30
+ FileUtils.mkdir_p(dir, mode: 0o700)
31
+ path = File.join(dir, filename)
32
+
33
+ payload = {
34
+ schema_version: SCHEMA_VERSION,
35
+ ts: ts.iso8601(3),
36
+ daemon_version: Browserctl::VERSION,
37
+ ruby_version: RUBY_VERSION,
38
+ os: os_info,
39
+ error: error_info(error),
40
+ backtrace: Array(error.backtrace),
41
+ last_events: tail_events(log_path)
42
+ }
43
+
44
+ File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
45
+ f.write(JSON.pretty_generate(payload))
46
+ end
47
+ path
48
+ rescue StandardError => e
49
+ warn "[crash-report-failed] #{e.class}: #{e.message}"
50
+ nil
51
+ end
52
+
53
+ def self.os_info
54
+ info = { platform: RUBY_PLATFORM }
55
+ uname = Etc.uname
56
+ info[:version] = uname[:version] if uname.is_a?(Hash) && uname[:version]
57
+ info[:sysname] = uname[:sysname] if uname.is_a?(Hash) && uname[:sysname]
58
+ info
59
+ rescue StandardError
60
+ { platform: RUBY_PLATFORM }
61
+ end
62
+ private_class_method :os_info
63
+
64
+ def self.error_info(error)
65
+ info = {
66
+ class: error.class.name,
67
+ message: error.message.to_s
68
+ }
69
+ info[:code] = error.code if error.respond_to?(:code) && error.code
70
+ info
71
+ end
72
+ private_class_method :error_info
73
+
74
+ def self.tail_events(log_path)
75
+ return [] unless log_path && File.exist?(log_path)
76
+
77
+ lines = File.readlines(log_path).last(LAST_EVENTS_LIMIT * 2)
78
+ events = []
79
+ lines.reverse_each do |line|
80
+ line = line.strip
81
+ next if line.empty?
82
+
83
+ begin
84
+ events.unshift(JSON.parse(line))
85
+ rescue JSON::ParserError
86
+ next
87
+ end
88
+ break if events.length >= LAST_EVENTS_LIMIT
89
+ end
90
+ events
91
+ rescue StandardError
92
+ []
93
+ end
94
+ private_class_method :tail_events
95
+ end
96
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ class Error < StandardError
5
+ # Canonical enum of stable error code strings emitted on the wire and in
6
+ # CLI stderr payloads. Codes are SCREAMING_SNAKE_CASE and must remain
7
+ # stable across releases — agents branch on these deterministically.
8
+ #
9
+ # The full sweep that wires every raise site to one of these codes lands
10
+ # in PR #8 of the v0.12 "Solid" milestone. This module is the single
11
+ # source of truth those raises will reference.
12
+ module Codes
13
+ AUTH_REQUIRED = "AUTH_REQUIRED"
14
+ SELECTOR_NOT_FOUND = "SELECTOR_NOT_FOUND"
15
+ STATE_EXPIRED = "STATE_EXPIRED"
16
+ SECRET_RESOLUTION_FAILED = "SECRET_RESOLUTION_FAILED"
17
+ DAEMON_UNREACHABLE = "DAEMON_UNREACHABLE"
18
+ PROTOCOL_MISMATCH = "PROTOCOL_MISMATCH"
19
+ DOMAIN_NOT_ALLOWED = "DOMAIN_NOT_ALLOWED"
20
+ KEY_NOT_FOUND = "KEY_NOT_FOUND"
21
+ GENERIC = "GENERIC"
22
+
23
+ ALL = [
24
+ AUTH_REQUIRED,
25
+ SELECTOR_NOT_FOUND,
26
+ STATE_EXPIRED,
27
+ SECRET_RESOLUTION_FAILED,
28
+ DAEMON_UNREACHABLE,
29
+ PROTOCOL_MISMATCH,
30
+ DOMAIN_NOT_ALLOWED,
31
+ KEY_NOT_FOUND,
32
+ GENERIC
33
+ ].freeze
34
+
35
+ def self.all
36
+ ALL
37
+ end
38
+
39
+ def self.valid?(code)
40
+ ALL.include?(code)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "codes"
4
+
5
+ module Browserctl
6
+ class Error < StandardError
7
+ # Stable mapping from a canonical {Browserctl::Error::Codes} string to a
8
+ # process exit status. The CLI's top-level rescue uses this so AI agents
9
+ # and shell scripts can branch on `$?` deterministically without parsing
10
+ # stderr.
11
+ #
12
+ # Stability contract:
13
+ # - Exit code integers are part of the v0.12 stable surface and will not
14
+ # be renumbered without a major version bump.
15
+ # - Unknown / unmapped codes intentionally fall through to {GENERIC} (1)
16
+ # so an unfamiliar code never silently surfaces as a non-error (0).
17
+ #
18
+ # See docs/reference/exit-codes.md for the operator-facing table.
19
+ module ExitCodes
20
+ OK = 0
21
+ GENERIC = 1
22
+ DRIFT = 2
23
+ AUTH_REQUIRED = 3
24
+ DAEMON_UNREACHABLE = 4
25
+ PROTOCOL_MISMATCH = 5
26
+ SELECTOR_NOT_FOUND = 6
27
+ STATE_EXPIRED = 7
28
+
29
+ # Canonical Codes string → exit status integer. Codes without an entry
30
+ # (e.g. DOMAIN_NOT_ALLOWED, KEY_NOT_FOUND, SECRET_RESOLUTION_FAILED,
31
+ # GENERIC) collapse to {GENERIC} for now; they may earn dedicated exit
32
+ # codes in a future milestone.
33
+ #
34
+ # DRIFT (2) is reserved for a future Codes::DRIFT and currently has no
35
+ # entry in this table — drift-related raises fall through to GENERIC
36
+ # until that code is introduced.
37
+ TABLE = {
38
+ Codes::AUTH_REQUIRED => AUTH_REQUIRED,
39
+ Codes::DAEMON_UNREACHABLE => DAEMON_UNREACHABLE,
40
+ Codes::PROTOCOL_MISMATCH => PROTOCOL_MISMATCH,
41
+ Codes::SELECTOR_NOT_FOUND => SELECTOR_NOT_FOUND,
42
+ Codes::STATE_EXPIRED => STATE_EXPIRED
43
+ }.freeze
44
+
45
+ # @param code [String, nil] a canonical code from {Browserctl::Error::Codes}
46
+ # @return [Integer] mapped exit status; {GENERIC} for nil or unknown codes
47
+ def self.for(code)
48
+ return GENERIC if code.nil?
49
+
50
+ TABLE.fetch(code, GENERIC)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "codes"
4
+
5
+ module Browserctl
6
+ class Error < StandardError
7
+ # Maps a stable error code (see {Browserctl::Error::Codes}) to a short
8
+ # imperative sentence telling the operator (or AI agent) what to try
9
+ # next. Codes without an explicit entry fall back to a generic pointer
10
+ # to the error reference doc (added in PR #11 of v0.12).
11
+ module SuggestedActions
12
+ DEFAULT = "See docs/reference/errors.md for guidance."
13
+
14
+ TABLE = {
15
+ Codes::AUTH_REQUIRED =>
16
+ "Run the suggested flow to refresh credentials, then retry.",
17
+ Codes::SELECTOR_NOT_FOUND =>
18
+ "Re-run snapshot to get fresh refs, then retry with a stable ref or selector.",
19
+ Codes::STATE_EXPIRED =>
20
+ "Re-save the state bundle (state save) or rotate it (state rotate).",
21
+ Codes::SECRET_RESOLUTION_FAILED =>
22
+ "Verify the secret resolver config and that the underlying secret exists.",
23
+ Codes::DAEMON_UNREACHABLE =>
24
+ "Start the daemon with 'browserctl daemon start', then retry.",
25
+ Codes::PROTOCOL_MISMATCH =>
26
+ "Upgrade browserctl to a version that supports this artifact's format version.",
27
+ Codes::DOMAIN_NOT_ALLOWED =>
28
+ "Add the domain to your policy allowlist or use an allowed URL.",
29
+ Codes::KEY_NOT_FOUND =>
30
+ "Verify the key was stored in this daemon session before fetching.",
31
+ Codes::GENERIC => DEFAULT
32
+ }.freeze
33
+
34
+ # @param code [String, nil] a SCREAMING_SNAKE code from {Codes}
35
+ # @return [String] suggested action sentence; never nil
36
+ def self.for(code)
37
+ TABLE.fetch(code, DEFAULT)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -1,28 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "error/codes"
4
+ require_relative "error/exit_codes"
5
+ require_relative "error/suggested_actions"
6
+
3
7
  module Browserctl
4
8
  # Base error class for all browserctl daemon errors.
5
9
  # Subclasses carry a machine-readable `code` that appears in wire responses.
10
+ # The canonical enum of stable codes lives in {Browserctl::Error::Codes};
11
+ # the sweep that retrofits every raise to use those codes lands in a later
12
+ # v0.12 PR.
6
13
  # @attr_reader code [String] machine-readable error code
14
+ # @attr_reader context [Hash] free-form structured fields (selector, path, ...)
7
15
  class Error < StandardError
8
16
  def self.default_code = "error"
9
17
 
10
- attr_reader :code
18
+ attr_reader :code, :context
11
19
 
12
- def initialize(msg = nil, code: self.class.default_code)
13
- @code = code
20
+ def initialize(msg = nil, code: self.class.default_code, context: {})
21
+ @code = code
22
+ @context = context || {}
14
23
  super(msg)
15
24
  end
25
+
26
+ # Returns the canonical structured payload emitted on the daemon wire and
27
+ # on CLI stderr. Shape is stable across releases — agents branch on `code`
28
+ # without parsing prose.
29
+ # @return [Hash{Symbol => Object}]
30
+ def to_payload
31
+ {
32
+ code: code,
33
+ message: message,
34
+ context: context,
35
+ suggested_action: SuggestedActions.for(code)
36
+ }
37
+ end
16
38
  end
17
39
 
18
- class PageNotFound < Error; def self.default_code = "page_not_found" end
19
- class SelectorNotFound < Error; def self.default_code = "selector_not_found" end
20
- class RefNotFound < Error; def self.default_code = "ref_not_found" end
21
- class PathNotAllowed < Error; def self.default_code = "path_not_allowed" end
22
- class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
23
- class TimeoutError < Error; def self.default_code = "timeout" end
24
- class KeyNotFound < Error; def self.default_code = "key_not_found" end
25
- class DaemonUnavailableError < Error; def self.default_code = "daemon_unavailable" end
40
+ class PageNotFound < Error; def self.default_code = "page_not_found" end
41
+ class SelectorNotFound < Error; def self.default_code = Codes::SELECTOR_NOT_FOUND end
42
+ class RefNotFound < Error; def self.default_code = "ref_not_found" end
43
+ class PathNotAllowed < Error; def self.default_code = "path_not_allowed" end
44
+ class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
45
+ class TimeoutError < Error; def self.default_code = "timeout" end
46
+ class KeyNotFound < Error; def self.default_code = "key_not_found" end
47
+ class DaemonUnavailableError < Error; def self.default_code = Codes::DAEMON_UNREACHABLE end
26
48
  class BrowserNotFound < Error; def self.default_code = "browser_not_found" end
27
49
 
28
50
  # Raised when the daemon detects that the current page needs authentication —
@@ -31,7 +53,7 @@ module Browserctl
31
53
  # suggested flow (from the bundle manifest) so callers can recover without
32
54
  # additional lookups. The CLI maps this code to exit status 7.
33
55
  class AuthRequiredError < Error
34
- def self.default_code = "AUTH_REQUIRED"
56
+ def self.default_code = Codes::AUTH_REQUIRED
35
57
 
36
58
  AUTH_REQUIRED_EXIT_CODE = 7
37
59
 
@@ -50,17 +72,25 @@ module Browserctl
50
72
  code: self.class.default_code,
51
73
  state: state,
52
74
  suggested_flow: suggested_flow,
53
- reason: reason
75
+ reason: reason,
76
+ context: { state: state, suggested_flow: suggested_flow, reason: reason }.compact,
77
+ suggested_action: SuggestedActions.for(self.class.default_code)
54
78
  }.compact
55
79
  end
56
80
  end
57
81
 
58
82
  class WorkflowError < Error; def self.default_code = "workflow_error" end
59
- class SecretResolverError < WorkflowError; def self.default_code = "secret_resolver_error" end
83
+ class SecretResolverError < WorkflowError; def self.default_code = Codes::SECRET_RESOLUTION_FAILED end
60
84
 
61
85
  class FlowError < WorkflowError; def self.default_code = "flow_error" end
62
86
  class FlowParamError < FlowError; def self.default_code = "flow_param_error" end
63
87
  class FlowPreconditionError < FlowError; def self.default_code = "flow_precondition_failed" end
64
88
  class FlowStepError < FlowError; def self.default_code = "flow_step_failed" end
65
89
  class FlowPostconditionError < FlowError; def self.default_code = "flow_postcondition_failed" end
90
+
91
+ # Raised when a persisted artifact (bundle, recording, workflow, etc.) has a
92
+ # `version:` header that this build does not know how to read. The full error
93
+ # code taxonomy lands in WS-2 (PR #7); this class is a forward-reference stub
94
+ # so WS-1 PRs can already raise the canonical code.
95
+ class ProtocolMismatch < Error; def self.default_code = Codes::PROTOCOL_MISMATCH end
66
96
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Browserctl
6
+ # Format-version header convention.
7
+ #
8
+ # Every persisted browserctl artifact declares its format version on the very
9
+ # first line as `version: <int>`. This module is the convention's single
10
+ # source of truth; per-format adoption (bundle, recording, workflow) lands in
11
+ # later WS-1 PRs. See `docs/reference/format-versions.md`.
12
+ module FormatVersion
13
+ HEADER_RE = /\Aversion:\s*(\d+)\s*\z/
14
+
15
+ module_function
16
+
17
+ # Parse the version header from an IO or String. Returns the Integer
18
+ # version. Raises Browserctl::ProtocolMismatch if the header is missing or
19
+ # malformed.
20
+ def parse(io_or_string)
21
+ first_line = io_or_string.respond_to?(:gets) ? io_or_string.gets : io_or_string.to_s.each_line.first
22
+ raise ProtocolMismatch, "missing version header" if first_line.nil?
23
+
24
+ match = HEADER_RE.match(first_line.chomp)
25
+ raise ProtocolMismatch, "malformed version header: #{first_line.inspect}" unless match
26
+
27
+ Integer(match[1])
28
+ end
29
+
30
+ # Returns the canonical header string for a given integer version.
31
+ def stamp(version:)
32
+ raise ArgumentError, "version must be a non-negative Integer" unless version.is_a?(Integer) && version >= 0
33
+
34
+ "version: #{version}\n"
35
+ end
36
+ end
37
+ end