ruby_native 0.17.2 → 0.18.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,94 @@
1
+ require "ruby_native/cli/credentials"
2
+ require "ruby_native/cli/mcp/platform"
3
+ require "ruby_native/cli/status"
4
+
5
+ module RubyNative
6
+ class CLI
7
+ # What every command that talks to rubynative.com about one app shares:
8
+ # finding the app, being logged in, and turning a refusal into something a
9
+ # person can act on. The MCP server calls the same `perform` methods, so a
10
+ # verb is a CLI command first and a tool second, and both say one thing.
11
+ class Command
12
+ Outcome = Struct.new(:ok, :text, :data, keyword_init: true)
13
+
14
+ def initialize(argv = [])
15
+ @argv = argv
16
+ end
17
+
18
+ private
19
+
20
+ def app_id!
21
+ flag = @argv.find { |arg| arg.start_with?("--app=") }
22
+ app_id = flag ? flag.split("=", 2).last : Mcp::Platform.app_id
23
+ return app_id unless app_id.to_s.strip.empty?
24
+
25
+ puts Mcp::Platform::NO_APP_ID
26
+ exit 1
27
+ end
28
+
29
+ def ensure_authenticated!
30
+ return if Credentials.token
31
+
32
+ puts Mcp::Platform::NOT_LOGGED_IN
33
+ exit 1
34
+ end
35
+
36
+ def finish(outcome)
37
+ puts outcome.text
38
+ exit 1 unless outcome.ok
39
+ end
40
+
41
+ PLATFORM_LABELS = { "ios" => "iOS", "android" => "Android" }.freeze
42
+
43
+ # Every store the app has started, unless --ios or --android names one.
44
+ # A server too old to say, or an app with neither started, means iOS.
45
+ def platforms!(app_id)
46
+ return [ "android" ] if @argv.include?("--android")
47
+ return [ "ios" ] if @argv.include?("--ios")
48
+
49
+ status = Status.fetch(app_id)
50
+ started = status.ok? ? Array(status.value["platforms"]) & PLATFORM_LABELS.keys : []
51
+ started.empty? ? [ "ios" ] : started
52
+ end
53
+
54
+ # One store prints as it always has; two get a heading each, and the
55
+ # command fails if either did.
56
+ def finish_each(platforms)
57
+ return finish(yield(platforms.first)) if platforms.one?
58
+
59
+ outcomes = platforms.map { |platform| [ platform, yield(platform) ] }
60
+ puts outcomes.map { |platform, outcome| "#{PLATFORM_LABELS[platform]}:\n#{outcome.text}" }.join("\n\n")
61
+ exit 1 unless outcomes.all? { |_, outcome| outcome.ok }
62
+ end
63
+
64
+ def positional
65
+ @argv.reject { |arg| arg.start_with?("--") }
66
+ end
67
+
68
+ def flag(name)
69
+ value = @argv.find { |arg| arg.start_with?("--#{name}=") }
70
+ value&.split("=", 2)&.last
71
+ end
72
+
73
+ def self.failure(result)
74
+ lines = [ result.error ]
75
+ result.field_errors.each do |field, messages|
76
+ Array(messages).each do |message|
77
+ next if result.error.to_s.downcase.include?(message.to_s.downcase)
78
+
79
+ lines << " #{field} #{message}"
80
+ end
81
+ end
82
+ if result.blocked_by.any?
83
+ lines << ""
84
+ lines.concat(Status.blocker_lines(result.blocked_by))
85
+ end
86
+ Outcome.new(ok: false, text: lines.join("\n").strip, data: { "error" => result.error, "blocked_by" => result.blocked_by, "errors" => result.field_errors })
87
+ end
88
+
89
+ def self.success(text, data)
90
+ Outcome.new(ok: true, text: text, data: data)
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,36 @@
1
+ require "ruby_native/cli/command"
2
+
3
+ module RubyNative
4
+ class CLI
5
+ # The reviewer's sign-in, staged with rubynative.com and written to App
6
+ # Store Connect when the listing is published. It stays out of
7
+ # config/ruby_native_listing.yml on purpose: that file is committed.
8
+ class DemoAccount < Command
9
+ def run
10
+ ensure_authenticated!
11
+ app_id = app_id!
12
+
13
+ unless positional.first == "set" && flag("name")
14
+ puts "Usage: ruby_native demo_account set --name=EMAIL --password=SECRET [--notes=\"...\"] [--not-required]"
15
+ exit 1
16
+ end
17
+
18
+ finish(self.class.set(app_id, name: flag("name"), password: flag("password"), notes: flag("notes"), required: !@argv.include?("--not-required")))
19
+ end
20
+
21
+ def self.set(app_id, name:, password: nil, notes: nil, required: true)
22
+ demo = { name: name, required: required }
23
+ demo[:password] = password unless password.nil?
24
+ demo[:notes] = notes unless notes.nil?
25
+
26
+ result = Mcp::Platform.patch("/api/v1/apps/#{app_id}/listing", { demo_account: demo })
27
+ return failure(result) unless result.ok?
28
+
29
+ account = result.value["demo_account"] || {}
30
+ text = "Demo account #{account["name"]}#{account["password_set"] ? "" : " (no password set)"} saved. " \
31
+ "It reaches App Store Connect when the listing is published."
32
+ success(text, result.value)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -4,6 +4,8 @@ require "uri"
4
4
  require "openssl"
