ruby_everywhere 0.0.1 → 0.1.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,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require "fileutils"
5
+ require_relative "../shellout"
6
+
7
+ module Everywhere
8
+ module Commands
9
+ # Prepare an existing app for packaged desktop life. Idempotent: skips
10
+ # anything already in place.
11
+ class Install < Dry::CLI::Command
12
+ desc "Prepare this app for RubyEverywhere (boot stub, config/everywhere.yml, config tweaks)"
13
+
14
+ option :root, default: ".", desc: "App root directory"
15
+ option :path, default: nil, desc: "Use a local checkout of ruby_everywhere in the Gemfile (path:)"
16
+
17
+ def call(root: ".", path: nil, **)
18
+ @framework = Framework.detect(root)
19
+ @root = @framework.root
20
+ UI.step("#{UI.bold(@framework.name)} app at #{@root}")
21
+
22
+ # Common to every framework.
23
+ add_gem(path)
24
+ write_config
25
+ write_boot_stub
26
+
27
+ # Framework-specific config surgery.
28
+ send("install_#{@framework.name}")
29
+
30
+ UI.success("installed — run `every dev` to open the app, `every build` to package it")
31
+ end
32
+
33
+ private
34
+
35
+ # ---- framework-specific recipes -----------------------------------------
36
+
37
+ def install_rails
38
+ guard_bootsnap
39
+ disable_force_ssl
40
+ point_database_at_app_data
41
+ disable_puma_keep_alives
42
+ write_initializer
43
+ install_bridge
44
+ end
45
+
46
+ def install_sinatra
47
+ vendor_bridge_to_public
48
+ guide_rack_manual_steps(
49
+ db: "point your production SQLite path at ENV.fetch(\"NATIVE_STORAGE_DIR\", \"storage\")",
50
+ boot: "call `Everywhere::Database.prepare!(__dir__)` from config.ru after loading your app",
51
+ script: "load the bridge with <script type=\"module\" src=\"/bridge.js\"></script>"
52
+ )
53
+ end
54
+
55
+ def install_hanami
56
+ vendor_bridge_to_public
57
+ guide_rack_manual_steps(
58
+ db: "override DATABASE_URL from ENV[\"NATIVE_STORAGE_DIR\"] in config.ru when NATIVE_PACKAGED",
59
+ boot: "migrate before Hanami finalizes: Hanami.prepare → run ROM migrations → require \"hanami/boot\"",
60
+ script: "serve public/ via Rack::Static and load /bridge.js as an EXTERNAL module (Hanami's CSP forbids inline scripts)"
61
+ )
62
+ end
63
+
64
+ # Rack frameworks vary too much to auto-edit their boot files safely, so we
65
+ # vendor the bridge and print the remaining wiring rather than guess.
66
+ def vendor_bridge_to_public
67
+ source = File.expand_path("../javascript/bridge.js", __dir__)
68
+ target_dir = app_file("public")
69
+ FileUtils.mkdir_p(target_dir)
70
+ target = File.join(target_dir, "bridge.js")
71
+ fresh = !File.exist?(target) || File.read(target) != File.read(source)
72
+ FileUtils.cp(source, target) if fresh
73
+ change("vendor @rubyeverywhere/bridge to public/bridge.js", fresh)
74
+ end
75
+
76
+ def guide_rack_manual_steps(db:, boot:, script:)
77
+ UI.step("#{UI.dim("next, wire these by hand (see example-#{@framework.name}/):")}")
78
+ UI.step(" #{UI.dim("• database:")} #{db}")
79
+ UI.step(" #{UI.dim("• boot:")} #{boot}")
80
+ UI.step(" #{UI.dim("• bridge:")} #{script}")
81
+ end
82
+
83
+ def app_file(*parts) = File.join(@root, *parts)
84
+
85
+ def change(label, made)
86
+ made ? UI.ok(label) : UI.step("#{label} #{UI.dim("(already done, skipped)")}")
87
+ end
88
+
89
+ def add_gem(path)
90
+ gemfile = app_file("Gemfile")
91
+ contents = File.read(gemfile)
92
+ return change("add ruby_everywhere to Gemfile", false) if contents.match?(/gem ["']ruby_everywhere["']/)
93
+
94
+ line = path ? "gem \"ruby_everywhere\", path: #{path.inspect}" : "gem \"ruby_everywhere\""
95
+ File.write(gemfile, "#{contents.chomp}\n\n# Desktop apps from this #{@framework.name.capitalize} app — https://rubyeverywhere.com\n#{line}\n")
96
+ Shellout.run!({}, "bundle", "install", "--quiet", chdir: @root)
97
+ change("add ruby_everywhere to Gemfile", true)
98
+ end
99
+
100
+ def write_config
101
+ config = app_file("config", "everywhere.yml")
102
+ return change("create config/everywhere.yml", false) if File.exist?(config)
103
+
104
+ app_name = File.basename(@root).split(/[-_]/).map(&:capitalize).join(" ")
105
+ File.write(config, <<~YAML)
106
+ # RubyEverywhere app configuration — https://rubyeverywhere.com
107
+ app:
108
+ name: #{app_name}
109
+ # Reverse-DNS app identifier: names the app-data directory on every
110
+ # platform (and the macOS bundle id). Set once, never change it.
111
+ bundle_id: com.example.#{app_name.downcase.gsub(/[^a-z0-9]+/, "-")}
112
+ entry_path: / # initial url on app launch
113
+ # icon: icon.png # PNG used to generate app icons (defaults to icon.png at the app root if present)
114
+ # splash: splash.html # custom boot splash (HTML); window.__EVERYWHERE_CONFIG__ carries name/colors
115
+
116
+ appearance:
117
+ # Colors take a hex string, or split by system theme:
118
+ # tint_color:
119
+ # light: "#AA1111"
120
+ # dark: "#FF5555"
121
+ tint_color: "#CC342D"
122
+ background_color:
123
+ light: "#FFFFFF"
124
+ dark: "#1C1B1A"
125
+ YAML
126
+ change("create config/everywhere.yml", true)
127
+ end
128
+
129
+ def write_boot_stub
130
+ stub = app_file("native_boot.rb")
131
+ return change("create native_boot.rb", false) if File.exist?(stub)
132
+
133
+ File.write(stub, @framework.boot_stub)
134
+ change("create native_boot.rb", true)
135
+ end
136
+
137
+ def guard_bootsnap
138
+ boot = app_file("config", "boot.rb")
139
+ contents = File.read(boot)
140
+ return change("guard bootsnap for packaged runs", false) if contents.include?("NATIVE_PACKAGED")
141
+
142
+ made = contents.sub!(/^require ["']bootsnap\/setup["'].*$/) do
143
+ "# Bootsnap writes its cache to tmp/, which is read-only inside the packaged filesystem.\n" \
144
+ "require \"bootsnap/setup\" unless ENV[\"NATIVE_PACKAGED\"]"
145
+ end
146
+ File.write(boot, contents) if made
147
+ change("guard bootsnap for packaged runs", !!made)
148
+ end
149
+
150
+ def disable_force_ssl
151
+ production = app_file("config", "environments", "production.rb")
152
+ contents = File.read(production)
153
+ made = false
154
+ made |= !!contents.gsub!(/^(\s*config\.assume_ssl\s*=\s*)true/) { "#{Regexp.last_match(1)}false # desktop: webview talks plain HTTP to 127.0.0.1" }
155
+ made |= !!contents.gsub!(/^(\s*config\.force_ssl\s*=\s*)true/) { "#{Regexp.last_match(1)}false" }
156
+ File.write(production, contents) if made
157
+ change("disable force_ssl for localhost", made)
158
+ end
159
+
160
+ # Vendor @rubyeverywhere/bridge (one JS API across browser/desktop/mobile)
161
+ # and pin it for importmap. Re-running `every install` after a gem update
162
+ # refreshes the vendored copy.
163
+ def install_bridge
164
+ source = File.expand_path("../javascript/bridge.js", __dir__)
165
+ target_dir = app_file("vendor", "javascript")
166
+ target = File.join(target_dir, "@rubyeverywhere--bridge.js")
167
+ FileUtils.mkdir_p(target_dir)
168
+ # Stamp a machine-readable version marker on the vendored copy so the
169
+ # build receipt (Receipt#bridge_version) can record which bridge shipped.
170
+ # Comparing against the fully-stamped content keeps re-runs idempotent.
171
+ desired = "// @rubyeverywhere/bridge version: #{Everywhere::BRIDGE_VERSION} (vendored by `every install`)\n#{File.read(source)}"
172
+ fresh = !File.exist?(target) || File.read(target) != desired
173
+ File.write(target, desired) if fresh
174
+
175
+ importmap = app_file("config", "importmap.rb")
176
+ if File.exist?(importmap)
177
+ contents = File.read(importmap)
178
+ unless contents.include?("@rubyeverywhere/bridge")
179
+ File.write(importmap, contents.chomp + "\npin \"@rubyeverywhere/bridge\", to: \"@rubyeverywhere--bridge.js\" # vendored by `every install`\n")
180
+ fresh = true
181
+ end
182
+ end
183
+ wire_application_js(fresh)
184
+ end
185
+
186
+ def wire_application_js(fresh)
187
+ app_js = app_file("app", "javascript", "application.js")
188
+ if File.exist?(app_js)
189
+ contents = File.read(app_js)
190
+ unless contents.include?("@rubyeverywhere/bridge")
191
+ File.write(app_js, contents.chomp + <<~JS)
192
+
193
+
194
+ import Everywhere from "@rubyeverywhere/bridge"
195
+ // Available everywhere: controllers, inline scripts, the console.
196
+ window.Everywhere = Everywhere
197
+ JS
198
+ fresh = true
199
+ end
200
+ end
201
+ change("install @rubyeverywhere/bridge", fresh)
202
+ end
203
+
204
+ def write_initializer
205
+ init = app_file("config", "initializers", "everywhere.rb")
206
+ return change("create everywhere initializer", false) if File.exist?(init)
207
+
208
+ File.write(init, <<~RUBY)
209
+ # RubyEverywhere desktop-runtime configuration (generated by `every install`).
210
+ if ENV["NATIVE_PACKAGED"]
211
+ # Large static files truncate when streamed from the packaged read-only
212
+ # filesystem; Everywhere.boot! copies public/ to app-data — serve from there.
213
+ Rails.application.config.paths["public"] = ENV["NATIVE_PUBLIC_DIR"] if ENV["NATIVE_PUBLIC_DIR"]
214
+
215
+ # Keep desktop users signed in across launches (session cookies are
216
+ # browser-session-scoped by default).
217
+ Rails.application.config.session_store :cookie_store,
218
+ key: "_\#{Rails.application.class.module_parent_name.underscore}_session",
219
+ expire_after: 1.year
220
+ end
221
+ RUBY
222
+ change("create everywhere initializer", true)
223
+ end
224
+
225
+ # WebKit (the webview) reuses keep-alive connections that Puma has
226
+ # already closed, intermittently killing asset loads with "network
227
+ # connection was lost" (-1005). Single local user: keep-alives off.
228
+ def disable_puma_keep_alives
229
+ puma = app_file("config", "puma.rb")
230
+ contents = File.read(puma)
231
+ return change("disable puma keep-alives when packaged", false) if contents.include?("enable_keep_alives")
232
+
233
+ File.write(puma, contents.chomp + <<~RUBY)
234
+
235
+
236
+ # RubyEverywhere: WebKit drops reused keep-alive connections from a local
237
+ # Puma ("network connection was lost"); disable them in the packaged app.
238
+ enable_keep_alives false if ENV["NATIVE_PACKAGED"] && respond_to?(:enable_keep_alives)
239
+ RUBY
240
+ change("disable puma keep-alives when packaged", true)
241
+ end
242
+
243
+ def point_database_at_app_data
244
+ db = app_file("config", "database.yml")
245
+ contents = File.read(db)
246
+ return change("point production SQLite at app-data dir", false) if contents.include?("NATIVE_STORAGE_DIR")
247
+
248
+ # Rails 8.x generates commented-out production database paths.
249
+ made = !!contents.gsub!(%r{^(\s*)#\s*database: path/to/persistent/storage/(\S+\.sqlite3)$}) do
250
+ "#{Regexp.last_match(1)}database: <%= ENV.fetch(\"NATIVE_STORAGE_DIR\", \"storage\") %>/#{Regexp.last_match(2)}"
251
+ end
252
+ # Older/edited apps: uncommented storage/ paths in the production section.
253
+ File.write(db, contents) if made
254
+
255
+ unless made || contents.include?("NATIVE_STORAGE_DIR")
256
+ UI.warn "couldn't rewrite config/database.yml automatically — point production " \
257
+ "sqlite paths at <%= ENV.fetch(\"NATIVE_STORAGE_DIR\", \"storage\") %>/..."
258
+ return
259
+ end
260
+ change("point production SQLite at app-data dir", made)
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require_relative "../../ui"
5
+ require_relative "../../platform/client"
6
+ require_relative "../../platform/credentials"
7
+
8
+ module Everywhere
9
+ module Commands
10
+ # `every platform auth status` — show whether this machine is logged in, and
11
+ # validate the saved token against the Platform.
12
+ class PlatformAuthStatus < Dry::CLI::Command
13
+ desc "Show Platform authentication status"
14
+
15
+ option :url, default: nil, desc: "Platform base URL"
16
+
17
+ def call(url: nil, **)
18
+ base = Everywhere::Platform::Client.base_url(url)
19
+ creds = Everywhere::Platform::Credentials.load.for(base)
20
+ return UI.warn("not logged in to #{base} — run `every platform login`") unless creds
21
+
22
+ res = Everywhere::Platform::Client.new(base, token: creds["token"]).get("/cli/token")
23
+ if res.ok?
24
+ user = res.body["user"] || {}
25
+ org = res.body["organization"] || {}
26
+ tok = res.body["token"] || {}
27
+ UI.ok("logged in to #{base}")
28
+ UI.step("user: #{user["email"]}")
29
+ UI.step("org: #{org["name"]} #{UI.dim("(#{org["id"]})")}")
30
+ last = tok["last_used_at"] ? " · last used #{tok["last_used_at"]}" : ""
31
+ UI.step("token: #{tok["hint"]}#{UI.dim(last)}")
32
+ elsif res.unauthorized?
33
+ UI.bad("saved token is invalid or revoked — run `every platform login`")
34
+ else
35
+ UI.bad("couldn't check status — HTTP #{res.status}")
36
+ end
37
+ rescue Everywhere::Error => e
38
+ UI.bad(e.message)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+ require_relative "../../ui"
7
+ require_relative "../../config"
8
+ require_relative "../../framework"
9
+ require_relative "../../receipt"
10
+ require_relative "../../platform/client"
11
+ require_relative "../../platform/credentials"
12
+ require_relative "../../platform/snapshot"
13
+
14
+ module Everywhere
15
+ module Commands
16
+ # `every platform build` — hosted build (Model B): snapshot the source, upload
17
+ # it straight to storage, submit a build for the configured targets, then tail
18
+ # logs and pull the finished artifacts. No local toolchain needed.
19
+ class PlatformBuild < Dry::CLI::Command
20
+ desc "Build this app on the RubyEverywhere Platform"
21
+
22
+ option :root, default: ".", desc: "App root directory"
23
+ option :url, default: nil, desc: "Platform base URL"
24
+ option :target, default: nil, desc: "Targets (comma-separated os-arch); overrides everywhere.yml"
25
+ option :channel, default: "direct", desc: "Distribution channel"
26
+ option :ruby, default: nil, desc: "Ruby version (default: build.ruby or 4.0.6)"
27
+ option :output, default: nil, desc: "Where to save artifacts (default: <root>/dist)"
28
+ option :wait, type: :boolean, default: true, desc: "Wait for the build and download results"
29
+ option :download, type: :boolean, default: true, desc: "Download finished artifacts"
30
+
31
+ def call(root: ".", url: nil, target: nil, channel: "direct", ruby: nil, output: nil,
32
+ wait: true, download: true, **)
33
+ root_path = File.expand_path(root)
34
+ config = Everywhere::Config.load(root_path)
35
+ base = Everywhere::Platform::Client.base_url(url)
36
+ creds = Everywhere::Platform::Credentials.load.for(base)
37
+ UI.die!("not logged in to #{base} — run `every platform login`") unless creds
38
+
39
+ client = Everywhere::Platform::Client.new(base, token: creds["token"])
40
+ framework = config.remote? ? nil : Everywhere::Framework.detect(root_path)
41
+ targets = resolve_targets(target, config)
42
+ ruby ||= config.build_ruby || "4.0.6"
43
+
44
+ Dir.mktmpdir("every-snapshot") do |tmp|
45
+ signed_id = snapshot_and_upload(client, root_path, tmp)
46
+ manifest = Everywhere::Receipt.new(
47
+ root: root_path, config: config, framework: framework, ruby: ruby,
48
+ target: targets.first, channel: channel
49
+ ).manifest
50
+
51
+ build = submit(client, targets, channel, signed_id, manifest)
52
+ UI.success("submitted #{UI.bold(build["id"])} for #{targets.join(", ")}")
53
+
54
+ return unless wait
55
+
56
+ final = poll_and_tail(client, build["id"])
57
+ report(client, final, output || File.join(root_path, "dist"), download)
58
+ end
59
+ rescue Everywhere::Error => e
60
+ UI.die!(e.message)
61
+ end
62
+
63
+ private
64
+
65
+ def snapshot_and_upload(client, root_path, tmp)
66
+ path = File.join(tmp, "snapshot.tar.gz")
67
+ UI.step("snapshotting source #{UI.dim("(honoring .everywhereignore)")}")
68
+ count = Everywhere::Platform::Snapshot.create(root_path, path)
69
+ size = File.size(path)
70
+ UI.step("#{count} files, #{mb(size)}MB")
71
+
72
+ reservation = client.post("/cli/direct_uploads",
73
+ filename: "snapshot.tar.gz", byte_size: size,
74
+ checksum: Everywhere::Platform::Snapshot.md5_base64(path), content_type: "application/gzip")
75
+ UI.die!("couldn't reserve upload (HTTP #{reservation.status})") unless reservation.ok?
76
+
77
+ upload = reservation.body["direct_upload"] || {}
78
+ UI.step("uploading snapshot")
79
+ res = client.put_file(upload["url"], path, headers: upload["headers"] || {})
80
+ UI.die!("snapshot upload failed (HTTP #{res.code})") unless res.code.to_i.between?(200, 299)
81
+
82
+ reservation.body["signed_id"]
83
+ end
84
+
85
+ def submit(client, targets, channel, signed_id, manifest)
86
+ res = client.post("/cli/builds",
87
+ targets: targets.join(","), channel: channel, snapshot_signed_id: signed_id, manifest: manifest)
88
+ return res.body if res.ok?
89
+
90
+ if res.status == 422 && res.body["error"] == "missing_credentials"
91
+ UI.bad("can't build yet — missing signing credentials:")
92
+ Array(res.body["targets"]).each do |t|
93
+ tgt = t["target"] || {}
94
+ UI.step("#{tgt["os"]}-#{tgt["arch"]}/#{tgt["distribution_channel"]}: add #{Array(t["missing"]).join(", ")}")
95
+ end
96
+ UI.step("set them up → #{UI.cyan(res.body["manage_url"])}")
97
+ exit 1
98
+ end
99
+ UI.die!("build submission failed (HTTP #{res.status}) #{res.body["error"]}")
100
+ end
101
+
102
+ def poll_and_tail(client, build_id)
103
+ printed = Hash.new("")
104
+ last = {}
105
+ loop do
106
+ res = client.get("/cli/builds/#{build_id}/logs")
107
+ if res.ok?
108
+ artifacts = res.body["artifacts"] || []
109
+ artifacts.each do |artifact|
110
+ tail_log(artifact, printed)
111
+ announce_status(artifact, last)
112
+ end
113
+ break if artifacts.any? && artifacts.all? { |a| terminal?(a["status"]) }
114
+ end
115
+ sleep(2)
116
+ end
117
+ client.get("/cli/builds/#{build_id}").body
118
+ end
119
+
120
+ def tail_log(artifact, printed)
121
+ full = artifact["log"].to_s
122
+ prev = printed[artifact["id"]]
123
+ return unless full.length > prev.length
124
+
125
+ print(full[prev.length..])
126
+ printed[artifact["id"]] = full
127
+ end
128
+
129
+ def announce_status(artifact, last)
130
+ return if last[artifact["id"]] == artifact["status"]
131
+
132
+ UI.step("#{label(artifact["target"])} → #{artifact["status"]}")
133
+ last[artifact["id"]] = artifact["status"]
134
+ end
135
+
136
+ def report(client, build, out_dir, download)
137
+ artifacts = build["artifacts"] || []
138
+ artifacts.each do |artifact|
139
+ if artifact["status"] == "succeeded"
140
+ report_success(client, artifact, out_dir, download)
141
+ else
142
+ UI.bad("#{label(artifact["target"])} — #{artifact["error"] || "failed"}")
143
+ end
144
+ end
145
+
146
+ if artifacts.all? { |a| a["status"] == "succeeded" }
147
+ UI.success("build #{build["id"]} complete")
148
+ else
149
+ UI.die!("build #{build["id"]} had failures")
150
+ end
151
+ end
152
+
153
+ def report_success(client, artifact, out_dir, download)
154
+ url = artifact["download_url"]
155
+ if download && url
156
+ FileUtils.mkdir_p(out_dir)
157
+ dest = File.join(out_dir, File.basename(artifact.dig("release", "artifact", "filename") || "#{label(artifact["target"])}.zip"))
158
+ client.download(url, dest)
159
+ UI.ok("#{label(artifact["target"])} ✓ #{dest}")
160
+ else
161
+ UI.ok("#{label(artifact["target"])} ✓ #{url}")
162
+ end
163
+ end
164
+
165
+ # everywhere.yml build.targets, or --target, or the macOS default.
166
+ def resolve_targets(override, config)
167
+ list = override ? override.split(",") : config.targets
168
+ list = ["macos-arm64"] if list.nil? || list.empty?
169
+ list.map(&:strip).reject(&:empty?)
170
+ end
171
+
172
+ def label(target)
173
+ target ? "#{target["os"]}-#{target["arch"]}" : "target"
174
+ end
175
+
176
+ def terminal?(status) = %w[succeeded failed].include?(status)
177
+
178
+ def mb(bytes) = (bytes / 1024.0 / 1024.0).round(1)
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require "socket"
5
+ require_relative "../../ui"
6
+ require_relative "../../platform/client"
7
+ require_relative "../../platform/credentials"
8
+
9
+ module Everywhere
10
+ module Commands
11
+ # `every platform login` — OAuth-style device flow: start a request, send the
12
+ # user to the browser to confirm the code and pick an org, then poll for the
13
+ # org-scoped token and save it locally.
14
+ class PlatformLogin < Dry::CLI::Command
15
+ desc "Log in to the RubyEverywhere Platform (device authorization)"
16
+
17
+ option :url, default: nil, desc: "Platform base URL (default: $EVERYWHERE_PLATFORM_URL or production)"
18
+
19
+ def call(url: nil, **)
20
+ base = Everywhere::Platform::Client.base_url(url)
21
+ client = Everywhere::Platform::Client.new(base)
22
+
23
+ start = client.post("/cli/device_authorizations", client_name: client_name)
24
+ UI.die!("couldn't start login — HTTP #{start.status}") unless start.ok?
25
+
26
+ verify_url = start.body["verification_uri_complete"] || start.body["verification_uri"]
27
+ UI.step("confirmation code: #{UI.bold(start.body["user_code"])}")
28
+ UI.step("opening #{UI.cyan(verify_url)}")
29
+ UI.step(UI.dim("browser didn't open? paste that URL yourself")) unless Everywhere::Platform.open_url(verify_url)
30
+ UI.step(UI.dim("waiting for you to approve in the browser…"))
31
+
32
+ result = poll(client, start.body["device_code"],
33
+ [start.body["interval"].to_i, 1].max, start.body["expires_in"].to_i)
34
+
35
+ org = result["organization"] || {}
36
+ Everywhere::Platform::Credentials.load.set(base,
37
+ "token" => result["access_token"], "organization" => org)
38
+ UI.success("logged in to #{org["name"]} #{UI.dim("(#{org["id"]})")}")
39
+ rescue Everywhere::Error => e
40
+ UI.die!(e.message)
41
+ end
42
+
43
+ private
44
+
45
+ def poll(client, device_code, interval, expires_in)
46
+ deadline = monotonic + (expires_in.positive? ? expires_in : 600)
47
+ loop do
48
+ sleep(interval)
49
+ UI.die!("login timed out — run `every platform login` again") if monotonic > deadline
50
+
51
+ res = client.post("/cli/device_authorizations/token", device_code: device_code)
52
+ UI.die!("login failed — #{res.body["error"] || "invalid request"}") if res.status == 400
53
+ next unless res.ok? # transient 5xx/network: keep polling until the deadline
54
+
55
+ case res.body["status"]
56
+ when "approved" then return res.body
57
+ when "pending" then interval = [res.body["interval"].to_i, interval].max
58
+ when "denied" then UI.die!("request denied in the browser")
59
+ when "expired" then UI.die!("the code expired — run `every platform login` again")
60
+ end
61
+ end
62
+ end
63
+
64
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
65
+
66
+ # Shown on the approval page + used as the token's name, so a user can tell
67
+ # their machines apart when reviewing/revoking tokens.
68
+ def client_name
69
+ host = Socket.gethostname.split(".").first
70
+ os = case RUBY_PLATFORM
71
+ when /darwin/ then "macOS"
72
+ when /mingw|mswin/ then "Windows"
73
+ else "Linux"
74
+ end
75
+ "#{host} (#{os})"
76
+ rescue StandardError
77
+ "RubyEverywhere CLI"
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require_relative "../../ui"
5
+ require_relative "../../platform/client"
6
+ require_relative "../../platform/credentials"
7
+
8
+ module Everywhere
9
+ module Commands
10
+ # `every platform logout` — revoke the token on the Platform, then drop the
11
+ # local credential.
12
+ class PlatformLogout < Dry::CLI::Command
13
+ desc "Log out of the RubyEverywhere Platform"
14
+
15
+ option :url, default: nil, desc: "Platform base URL"
16
+
17
+ def call(url: nil, **)
18
+ base = Everywhere::Platform::Client.base_url(url)
19
+ store = Everywhere::Platform::Credentials.load
20
+ creds = store.for(base)
21
+ return UI.step("not logged in to #{base}") unless creds
22
+
23
+ begin
24
+ Everywhere::Platform::Client.new(base, token: creds["token"]).delete("/cli/token")
25
+ rescue Everywhere::Error => e
26
+ UI.warn("couldn't reach the Platform to revoke (#{e.message}) — removing local credential anyway")
27
+ end
28
+
29
+ store.delete(base)
30
+ UI.success("logged out of #{base}")
31
+ end
32
+ end
33
+ end
34
+ end