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.
- checksums.yaml +4 -4
- data/exe/every +6 -0
- data/exe/rbe +6 -0
- data/lib/everywhere/boot.rb +138 -0
- data/lib/everywhere/cli.rb +44 -0
- data/lib/everywhere/commands/build.rb +272 -0
- data/lib/everywhere/commands/dev.rb +66 -0
- data/lib/everywhere/commands/doctor.rb +54 -0
- data/lib/everywhere/commands/install.rb +264 -0
- data/lib/everywhere/commands/platform/auth_status.rb +42 -0
- data/lib/everywhere/commands/platform/build.rb +181 -0
- data/lib/everywhere/commands/platform/login.rb +81 -0
- data/lib/everywhere/commands/platform/logout.rb +34 -0
- data/lib/everywhere/commands/platform/runner.rb +224 -0
- data/lib/everywhere/commands/release.rb +143 -0
- data/lib/everywhere/config.rb +188 -0
- data/lib/everywhere/database.rb +89 -0
- data/lib/everywhere/framework.rb +197 -0
- data/lib/everywhere/ignore.rb +85 -0
- data/lib/everywhere/javascript/bridge.js +174 -0
- data/lib/everywhere/platform/client.rb +125 -0
- data/lib/everywhere/platform/credentials.rb +71 -0
- data/lib/everywhere/platform/snapshot.rb +48 -0
- data/lib/everywhere/png.rb +110 -0
- data/lib/everywhere/receipt.rb +185 -0
- data/lib/everywhere/shellout.rb +46 -0
- data/lib/everywhere/ui.rb +37 -0
- data/lib/everywhere/version.rb +11 -0
- data/lib/everywhere.rb +50 -0
- data/lib/ruby_everywhere.rb +2 -3
- data/support/github/build.yml +85 -0
- data/support/macos/entitlements.plist +23 -0
- data/support/macos/notarize.sh +123 -0
- metadata +67 -7
- data/README.md +0 -11
|
@@ -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
|