5
5
  require "ruby_native/cli/check"
6
6
  require "ruby_native/cli/credentials"
7
+ require "ruby_native/cli/mcp/platform"
8
+ require "ruby_native/cli/status"
7
9
  require "ruby_native/version"
8
10
 
9
11
  module RubyNative
@@ -34,6 +36,8 @@ module RubyNative
34
36
  @if_needed = argv.include?("--if-needed")
35
37
  @skip_check = argv.include?("--skip-check")
36
38
  @platform = parse_platform(argv)
39
+ @app_flag = argv.find { |arg| arg.start_with?("--app=") }&.split("=", 2)&.last
40
+ @version = argv.find { |arg| arg.start_with?("--version=") }&.split("=", 2)&.last
37
41
  end
38
42
 
39
43
  def run
@@ -46,7 +50,10 @@ module RubyNative
46
50
  return
47
51
  end
48
52
 
49
- check_signals! unless @skip_check
53
+ unless @skip_check
54
+ check_signals!
55
+ warn_scan_without_camera(app_id)
56
+ end
50
57
 
51
58
  build = trigger_build(app_id)
52
59
  return if @if_needed
@@ -83,6 +90,17 @@ module RubyNative
83
90
  exit 1
84
91
  end
85
92
 
93
+ # A warning, not a stop: the build is fine, one button in it isn't.
94
+ def warn_scan_without_camera(app_id)
95
+ file, line = Permissions.scan_without_camera(app_id)
96
+ return unless file
97
+
98
+ puts "Heads up: #{Permissions.scan_without_camera_message(file, line)}"
99
+ puts
100
+ rescue StandardError
101
+ nil
102
+ end
103
+
86
104
  def ensure_authenticated!
87
105
  return if Credentials.token
88
106
 
@@ -145,7 +163,7 @@ module RubyNative
145
163
  end
146
164
 
147
165
  def resolve_app_id!
148
- app_id = @config.dig(:ruby_native, :app_id)
166
+ app_id = (@app_flag unless @app_flag.to_s.strip.empty?) || @config.dig(:ruby_native, :app_id)
149
167
  app_id = link_app unless app_id
150
168
  unless app_id
151
169
  puts "No app selected. Run `ruby_native deploy` again."
@@ -244,6 +262,7 @@ module RubyNative
244
262
  when Net::HTTPUnprocessableEntity
245
263
  data = parse_json(response)
246
264
  puts "Cannot build: #{data&.dig("error") || "the server rejected the request (422)."}"
265
+ print_blockers(data)
247
266
  exit 1
248
267
  when Net::HTTPNotFound
249
268
  puts "The app linked in config/ruby_native.yml was not found on your account."
@@ -419,6 +438,16 @@ module RubyNative
419
438
 
420
439
  # Server-provided warnings (a lapsed payment, say) reach customers with
421
440
  # no gem release. Deduped, since every status poll repeats the string.
441
+ # A refusal names what is in the way and who clears it, the same list the
442
+ # dashboard shows. An older server sends only the sentence.
443
+ def print_blockers(data)
444
+ blockers = data.is_a?(Hash) ? data["blocked_by"] : nil
445
+ return unless blockers.is_a?(Array) && blockers.any?
446
+
447
+ puts ""
448
+ puts Status.blocker_lines(blockers)
449
+ end
450
+
422
451
  def print_notice(data)
