rails-markup 1.2.4 → 1.3.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: c558fe97e361a67cf9bea35dda5c4477c631d14d4fe3883a252e7df41a9ffce5
4
- data.tar.gz: e1c04335a8337fbb2a9f05031c12980be2fbf4eeffd6dc7c767840801238a910
3
+ metadata.gz: d852927076b8b5e1774675692c45b575eb9dd6337c934637e90c693310d96f4d
4
+ data.tar.gz: 3608bdd64056b7cb3373de2cd4ea0797a1e6dc3e021457ec96697a4d3f8c7919
5
5
  SHA512:
6
- metadata.gz: 879ed444ed7087e294ae9ac493463f6e1ce93be51e1a32743c75e4a4de730eaa5c25637511feea145c4ec3825ccb716391f158528febd7c3e80acc46a382c14e
7
- data.tar.gz: 4d6fefa5e2fd4f5f04aa60a0b8ba6d0ea7cfa6447d1303ed252d4b3a92dbe71b3b586337e7f60010c1236d34441c1adf36a449b8e24dfff50b19154ef3cc83be
6
+ metadata.gz: ac1ee4649e024d9a3fa306bf3fefb9f6443c2d89cec4d33de6b504831123228f1db3cc2e20414914fdec41150e832ea077d76cfe1c606859c2177626eb643251
7
+ data.tar.gz: e8942c6bcbff10487ab62170039b6c62c0c0ffe87a59cb77ac8df87792c6365109d222451fc8756831fad1284b1336f7d0adff3dbf3698b257e2c0e2b1346899
@@ -381,7 +381,7 @@
381
381
  this._boundMouseUp = (e) => self._handleMouseUp(e);
382
382
  this._boundClick = (e) => self._handleClick(e);
383
383
  this._boundKeyDown = (e) => self._handleKeyDown(e);
384
- this._boundTouchStart = (e) => { if (self.active && e.touches[0]) { const t = e.touches[0]; self._handleMouseDown({ clientX: t.clientX, clientY: t.clientY }); } };
384
+ this._boundTouchStart = (e) => { if (self.active && e.touches[0]) { const t = e.touches[0]; const el = document.elementFromPoint(t.clientX, t.clientY); if (el && !self._isToolbar(el) && e.cancelable) e.preventDefault(); self._handleMouseDown({ clientX: t.clientX, clientY: t.clientY }); } };
385
385
  this._boundTouchEnd = (e) => { if (self.active && e.changedTouches[0]) { const t = e.changedTouches[0]; const el = document.elementFromPoint(t.clientX, t.clientY); if (el && !self._isToolbar(el)) { e.preventDefault(); self._handleMouseUp({ clientX: t.clientX, clientY: t.clientY, preventDefault(){}, stopPropagation(){} }); } } };
386
386
 
387
387
  // Turbo Frames — partial DOM update, reposition pins
@@ -512,7 +512,14 @@
512
512
 
513
513
  _handleMouseDown(event) {
514
514
  const el = document.elementFromPoint(event.clientX, event.clientY);
515
- if (el && !this._isToolbar(el)) this.clickedElement = el;
515
+ if (el && !this._isToolbar(el)) {
516
+ this.clickedElement = el;
517
+ // Suppress the press so host controls (buttons, drag handles, form
518
+ // fields) don't act before mouseup/click is blocked. Guard for the
519
+ // synthetic object passed from the touchstart handler.
520
+ if (typeof event.preventDefault === "function") event.preventDefault();
521
+ if (typeof event.stopPropagation === "function") event.stopPropagation();
522
+ }
516
523
  },
517
524
 
518
525
  async _handleMouseUp(event) {
@@ -1035,13 +1042,17 @@
1035
1042
 
1036
1043
  // ---- Storage ----
1037
1044
 
1038
- _storageKey() { return "rm-annotations"; },
1045
+ _storageKey() {
1046
+ const endpoint = (this.endpoint || "/feedback/api").replace(/\/+$/, "") || "/";
1047
+ return `rm-annotations:${encodeURIComponent(endpoint)}`;
1048
+ },
1039
1049
  _pageUrl() { return window.location.pathname + window.location.search; },
1040
- _pageStorageKey() { return "rm-annotations:" + this._pageUrl(); },
1050
+ _pageStorageKey() { return this._storageKey() + ":" + this._pageUrl(); },
1041
1051
 
1042
1052
  _saveToStorage() {
1043
1053
  try {
1044
- // Save all annotations to a single global key
1054
+ // Cross-tab storage-event merging is deferred: safely reconciling ordered
1055
+ // upserts and tombstones needs conflict semantics, not a last-write merge.
1045
1056
  localStorage.setItem(this._storageKey(), JSON.stringify({
1046
1057
  annotations: this.annotations,
1047
1058
  nextId: this.nextId,
@@ -1436,7 +1447,8 @@
1436
1447
 
1437
1448
  _loadFromStorage() {
1438
1449
  try {
1439
- // Load from global key first
1450
+ // Old unnamespaced keys are intentionally not read because they may belong
1451
+ // to another user or toolbar mount on the same origin.
1440
1452
  let raw = localStorage.getItem(this._storageKey());
1441
1453
  if (raw) {
1442
1454
  const data = JSON.parse(raw);
@@ -1459,8 +1471,8 @@
1459
1471
  },
1460
1472
 
1461
1473
  _migratePageAnnotations() {
1462
- // Find and merge any legacy per-page annotation keys
1463
- const prefix = "rm-annotations:";
1474
+ // Find and merge per-page annotation keys only within this endpoint namespace.
1475
+ const prefix = this._storageKey() + ":";
1464
1476
  const migratedKeys = [];
1465
1477
  const seenIds = new Set(this.annotations.map(a => a.id));
1466
1478
  const consolidatedClientIds = new Set(this.annotations.map(a => a.clientId).filter(clientId => this._validClientId(clientId)));
@@ -1537,6 +1549,8 @@
1537
1549
  return { annotation, index };
1538
1550
  });
1539
1551
 
1552
+ this._normalizeOutboxEnvelopes();
1553
+
1540
1554
  const byClientId = new Map();
1541
1555
  normalized.forEach(candidate => {
1542
1556
  const current = byClientId.get(candidate.annotation.clientId);
@@ -1549,7 +1563,10 @@
1549
1563
  this._assignDisplayIds();
1550
1564
  this.annotations.forEach(annotation => {
1551
1565
  const mapped = annotation.serverId != null;
1552
- const queued = Boolean(this.outbox[annotation.clientId]);
1566
+ const queuedEntry = this.outbox[annotation.clientId];
1567
+ const queued = Boolean(queuedEntry);
1568
+ const annotationRevision = Number.isInteger(annotation.revision) && annotation.revision >= 0 ? annotation.revision : 0;
1569
+ annotation.revision = annotationRevision;
1553
1570
  annotation.syncState = (queued && annotation.syncState === "failed")
1554
1571
  ? "failed"
1555
1572
  : ((queued || !mapped) ? "pending" : "synced");
@@ -1557,6 +1574,9 @@
1557
1574
  annotation.dirtyFields = this._legacyDirtyFields(annotation);
1558
1575
  this.outbox[annotation.clientId] = {
1559
1576
  type: "upsert",
1577
+ clientId: annotation.clientId,
1578
+ revision: annotation.revision,
1579
+ syncState: "pending",
1560
1580
  annotation: this._desiredState(annotation),
1561
1581
  dirtyFields: annotation.dirtyFields.slice()
1562
1582
  };
@@ -1564,6 +1584,45 @@
1564
1584
  });
1565
1585
  },
1566
1586
 
1587
+ _normalizeOutboxEnvelopes() {
1588
+ const normalized = {};
1589
+
1590
+ Object.entries(this.outbox).forEach(([storedClientId, candidate]) => {
1591
+ if (!this._plainObject(candidate)) return;
1592
+
1593
+ const nestedClientId = candidate.annotation?.clientId;
1594
+ const clientId = this._validClientId(nestedClientId)
1595
+ ? nestedClientId
1596
+ : (this._validClientId(candidate.clientId)
1597
+ ? candidate.clientId
1598
+ : (this._validClientId(storedClientId) ? storedClientId : null));
1599
+ if (!clientId) return;
1600
+
1601
+ const type = candidate.type === "delete"
1602
+ ? "delete"
1603
+ : ((candidate.type === "upsert" || this._plainObject(candidate.annotation)) ? "upsert" : null);
1604
+ if (!type) return;
1605
+
1606
+ const annotation = this.annotations.find(record => record.clientId === clientId);
1607
+ const candidateRevision = Number.isInteger(candidate.revision) && candidate.revision >= 0 ? candidate.revision : 0;
1608
+ const annotationRevision = Number.isInteger(annotation?.revision) && annotation.revision >= 0 ? annotation.revision : 0;
1609
+ const envelope = Object.assign({}, candidate, {
1610
+ type,
1611
+ clientId,
1612
+ revision: Math.max(candidateRevision, annotationRevision),
1613
+ syncState: candidate.syncState === "failed" ? "failed" : "pending"
1614
+ });
1615
+
1616
+ if (type === "upsert") {
1617
+ envelope.annotation = Object.assign({}, candidate.annotation, { clientId });
1618
+ envelope.dirtyFields = this._mergeDirtyFields(candidate.dirtyFields || envelope.annotation.dirtyFields || []);
1619
+ }
1620
+ normalized[clientId] = envelope;
1621
+ });
1622
+
1623
+ this.outbox = normalized;
1624
+ },
1625
+
1567
1626
  _isNewerLocalRecord(candidate, current) {
1568
1627
  const timestamp = value => {
1569
1628
  const parsed = Date.parse(value || "");
@@ -223,10 +223,22 @@ module RailsMarkup
223
223
  # each (not find_each) so the export keeps the scope's :recent ordering —
224
224
  # find_each ignores ORDER BY and batches by primary key.
225
225
  scope.each do |ann|
226
- csv << [ann.id, ann.status, ann.intent, ann.severity, ann.content, ann.page_url,
227
- ann.author_name, ann.selected_text, ann.created_at.iso8601, ann.updated_at.iso8601]
226
+ csv << [ann.id, ann.status, ann.intent, ann.severity,
227
+ csv_safe(ann.content), csv_safe(ann.page_url),
228
+ csv_safe(ann.author_name), csv_safe(ann.selected_text),
229
+ ann.created_at.iso8601, ann.updated_at.iso8601]
228
230
  end
229
231
  end
230
232
  end
233
+
234
+ # Neutralize CSV/spreadsheet formula injection: a leading =, +, -, @, or
235
+ # control char makes Excel/Sheets evaluate the cell as a formula. Prefix
236
+ # such values with an apostrophe so they're treated as literal text.
237
+ def csv_safe(value)
238
+ str = value.to_s
239
+ return str unless str.match?(/\A[=+\-@\t\r]/)
240
+
241
+ "'#{str}"
242
+ end
231
243
  end
232
244
  end
@@ -54,8 +54,11 @@ module RailsMarkup
54
54
  end
55
55
 
56
56
  def authenticate_token!
57
- # Development allow all requests without token (dev server may bind to LAN IP)
58
- return if Rails.env.development?
57
+ # Development convenience: skip token auth so you can reach the API from
58
+ # another device on your LAN. This permits unauthenticated reads and
59
+ # writes from any network peer — set
60
+ # config.require_api_token_in_development to lock it down.
61
+ return if Rails.env.development? && !RailsMarkup.config.require_api_token_in_development
59
62
 
60
63
  token = RailsMarkup.config.api_token
61
64
  return head(:not_found) if token.nil?
@@ -114,28 +114,35 @@ module RailsMarkup
114
114
  end
115
115
 
116
116
  def resolve!(summary: nil)
117
- return self if status == "resolved" # idempotent re-resolving is a no-op
118
- raise "Cannot resolve a #{status} annotation" unless status.in?(%w[pending acknowledged])
117
+ # with_lock reloads under a row lock so a concurrent reply/resolve can't
118
+ # read a stale thread and silently drop the other write on save.
119
+ with_lock do
120
+ return self if status == "resolved" # idempotent — re-resolving is a no-op
121
+ raise "Cannot resolve a #{status} annotation" unless status.in?(%w[pending acknowledged])
119
122
 
120
- transaction do
121
123
  add_thread_entry(role: "agent", message: summary) if summary.present?
122
124
  update!(status: "resolved")
123
125
  end
126
+ self
124
127
  end
125
128
 
126
129
  def dismiss!(reason: nil)
127
- return self if status == "dismissed" # idempotent — re-dismissing is a no-op
128
- raise "Cannot dismiss a #{status} annotation" unless status.in?(%w[pending acknowledged])
130
+ with_lock do
131
+ return self if status == "dismissed" # idempotent — re-dismissing is a no-op
132
+ raise "Cannot dismiss a #{status} annotation" unless status.in?(%w[pending acknowledged])
129
133
 
130
- transaction do
131
134
  add_thread_entry(role: "agent", message: reason) if reason.present?
132
135
  update!(status: "dismissed")
133
136
  end
137
+ self
134
138
  end
135
139
 
136
140
  def add_reply!(message:, role: "agent")
137
- add_thread_entry(role: role, message: message)
138
- save!
141
+ with_lock do
142
+ add_thread_entry(role: role, message: message)
143
+ save!
144
+ end
145
+ self
139
146
  end
140
147
 
141
148
  def as_api_json
@@ -1,5 +1,11 @@
1
1
  <%# Rails Markup annotation toolbar — self-contained, no Tailwind/Stimulus dependency %>
2
2
  <% if RailsMarkup.config.toolbar_enabled %>
3
+ <%# Authorization sentinel: present only when this partial renders (i.e. the
4
+ layout gate authorized the request). The turbo:load handler below checks for
5
+ it after each navigation and tears the toolbar down if it's gone — otherwise
6
+ the document-level listener would recreate the toolbar (and expose cached
7
+ annotations) after a logout Turbo visit whose new body omits the partial. %>
8
+ <span id="rm-toolbar-gate" hidden aria-hidden="true"></span>
3
9
  <script>
4
10
  <%== File.read(File.expand_path("../../../assets/javascripts/rails_markup/toolbar.js", __dir__)) %>
5
11
  </script>
@@ -19,9 +25,15 @@
19
25
  };
20
26
  // Init immediately (DOM is ready — script is at end of body)
21
27
  RailsMarkupToolbar.init(opts);
22
- // Re-init after Turbo Drive navigations (DOMContentLoaded won't fire again)
28
+ // After Turbo Drive navigations (DOMContentLoaded won't fire again), only
29
+ // re-init when the new page is still authorized (gate sentinel present);
30
+ // otherwise tear the toolbar down so it can't persist past a logout.
23
31
  document.addEventListener("turbo:load", function() {
24
- RailsMarkupToolbar.init(opts);
32
+ if (document.getElementById("rm-toolbar-gate")) {
33
+ RailsMarkupToolbar.init(opts);
34
+ } else if (window.RailsMarkupToolbar && typeof RailsMarkupToolbar.destroy === "function") {
35
+ RailsMarkupToolbar.destroy();
36
+ }
25
37
  });
26
38
  })();
27
39
  </script>
@@ -5,7 +5,10 @@ class CreateRailsMarkupAnnotations < ActiveRecord::Migration<%= migration_versio
5
5
 
6
6
  create_table :<%= options[:table_name] %> do |t|
7
7
  t.bigint :user_id
8
- t.string :page_url, null: false
8
+ # 2048 to match the model's page_url length validation. The default
9
+ # string limit (255) would raise DB errors on PostgreSQL/MySQL for
10
+ # model-valid long URLs (SQLite is lax, so tests wouldn't catch it).
11
+ t.string :page_url, limit: 2048, null: false
9
12
  t.send json_type, :target, default: {}
10
13
  t.text :content, null: false
11
14
  t.string :intent, null: false, default: "change"
@@ -20,7 +23,13 @@ class CreateRailsMarkupAnnotations < ActiveRecord::Migration<%= migration_versio
20
23
  end
21
24
 
22
25
  add_index :<%= options[:table_name] %>, [:status, :created_at]
23
- add_index :<%= options[:table_name] %>, :page_url
26
+ # A full 2048-char index blows past MySQL/InnoDB's key-length limit on
27
+ # utf8mb4, so index only a prefix there; other adapters index the column.
28
+ if connection.adapter_name.downcase.include?("mysql")
29
+ add_index :<%= options[:table_name] %>, :page_url, length: 191
30
+ else
31
+ add_index :<%= options[:table_name] %>, :page_url
32
+ end
24
33
  add_index :<%= options[:table_name] %>, :user_id
25
34
  add_index :<%= options[:table_name] %>, :client_uuid, unique: true
26
35
  end
@@ -70,8 +70,19 @@ module RailsMarkup
70
70
  <% end %>
71
71
  ERB
72
72
 
73
- if File.read(File.join(destination_root, layout_path)).include?("rails_markup/shared/toolbar")
74
- say_status :skip, "toolbar already present in #{layout_path}", :yellow
73
+ content = File.read(File.join(destination_root, layout_path))
74
+
75
+ if content.include?("rails_markup/shared/toolbar")
76
+ # Upgrade the pre-1.2.3 partial-existence gate, which rendered the
77
+ # toolbar for every visitor, to the admin-gated block. Leave any
78
+ # custom (hand-edited) block untouched so we don't clobber it.
79
+ legacy = /^[ \t]*<%#\s*Rails Markup annotation toolbar\s*%>\r?\n[ \t]*<%\s*if\s+lookup_context\.exists\?.*?<%\s*end\s*%>\r?\n?/m
80
+ if content =~ legacy
81
+ gsub_file layout_path, legacy, toolbar_block
82
+ say_status :update, "upgraded toolbar to admin-gated render in #{layout_path}", :green
83
+ else
84
+ say_status :skip, "toolbar already present in #{layout_path} (left as-is)", :yellow
85
+ end
75
86
  return
76
87
  end
77
88
 
@@ -39,8 +39,10 @@ module RailsMarkup
39
39
  bin/markup server --port 5000 # custom port
40
40
  DESC
41
41
  method_option :port, type: :numeric, default: 4747, desc: "HTTP server port"
42
+ method_option :host, type: :string, default: "127.0.0.1",
43
+ desc: "Bind address (loopback by default; use 0.0.0.0 to expose on the LAN)"
42
44
  def server
43
- srv = RailsMarkup::Server.new(port: options[:port])
45
+ srv = RailsMarkup::Server.new(port: options[:port], bind: options[:host])
44
46
  srv.start
45
47
  end
46
48
 
@@ -641,17 +643,25 @@ module RailsMarkup
641
643
  "local"
642
644
  end
643
645
 
644
- # Check local first, then fall back to global/codex configs.
646
+ # Merge configs per-key with local taking precedence over global over codex.
647
+ # Merging (rather than returning the first non-empty config) means a local
648
+ # config that only defines dev values doesn't shadow production credentials
649
+ # stored globally or in the codex config.
645
650
  def resolve_mcp_env
646
- McpConfig::SCOPES.each do |scope|
651
+ merged = {}
652
+ # Reverse so the highest-precedence scope (local) is applied last and wins.
653
+ McpConfig::SCOPES.reverse_each do |scope|
647
654
  config = McpConfig.new(scope: scope)
648
655
  next unless config.exist?
649
656
 
650
- env = config.raw_env
651
- return env unless env.empty?
657
+ # Skip blank values so an empty key in a higher-precedence config
658
+ # doesn't clobber a real value from a lower-precedence one.
659
+ config.raw_env.each do |key, value|
660
+ merged[key] = value unless value.to_s.strip.empty?
661
+ end
652
662
  end
653
663
 
654
- {}
664
+ merged
655
665
  end
656
666
 
657
667
  def resolve_env(production)
@@ -674,6 +684,12 @@ module RailsMarkup
674
684
  return nil
675
685
  end
676
686
 
687
+ unless base_url.to_s.downcase.start_with?("https://")
688
+ say "Refusing to send the production token over an insecure connection.", :red
689
+ say "Production URL must use HTTPS (got: #{base_url})", :red
690
+ return nil
691
+ end
692
+
677
693
  { base_url: base_url, token: token, mount_path: mount }
678
694
  else
679
695
  base_url = options[:url] || mcp_env["RAILS_MARKUP_DEV_URL"]
@@ -15,6 +15,13 @@ module RailsMarkup
15
15
  # Set to nil to disable external API.
16
16
  attr_accessor :api_token
17
17
 
18
+ # In development the external API skips token auth by default so you can
19
+ # reach it from another device on your LAN (e.g. your phone). That allows
20
+ # unauthenticated reads AND writes from any network peer. Set to true to
21
+ # require the bearer token even in development.
22
+ # Default: false (open in development)
23
+ attr_accessor :require_api_token_in_development
24
+
18
25
  # Database table name for annotations.
19
26
  attr_accessor :table_name
20
27
 
@@ -77,6 +84,7 @@ module RailsMarkup
77
84
  def initialize
78
85
  @base_controller_class = "RailsMarkup::ApplicationController"
79
86
  @api_token = nil
87
+ @require_api_token_in_development = false
80
88
  @table_name = "rails_markup_annotations"
81
89
  @per_page = 25
82
90
  @toolbar_accent = "indigo"
@@ -9,15 +9,22 @@ module RailsMarkup
9
9
  class HttpServer
10
10
  attr_reader :port, :store
11
11
 
12
- def initialize(store:, port: 4747, logger: nil)
12
+ attr_reader :bind
13
+
14
+ def initialize(store:, port: 4747, bind: "127.0.0.1", logger: nil)
13
15
  @store = store
14
16
  @port = port
17
+ @bind = bind
15
18
  @logger = logger || WEBrick::Log.new($stderr, WEBrick::Log::WARN)
16
19
  end
17
20
 
18
21
  def start
22
+ # Bind to loopback by default — this store server is unauthenticated, so
23
+ # binding to 0.0.0.0 would expose it to the whole LAN. Pass bind: "0.0.0.0"
24
+ # (bin/markup server --host 0.0.0.0) to deliberately expose it.
19
25
  @server = WEBrick::HTTPServer.new(
20
26
  Port: @port,
27
+ BindAddress: @bind,
21
28
  Logger: @logger,
22
29
  AccessLog: [],
23
30
  DoNotReverseLookup: true
@@ -9,8 +9,9 @@ module RailsMarkup
9
9
  class Server
10
10
  attr_reader :store
11
11
 
12
- def initialize(port: 4747, mcp_only: false)
12
+ def initialize(port: 4747, bind: "127.0.0.1", mcp_only: false)
13
13
  @port = port
14
+ @bind = bind
14
15
  @mcp_only = mcp_only
15
16
  @store = Store.new
16
17
  end
@@ -44,8 +45,8 @@ module RailsMarkup
44
45
  if port_available?(@port)
45
46
  # We own the port — start HTTP + MCP with shared in-memory store
46
47
  http_thread = Thread.new do
47
- http = HttpServer.new(store: @store, port: @port)
48
- $stderr.puts "[rails-markup] HTTP server listening on port #{@port}"
48
+ http = HttpServer.new(store: @store, port: @port, bind: @bind)
49
+ $stderr.puts "[rails-markup] HTTP server listening on #{@bind}:#{@port}"
49
50
  http.start
50
51
  end
51
52
 
@@ -63,7 +64,7 @@ module RailsMarkup
63
64
  end
64
65
 
65
66
  def port_available?(port)
66
- server = TCPServer.new("0.0.0.0", port)
67
+ server = TCPServer.new(@bind, port)
67
68
  server.close
68
69
  true
69
70
  rescue Errno::EADDRINUSE
@@ -214,6 +214,17 @@ module RailsMarkup
214
214
  false
215
215
  end
216
216
  end
217
+
218
+ # Hard cap: if every session is still fresh we'd otherwise grow without
219
+ # bound. Evict the oldest sessions to make room for the incoming one.
220
+ return if @sessions.size < MAX_SESSIONS
221
+
222
+ overflow = @sessions.size - MAX_SESSIONS + 1
223
+ oldest = @sessions.values.sort_by(&:created_at).first(overflow)
224
+ oldest.each do |session|
225
+ session.annotations.each { |a| @annotations_index.delete(a.id) }
226
+ @sessions.delete(session.id)
227
+ end
217
228
  end
218
229
  end
219
230
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsMarkup
4
- VERSION = "1.2.4"
4
+ VERSION = "1.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-markup
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.4
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - InventList