423
452
  notice = data["notice"]
424
453
  return unless notice.is_a?(String) && !notice.strip.empty?
@@ -483,6 +512,7 @@ module RubyNative
483
512
  def build_payload
484
513
  payload = { gem_version: RubyNative::VERSION }
485
514
  payload[:platform] = @platform unless @platform == "ios"
515
+ payload[:version] = @version if @version && !@version.empty?
486
516
  payload
487
517
  end
488
518
 
@@ -538,16 +568,7 @@ module RubyNative
538
568
  end
539
569
 
540
570
  def write_app_id_to_config(app_id)
541
- raw = File.read(CONFIG_PATH)
542
-
543
- if raw.match?(/^ruby_native:/)
544
- raw = raw.gsub(/^(ruby_native:\s*\n)/, "\\1 app_id: #{app_id}\n")
545
- else
546
- raw = raw.rstrip + "\n\nruby_native:\n app_id: #{app_id}\n"
547
- end
548
-
549
- File.write(CONFIG_PATH, raw)
550
-
571
+ Mcp::Platform.write_app_id(app_id, CONFIG_PATH)
551
572
  @config = read_config
552
573
  end
553
574
 
@@ -0,0 +1,29 @@
1
+ require "ruby_native/cli/command"
2
+
3
+ module RubyNative
4
+ class CLI
5
+ # The app icon: a 1024x1024 PNG with no transparency. It goes in the asset
6
+ # catalog, so a new one ships with the next build.
7
+ class Icon < Command
8
+ def run
9
+ ensure_authenticated!
10
+ app_id = app_id!
11
+
12
+ unless positional.first == "set" && positional[1]
13
+ puts "Usage: ruby_native icon set <path/to/icon.png>"
14
+ exit 1
15
+ end
16
+ finish(self.class.set(app_id, positional[1]))
17
+ end
18
+
19
+ def self.set(app_id, path)
20
+ return Outcome.new(ok: false, text: "No file at #{path}.", data: {}) unless File.file?(path)
21
+
22
+ result = Mcp::Platform.put_file("/api/v1/apps/#{app_id}/icon", field: "icon", file: path, content_type: "image/png")
23
+ return failure(result) unless result.ok?
24
+
25
+ success("Icon set from #{path}. #{result.value["note"]}", result.value)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,40 @@
1
+ require "ruby_native/cli/command"
2
+ require "ruby_native/cli/mcp/platform"
3
+
4
+ module RubyNative
5
+ class CLI
6
+ # Links this project to an app on rubynative.com by writing
7
+ # `ruby_native.app_id` into config/ruby_native.yml. Every command that
8
+ # talks to rubynative.com reads it from there. Deploy used to be the only
9
+ # way to link, which put the first honest status read behind a build.
10
+ class Link < Command
11
+ def run
12
+ ensure_authenticated!
13
+ finish(self.class.link(positional.first || flag("app")))
14
+ end
15
+
16
+ def self.link(app_id = nil)
17
+ result = Mcp::Platform.get("/api/v1/apps")
18
+ return failure(result) unless result.ok?
19
+
20
+ apps = Array(result.value)
21
+ return Outcome.new(ok: false, text: "No apps on this account yet. Create one at #{Mcp::Platform::HOST}/apps/new, then run this again.", data: { "apps" => [] }) if apps.empty?
22
+
23
+ app = if app_id.to_s.strip.empty?
24
+ apps.one? ? apps.first : nil
25
+ else
26
+ apps.find { |candidate| candidate["public_id"] == app_id.to_s.strip }
27
+ end
28
+
29
+ unless app
30
+ lines = [ app_id ? "No app #{app_id.inspect} on this account. Pick one:" : "Which app? Run `ruby_native link APP_ID` with one of these:" ]
31
+ apps.each { |candidate| lines << " #{candidate["public_id"]} #{candidate["name"]}#{" (#{candidate["url"]})" if candidate["url"]}" }
32
+ return Outcome.new(ok: false, text: lines.join("\n"), data: { "apps" => apps })
33
+ end
34
+
35
+ Mcp::Platform.write_app_id(app["public_id"])
36
+ success("Linked to #{app["name"]} (#{app["public_id"]}). Wrote ruby_native.app_id to #{Mcp::Platform::CONFIG_PATH}.", { "app_id" => app["public_id"], "name" => app["name"] })
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,236 @@
1
+ require "erb"
2
+ require "yaml"
3
+ require "ruby_native/cli/command"
4
+
5
+ module RubyNative
6
+ class CLI
7
+ # The App Store listing, written in config/ruby_native_listing.yml and
8
+ # staged with rubynative.com. `push` stops there. `publish` is a different
9
+ # verb on purpose: the agent prompt names `push`, so following it can never
10
+ # put words on the store, and someone shipping from a terminal types the
11
+ # same act as pressing the button.
12
+ class Listing < Command
13
+ PATH = "config/ruby_native_listing.yml".freeze
14
+ FIELDS = %w[name subtitle primary_category secondary_category keywords whats_new privacy_policy_url support_url support_email website copyright description].freeze
15
+ CONTACT_FIELDS = %w[first_name last_name email phone].freeze
16
+ LIMITS = { "name" => 30, "subtitle" => 30, "keywords" => 100, "whats_new" => 4000, "description" => 4000 }.freeze
17
+ # An `ios:` or `android:` block replaces these for one store: Apple
18
+ # refuses a listing that mentions Android, and Play's short description
19
+ # (the subtitle) has 80 characters. The rest is Apple's alone.
20
+ STORE_FIELDS = %w[name subtitle description whats_new].freeze
21
+ BLOCK_FIELDS = %w[description whats_new].freeze
22
+ PLAY_LIMITS = { "name" => 30, "subtitle" => 80, "description" => 4000, "whats_new" => 500 }.freeze
23
+ STORES = { "ios" => "the App Store", "android" => "Google Play" }.freeze
24
+
25
+ def run
26
+ ensure_authenticated!
27
+ app_id = app_id!
28
+
29
+ case positional.first
30
+ when "push" then finish(self.class.push(app_id))
31
+ when "pull" then finish(self.class.pull(app_id))
32
+ when "publish" then publish_each(app_id)
33
+ when "revert" then finish(self.class.revert(app_id, positional.drop(1), platform: @argv.include?("--android") ? "android" : "ios"))
34
+ else
35
+ puts "Usage: ruby_native listing <push|pull|publish|revert FIELD...> [--ios|--android]"
36
+ puts " push Save #{PATH} to Ruby Native. Nothing reaches a store."
37
+ puts " pull Write #{PATH} from what Ruby Native holds."
38
+ puts " publish Push, then send the listing to every store the app has started."
39
+ puts " revert Take App Store Connect's words (Google Play Console's with --android) for each field named."
40
+ puts " --ios Publish to App Store Connect only."
41
+ puts " --android Publish the title and descriptions to Google Play only."
42
+ exit 1
43
+ end
44
+ end
45
+
46
+ # One push, then each store.
47
+ def publish_each(app_id)
48
+ pushed = self.class.push(app_id)
49
+ return finish(pushed) unless pushed.ok
50
+
51
+ adopted = self.class.store_lines(pushed.data.merge("conflicts" => []))
52
+ puts adopted.join("\n") if adopted.any?
53
+ finish_each(platforms!(app_id)) { |platform| self.class.send_to_store(app_id, platform) }
54
+ end
55
+
56
+ def self.push(app_id)
57
+ listing = read_file
58
+ return listing if listing.is_a?(Outcome)
59
+
60
+ problems = over_limits(listing)
61
+ return Outcome.new(ok: false, text: ([ "#{PATH} has fields over the stores' limits:" ] + problems).join("\n"), data: { "errors" => problems }) if problems.any?
62
+
63
+ contact = listing.delete("review_contact")
64
+ body = { listing: listing }
65
+ body[:review_contact] = contact if contact&.any?
66
+ result = Mcp::Platform.patch("/api/v1/apps/#{app_id}/listing", body)
67
+ return failure(result) unless result.ok?
68
+
69
+ changed = Array(result.value["changed"])
70
+ lines = [ changed.empty? ? "Nothing changed." : "Saved #{changed.join(", ")}." ]
71
+ missing = Array(result.value["missing"])
72
+ lines << "Still missing before it can be submitted: #{missing.join(", ")}." if missing.any?
73
+ lines.concat(store_lines(result.value))
74
+ lines << "Nothing has reached Apple. `ruby_native listing publish` sends it, or publish from the dashboard."
75
+ success(lines.join("\n"), result.value)
76
+ end
77
+
78
+ def self.revert(app_id, fields, platform: "ios")
79
+ return Outcome.new(ok: false, text: "Name the fields to revert, like `ruby_native listing revert name subtitle`.", data: {}) if fields.empty?
80
+
81
+ body = { revert: fields }
82
+ body[:platform] = "android" if platform == "android"
83
+ result = Mcp::Platform.patch("/api/v1/apps/#{app_id}/listing", body)
84
+ return failure(result) unless result.ok?
85
+
86
+ store = platform == "android" ? "Google Play Console" : "App Store Connect"
87
+ success("Took #{store}'s #{fields.map { |field| field.tr("_", " ") }.join(", ")}. Run `ruby_native listing pull` so #{PATH} has it too, or the next push sends yours again.", result.value)
88
+ end
89
+
90
+ # What reading the stores found: words someone changed there that Ruby Native took, and fields both sides changed.
91
+ # A server too old to read the stores sends neither.
92
+ def self.store_lines(value)
93
+ lines = []
94
+ adopted = Array(value["adopted"])
95
+ if adopted.any?
96
+ lines << "Took #{adopted.map { |row| "#{store_name(row)}'s #{row["field"].to_s.tr("_", " ")}" }.join(", ")}, changed there since the last publish. " \
97
+ "Run `ruby_native listing pull` so #{PATH} has it."
98
+ end
99
+ Array(value["conflicts"]).each do |row|
100
+ flag = row["platform"] == "android" ? " --android" : ""
101
+ lines << "#{store_name(row)} has a different #{row["field"].to_s.tr("_", " ")}: #{row["store"].to_s.inspect}. Publishing sends yours over it; " \
102
+ "`ruby_native listing revert #{row["field"]}#{flag}` takes #{store_name(row)}'s."
103
+ end
104
+ lines
105
+ end
106
+
107
+ def self.store_name(row) = row["platform"] == "android" ? "Google Play Console" : "App Store Connect"
108
+
109
+ def self.pull(app_id)
110
+ result = Mcp::Platform.get("/api/v1/apps/#{app_id}/listing")
111
+ return failure(result) unless result.ok?
112
+
113
+ listing = (result.value["listing"] || {}).merge("review_contact" => result.value["review_contact"] || {})
114
+ File.write(PATH, render(listing))
115
+ success("Wrote #{PATH} from the listing Ruby Native holds.", result.value)
116
+ end
117
+
118
+ def self.publish(app_id, platform: "ios")
119
+ pushed = push(app_id)
120
+ return pushed unless pushed.ok
121
+
122
+ send_to_store(app_id, platform)
123
+ end
124
+
125
+ def self.send_to_store(app_id, platform)
126
+ result = Mcp::Platform.post("/api/v1/apps/#{app_id}/listing/publish", platform == "android" ? { platform: "android" } : nil)
127
+ return failure(result) unless result.ok?
128
+
129
+ success(result.value["note"].to_s, result.value)
130
+ end
131
+
132
+ # ERB first so a description can interpolate, then YAML. Only known
133
+ # keys go up; anything else in the file is left alone and reported.
134
+ def self.read_file
135
+ return Outcome.new(ok: false, text: "No #{PATH}. Run `ruby_native listing pull` to start one from the dashboard's draft, or write it by hand.", data: {}) unless File.exist?(PATH)
136
+
137
+ data = YAML.safe_load(ERB.new(File.read(PATH, encoding: "UTF-8")).result, aliases: true)
138
+ return Outcome.new(ok: false, text: "#{PATH} has to be a YAML mapping of listing fields.", data: {}) unless data.is_a?(Hash)
139
+
140
+ # An empty line from the template is a field not written yet, not a
141
+ # request to blank it.
142
+ listing = data.slice(*FIELDS).compact.transform_values { |value| value.to_s.strip }
143
+ # Every push sends both blocks, so one deleted from the file stops overriding.
144
+ STORES.each_key do |store|
145
+ block = data[store] || {}
146
+ return Outcome.new(ok: false, text: "#{store}: in #{PATH} has to be a mapping of #{STORE_FIELDS.join(", ")}.", data: {}) unless block.is_a?(Hash)
147
+
148
+ shared = block.keys.map(&:to_s) - STORE_FIELDS
149
+ return Outcome.new(ok: false, text: "#{store}: in #{PATH} can only set #{STORE_FIELDS.join(", ")}; #{shared.join(", ")} #{shared.one? ? "is" : "are"} shared by every store.", data: {}) if shared.any?
150
+
151
+ listing[store] = block.transform_keys(&:to_s).compact.transform_values { |value| value.to_s.strip }.reject { |_, value| value.empty? }
152
+ end
153
+ contact = data["review_contact"]
154
+ listing["review_contact"] = contact.slice(*CONTACT_FIELDS).compact.transform_values { |value| value.to_s.strip } if contact.is_a?(Hash)
155
+ listing
156
+ rescue Psych::SyntaxError => error
157
+ Outcome.new(ok: false, text: "#{PATH} is not valid YAML: #{error.message}", data: {})
158
+ end
159
+
160
+ # Checked on what each store will get: a long shared subtitle is fine
161
+ # when `ios:` has its own.
162
+ def self.over_limits(listing)
163
+ { "ios" => LIMITS, "android" => PLAY_LIMITS }.flat_map do |store, limits|
164
+ own = listing[store] || {}
165
+ limits.filter_map do |field, limit|
166
+ # Shared release notes are cut to Play's 500 on the way out; only Play's own must fit.
167
+ next if store == "android" && field == "whats_new" && !own[field]
168
+
169
+ value = own[field] || listing[field]
170
+ length = value.to_s.length
171
+ next unless length > limit
172
+
173
+ " #{"#{store}." if own[field]}#{field} is #{length} characters, the limit on #{STORES[store]} is #{limit}"
174
+ end
175
+ end.uniq
176
+ end
177
+
178
+ # Every field is written, empty ones with their limit, so a pull on an
179
+ # app with no listing yet is a template rather than a file of comments
180
+ # that push then refuses.
181
+ def self.render(listing)
182
+ lines = [ "# App Store and Google Play listing. `ruby_native listing push` saves it to Ruby Native;", "# publishing to a store is a separate step.", "" ]
183
+ FIELDS.each do |field|
184
+ lines.concat(render_contact(listing["review_contact"] || {})) if field == "description"
185
+ value = listing[field]
186
+ if value.nil? || value.to_s.empty?
187
+ lines << "#{field}:#{" # up to #{LIMITS[field]} characters" if LIMITS[field]}"
188
+ next
189
+ end
190
+
191
+ if BLOCK_FIELDS.include?(field)
192
+ lines << "#{field}: |-"
193
+ value.to_s.each_line { |line| lines << " #{line.rstrip}" }
194
+ else
195
+ lines << "#{field}: #{scalar(value)}"
196
+ end
197
+ end
198
+ lines << ""
199
+ lines << "# ios: and android: replace name, subtitle, description, or whats_new for one store."
200
+ STORES.each_key do |store|
201
+ block = (listing[store] || {}).slice(*STORE_FIELDS).reject { |_, value| value.to_s.empty? }
202
+ next if block.empty?
203
+
204
+ lines << "#{store}:"
205
+ block.each do |field, value|
206
+ if BLOCK_FIELDS.include?(field)
207
+ lines << " #{field}: |-"
208
+ value.to_s.each_line { |line| lines << " #{line.rstrip}" }
209
+ else
210
+ lines << " #{field}: #{scalar(value)}"
211
+ end
212
+ end
213
+ end
214
+ lines.join("\n") + "\n"
215
+ end
216
+
217
+ def self.render_contact(contact)
218
+ lines = [ "review_contact: # who App Review contacts; never shown on the store" ]
219
+ CONTACT_FIELDS.each do |field|
220
+ value = contact[field]
221
+ lines << if value.nil? || value.to_s.empty?
222
+ " #{field}:#{" # \"+1 503 555 0100\", quoted" if field == "phone"}"
223
+ else
224
+ " #{field}: #{scalar(value)}"
225
+ end
226
+ end
227
+ lines
228
+ end
229
+
230
+ # YAML reads an unquoted +15035550100 as a number and drops the +.
231
+ def self.scalar(value)
232
+ value.to_s.include?(":") || value.to_s.start_with?("#", "+") ? value.to_s.inspect : value
233
+ end
234
+ end
235
+ end
236
+ end