ruby_everywhere 0.6.0 → 0.8.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 +2 -2
- data/exe/rbe +2 -2
- data/lib/everywhere/agents_guide.rb +3 -1
- data/lib/everywhere/blake2b.rb +17 -1
- data/lib/everywhere/boot.rb +30 -6
- data/lib/everywhere/builders/android.rb +152 -64
- data/lib/everywhere/builders/base.rb +53 -0
- data/lib/everywhere/builders/desktop.rb +32 -40
- data/lib/everywhere/builders/ios.rb +268 -36
- data/lib/everywhere/builders/native_sources.rb +38 -0
- data/lib/everywhere/child_processes.rb +4 -4
- data/lib/everywhere/child_supervision.rb +172 -0
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/clock.rb +12 -0
- data/lib/everywhere/commands/build.rb +132 -66
- data/lib/everywhere/commands/clean.rb +8 -3
- data/lib/everywhere/commands/dev.rb +91 -242
- data/lib/everywhere/commands/doctor.rb +138 -21
- data/lib/everywhere/commands/install.rb +49 -8
- data/lib/everywhere/commands/platform/auth_status.rb +1 -0
- data/lib/everywhere/commands/platform/build.rb +141 -33
- data/lib/everywhere/commands/platform/login.rb +20 -6
- data/lib/everywhere/commands/platform/logout.rb +1 -0
- data/lib/everywhere/commands/platform/runner.rb +170 -25
- data/lib/everywhere/commands/preview.rb +286 -0
- data/lib/everywhere/commands/publish.rb +22 -2
- data/lib/everywhere/commands/release.rb +155 -35
- data/lib/everywhere/commands/shell_dir.rb +4 -2
- data/lib/everywhere/commands/updates_keygen.rb +25 -1
- data/lib/everywhere/config/app.rb +126 -0
- data/lib/everywhere/config/auth.rb +108 -0
- data/lib/everywhere/config/data.rb +50 -0
- data/lib/everywhere/config/deep_linking.rb +107 -0
- data/lib/everywhere/config/desktop_ui.rb +153 -0
- data/lib/everywhere/config/mobile.rb +211 -0
- data/lib/everywhere/config/native_desktop.rb +168 -0
- data/lib/everywhere/config/native_mobile.rb +337 -0
- data/lib/everywhere/config/shell.rb +57 -0
- data/lib/everywhere/config/updates.rb +63 -0
- data/lib/everywhere/config.rb +59 -1367
- data/lib/everywhere/console.rb +2 -1
- data/lib/everywhere/desktop_dev_app.rb +138 -0
- data/lib/everywhere/dock/state.rb +3 -1
- data/lib/everywhere/engine.rb +24 -0
- data/lib/everywhere/entrypoint.rb +24 -0
- data/lib/everywhere/error.rb +8 -0
- data/lib/everywhere/framework.rb +23 -6
- data/lib/everywhere/host.rb +11 -0
- data/lib/everywhere/ignore.rb +13 -5
- data/lib/everywhere/jump.rb +121 -0
- data/lib/everywhere/line_pump.rb +3 -1
- data/lib/everywhere/minisign.rb +1 -0
- data/lib/everywhere/mobile_config_endpoint.rb +47 -0
- data/lib/everywhere/mobile_configs_controller.rb +46 -0
- data/lib/everywhere/native_platform.rb +44 -0
- data/lib/everywhere/paths.rb +36 -13
- data/lib/everywhere/platform/client.rb +46 -7
- data/lib/everywhere/platform/credentials.rb +18 -8
- data/lib/everywhere/platform/snapshot.rb +12 -0
- data/lib/everywhere/plist.rb +17 -0
- data/lib/everywhere/png.rb +1 -0
- data/lib/everywhere/raw_tty.rb +51 -0
- data/lib/everywhere/receipt.rb +55 -10
- data/lib/everywhere/s3.rb +1 -0
- data/lib/everywhere/shell_pages.rb +109 -0
- data/lib/everywhere/shellout.rb +19 -0
- data/lib/everywhere/simulator.rb +12 -1
- data/lib/everywhere/tab_filter.rb +36 -0
- data/lib/everywhere/task_pool.rb +3 -4
- data/lib/everywhere/ui.rb +6 -1
- data/lib/everywhere/version.rb +6 -4
- data/lib/everywhere.rb +1 -2
- data/support/mobile/android/app/build.gradle.kts +29 -1
- data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
- data/support/mobile/ios/App/SceneDelegate.swift +75 -8
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -4
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
- data/support/mobile/ios/README.md +13 -6
- data/support/release/macos/notarize.sh +3 -0
- metadata +40 -1
|
@@ -7,15 +7,19 @@ require "shellwords"
|
|
|
7
7
|
require "base64"
|
|
8
8
|
require "json"
|
|
9
9
|
require "open3"
|
|
10
|
+
require "securerandom"
|
|
11
|
+
require "rubygems/package"
|
|
12
|
+
require "zlib"
|
|
10
13
|
require_relative "../../ui"
|
|
14
|
+
require_relative "../../host"
|
|
11
15
|
require_relative "../../paths"
|
|
12
16
|
require_relative "../../platform/client"
|
|
13
17
|
|
|
14
18
|
module Everywhere
|
|
15
19
|
module Commands
|
|
16
|
-
# `every platform runner` — runs ON a build runner
|
|
17
|
-
#
|
|
18
|
-
# `every release` signing engine:
|
|
20
|
+
# `every platform runner` — runs ON a build runner: a macOS machine for the
|
|
21
|
+
# macos/ios targets, macOS or Linux for android. It's the glue between the
|
|
22
|
+
# Runner API and the `every release` signing engine:
|
|
19
23
|
#
|
|
20
24
|
# job spec → mark running → download+extract snapshot → pull JIT creds into
|
|
21
25
|
# a throwaway keychain (+ .p8) → every release (streaming logs) → upload the
|
|
@@ -24,6 +28,30 @@ module Everywhere
|
|
|
24
28
|
# Authenticated with the per-artifact runner token, so it can only touch the
|
|
25
29
|
# one artifact it was dispatched for.
|
|
26
30
|
class PlatformRunner < Dry::CLI::Command
|
|
31
|
+
# What `every release` leaves in dist/ per os, newest first when several
|
|
32
|
+
# match, and how the blob store should label it.
|
|
33
|
+
ARTIFACTS = { "macos" => %w[*.zip], "ios" => %w[*.ipa], "android" => %w[*.aab *.apk] }.freeze
|
|
34
|
+
CONTENT_TYPES = {
|
|
35
|
+
".zip" => "application/zip",
|
|
36
|
+
".ipa" => "application/octet-stream",
|
|
37
|
+
".apk" => "application/vnd.android.package-archive",
|
|
38
|
+
".aab" => "application/octet-stream"
|
|
39
|
+
}.freeze
|
|
40
|
+
RELEASE_NARRATION = {
|
|
41
|
+
"macos" => "every release — press → sign → notarize → staple",
|
|
42
|
+
"ios" => "every release — build → archive → export .ipa",
|
|
43
|
+
"android" => "every release — assemble → sign → bundle"
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# A .mobileprovision has to be in both: Xcode reads the UserData copy,
|
|
47
|
+
# xcodebuild's own resolution still reads the MobileDevice one.
|
|
48
|
+
PROFILE_DIRS = [
|
|
49
|
+
"~/Library/MobileDevice/Provisioning Profiles",
|
|
50
|
+
"~/Library/Developer/Xcode/UserData/Provisioning Profiles"
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
PROFILE_UUID = /\A[0-9a-f-]+\z/i
|
|
54
|
+
|
|
27
55
|
desc "Run a Platform build on this machine (used by the hosted runner)"
|
|
28
56
|
|
|
29
57
|
option :artifact, required: true, desc: "Artifact public id (art_…)"
|
|
@@ -49,10 +77,10 @@ module Everywhere
|
|
|
49
77
|
src = fetch_snapshot(work, job)
|
|
50
78
|
signing_env = pull_credentials(work)
|
|
51
79
|
|
|
52
|
-
status, release_json,
|
|
80
|
+
status, release_json, built = run_release(src, job, signing_env, shell_dir)
|
|
53
81
|
|
|
54
|
-
if status == "succeeded" &&
|
|
55
|
-
complete_success(
|
|
82
|
+
if status == "succeeded" && built
|
|
83
|
+
complete_success(built, release_json)
|
|
56
84
|
else
|
|
57
85
|
complete_failure("build failed — see log")
|
|
58
86
|
end
|
|
@@ -62,6 +90,12 @@ module Everywhere
|
|
|
62
90
|
rescue Everywhere::Error => e
|
|
63
91
|
complete_failure(e.message)
|
|
64
92
|
UI.die!(e.message)
|
|
93
|
+
rescue Everywhere::Fatal => e
|
|
94
|
+
# UI.die! raises Fatal (a SystemExit), which the rescue above can't see —
|
|
95
|
+
# without this an upload/signing failure would leave the artifact stuck
|
|
96
|
+
# "running" on the Platform forever.
|
|
97
|
+
complete_failure(e.message.to_s.empty? ? "build failed" : e.message)
|
|
98
|
+
raise
|
|
65
99
|
end
|
|
66
100
|
|
|
67
101
|
private
|
|
@@ -74,32 +108,128 @@ module Everywhere
|
|
|
74
108
|
@client.download(job["snapshot_url"], tarball)
|
|
75
109
|
src = File.join(work, "src")
|
|
76
110
|
FileUtils.mkdir_p(src)
|
|
77
|
-
|
|
111
|
+
extract_snapshot(tarball, src)
|
|
78
112
|
src
|
|
79
113
|
end
|
|
80
114
|
|
|
115
|
+
# Read with the same pure-Ruby tar the snapshot was WRITTEN with
|
|
116
|
+
# (Platform::Snapshot uses Gem::Package::TarWriter) instead of shelling
|
|
117
|
+
# out: `tar -xzf` follows "../" members and symlink members straight out
|
|
118
|
+
# of the extraction dir, and this archive arrives over the network.
|
|
119
|
+
def extract_snapshot(tarball, dest)
|
|
120
|
+
Zlib::GzipReader.open(tarball) do |gz|
|
|
121
|
+
Gem::Package::TarReader.new(gz) do |tar|
|
|
122
|
+
tar.each { |entry| extract_entry(entry, dest) }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
rescue Zlib::Error, Gem::Package::TarInvalidError => e
|
|
126
|
+
UI.die!("failed to extract snapshot (#{e.class.name.split("::").last})")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def extract_entry(entry, dest)
|
|
130
|
+
name = entry.full_name
|
|
131
|
+
UI.die!("the snapshot contains an unsafe path (#{name})") if unsafe_entry?(name)
|
|
132
|
+
# Snapshot only ever writes regular files; anything else in the archive
|
|
133
|
+
# didn't come from us.
|
|
134
|
+
return unless entry.file? && !entry.symlink?
|
|
135
|
+
|
|
136
|
+
path = File.join(dest, name)
|
|
137
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
138
|
+
File.open(path, "wb") { |out| out.write(entry.read(1 << 20)) until entry.eof? }
|
|
139
|
+
mode = entry.header.mode & 0o777
|
|
140
|
+
File.chmod(mode.zero? ? 0o644 : mode, path)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def unsafe_entry?(name)
|
|
144
|
+
name.empty? || name.start_with?("/") || name.split("/").include?("..")
|
|
145
|
+
end
|
|
146
|
+
|
|
81
147
|
def pull_credentials(work)
|
|
82
148
|
remote_log("pulling signing credentials")
|
|
83
149
|
creds = get!("/runner/artifacts/#{@artifact}/credentials")["credentials"] || []
|
|
84
150
|
env = {}
|
|
85
151
|
creds.each do |cred|
|
|
86
|
-
path =
|
|
152
|
+
path = credential_path(work, cred["filename"])
|
|
87
153
|
File.binwrite(path, Base64.strict_decode64(cred["data_base64"]))
|
|
88
154
|
File.chmod(0o600, path)
|
|
155
|
+
meta = cred["metadata"] || {}
|
|
89
156
|
case cred["kind"]
|
|
90
157
|
when "apple_developer_id", "apple_distribution"
|
|
91
|
-
env["
|
|
158
|
+
env["EVERY_IOS_TEAM_ID"] ||= meta["team_id"]
|
|
159
|
+
# An android job runs on Linux, where there's no keychain to import
|
|
160
|
+
# into. A stray apple credential there is the Platform's mistake, not
|
|
161
|
+
# a reason to lose the build.
|
|
162
|
+
if Host.darwin?
|
|
163
|
+
env["APPLE_SIGNING_IDENTITY"] = install_certificate(path, meta, work)
|
|
164
|
+
else
|
|
165
|
+
remote_log("skipping #{cred["kind"]} — keychain import needs macOS")
|
|
166
|
+
end
|
|
92
167
|
when "apple_asc_api_key"
|
|
93
168
|
env["NOTARY_KEY"] = path
|
|
94
|
-
env["NOTARY_KEY_ID"] =
|
|
95
|
-
env["NOTARY_ISSUER"] =
|
|
169
|
+
env["NOTARY_KEY_ID"] = meta["key_id"]
|
|
170
|
+
env["NOTARY_ISSUER"] = meta["issuer_id"]
|
|
171
|
+
when "apple_provisioning_profile"
|
|
172
|
+
profile = install_provisioning_profile(path)
|
|
173
|
+
env["EVERY_IOS_PROVISIONING_PROFILE"] = profile[:name]
|
|
174
|
+
env["EVERY_IOS_TEAM_ID"] ||= profile[:team]
|
|
175
|
+
when "android_keystore"
|
|
176
|
+
env["EVERY_ANDROID_KEYSTORE"] = path
|
|
177
|
+
env["EVERY_ANDROID_KEYSTORE_PASSWORD"] = meta["store_password"]
|
|
178
|
+
env["EVERY_ANDROID_KEY_ALIAS"] = meta["key_alias"]
|
|
179
|
+
# One-key keystores are usually created with a single password.
|
|
180
|
+
env["EVERY_ANDROID_KEY_PASSWORD"] = meta["key_password"] || meta["store_password"]
|
|
96
181
|
end
|
|
97
182
|
end
|
|
98
|
-
env
|
|
183
|
+
env.compact
|
|
99
184
|
end
|
|
100
185
|
|
|
186
|
+
# Credential filenames come from the Platform API, so they're untrusted
|
|
187
|
+
# input: a "../../.ssh/authorized_keys" would write outside the job's
|
|
188
|
+
# tmpdir. Only ever a basename, inside work.
|
|
189
|
+
def credential_path(work, filename)
|
|
190
|
+
name = File.basename(filename.to_s)
|
|
191
|
+
UI.die!("the Platform delivered a credential with no usable filename") if name.empty? || [".", ".."].include?(name)
|
|
192
|
+
|
|
193
|
+
File.join(work, name)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# A .mobileprovision is a CMS-signed DER blob wrapped around a plain XML
|
|
197
|
+
# plist. Scanning for the plist reads it identically on Linux, where
|
|
198
|
+
# `security cms -D` doesn't exist.
|
|
199
|
+
def install_provisioning_profile(path)
|
|
200
|
+
plist = File.binread(path)[/<\?xml.*<\/plist>/m]
|
|
201
|
+
UI.die!("the delivered provisioning profile has no embedded plist") unless plist
|
|
202
|
+
|
|
203
|
+
uuid = plist[%r{<key>UUID</key>\s*<string>([^<]+)</string>}m, 1]
|
|
204
|
+
name = plist[%r{<key>Name</key>\s*<string>([^<]+)</string>}m, 1]
|
|
205
|
+
# TeamIdentifier is an <array> of one — the first <string> after the key.
|
|
206
|
+
team = plist[%r{<key>TeamIdentifier</key>.*?<string>([^<]+)</string>}m, 1]
|
|
207
|
+
# The UUID becomes a filename in the user's Provisioning Profiles dirs,
|
|
208
|
+
# and it's read out of a blob we didn't author — a "../" one would land
|
|
209
|
+
# anywhere. Shape-check it before it's ever a path segment.
|
|
210
|
+
UI.die!("the delivered provisioning profile has no usable UUID") unless uuid&.match?(PROFILE_UUID)
|
|
211
|
+
|
|
212
|
+
if Host.darwin?
|
|
213
|
+
profile_dirs.each do |dir|
|
|
214
|
+
FileUtils.mkdir_p(dir)
|
|
215
|
+
FileUtils.cp(path, File.join(dir, "#{uuid}.mobileprovision"))
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
remote_log("provisioning profile #{UI.bold(name || uuid)}#{team ? " #{UI.dim("(team #{team})")}" : ""}")
|
|
219
|
+
{ name: name, team: team, uuid: uuid }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def profile_dirs = PROFILE_DIRS.map { |dir| File.expand_path(dir) }
|
|
223
|
+
|
|
224
|
+
|
|
101
225
|
# Import the .p12 into a throwaway keychain and add it to the search list so
|
|
102
226
|
# codesign finds the identity. Returns the identity name for notarize.sh.
|
|
227
|
+
#
|
|
228
|
+
# ACCEPTED RISK: `security` takes its passwords on argv, where `ps` can see
|
|
229
|
+
# them — it has no stdin form (a bare unlock-keychain prompts on the tty,
|
|
230
|
+
# which is no use in a headless job). What's exposed is this keychain's
|
|
231
|
+
# random single-use password and the .p12's own, on an ephemeral
|
|
232
|
+
# single-tenant runner that is destroyed after the build.
|
|
103
233
|
def install_certificate(p12, metadata, work)
|
|
104
234
|
@keychain = File.join(work, "runner.keychain-db")
|
|
105
235
|
password = SecureRandom.hex(12)
|
|
@@ -124,11 +254,16 @@ module Everywhere
|
|
|
124
254
|
end
|
|
125
255
|
|
|
126
256
|
def run_release(src, job, signing_env, shell_dir)
|
|
127
|
-
|
|
257
|
+
os = job.dig("target", "os").to_s
|
|
258
|
+
channel = job.dig("target", "distribution_channel").to_s
|
|
259
|
+
remote_log(RELEASE_NARRATION.fetch(os, "every release"))
|
|
128
260
|
cmd = [RbConfig.ruby, "-I", lib_dir, every_exe, "release",
|
|
129
261
|
"--root", src,
|
|
130
|
-
"--target", "#{
|
|
131
|
-
"--channel",
|
|
262
|
+
"--target", "#{os}-#{job.dig("target", "arch")}",
|
|
263
|
+
"--channel", channel]
|
|
264
|
+
# Play wants an App Bundle; every other Android channel (direct download,
|
|
265
|
+
# sideload, Amazon) wants the installable apk `every release` defaults to.
|
|
266
|
+
cmd += ["--format", "aab"] if os == "android" && channel == "play"
|
|
132
267
|
cmd += ["--shell-dir", shell_dir] if shell_dir
|
|
133
268
|
|
|
134
269
|
# The web UI renders ANSI, but `every release` runs here as a pipe (not a
|
|
@@ -144,37 +279,45 @@ module Everywhere
|
|
|
144
279
|
|
|
145
280
|
dist = File.join(src, "dist")
|
|
146
281
|
release_json = JSON.parse(File.read(File.join(dist, "release.json"))) rescue nil
|
|
147
|
-
|
|
148
|
-
|
|
282
|
+
built = ARTIFACTS.fetch(os, %w[*.zip])
|
|
283
|
+
.filter_map { |p| Dir[File.join(dist, p)].max_by { |f| File.mtime(f) } }.first
|
|
284
|
+
[success ? "succeeded" : "failed", release_json, built]
|
|
149
285
|
end
|
|
150
286
|
|
|
151
|
-
def complete_success(
|
|
287
|
+
def complete_success(built, release_json)
|
|
152
288
|
report_phase("uploading")
|
|
153
|
-
remote_log("uploading artifact #{UI.dim("(#{(File.size(
|
|
154
|
-
signed_id = upload_artifact(
|
|
289
|
+
remote_log("uploading artifact #{UI.dim("(#{(File.size(built) / 1024.0 / 1024.0).round(1)}MB)")}")
|
|
290
|
+
signed_id = upload_artifact(built)
|
|
155
291
|
post!("/runner/artifacts/#{@artifact}/complete",
|
|
156
292
|
status: "succeeded", file_signed_id: signed_id, release_json: release_json)
|
|
157
|
-
UI.success("build succeeded — #{File.basename(
|
|
293
|
+
UI.success("build succeeded — #{File.basename(built)}")
|
|
158
294
|
end
|
|
159
295
|
|
|
296
|
+
# Best-effort: if the thing that failed WAS connectivity, reporting the
|
|
297
|
+
# failure fails too — raising here would replace the real error and abort
|
|
298
|
+
# cleanup, so log locally and move on.
|
|
160
299
|
def complete_failure(message)
|
|
161
300
|
return if @completed
|
|
162
301
|
|
|
163
302
|
@client.post("/runner/artifacts/#{@artifact}/complete", status: "failed", error: message)
|
|
164
303
|
@completed = true
|
|
304
|
+
rescue Everywhere::Error => e
|
|
305
|
+
UI.warn("couldn't report the failure to the Platform (#{e.message})")
|
|
165
306
|
end
|
|
166
307
|
|
|
167
|
-
def upload_artifact(
|
|
308
|
+
def upload_artifact(built)
|
|
168
309
|
require_relative "../../platform/snapshot"
|
|
169
310
|
reservation = post!("/runner/artifacts/#{@artifact}/upload",
|
|
170
|
-
filename: File.basename(
|
|
171
|
-
checksum: Everywhere::Platform::Snapshot.md5_base64(
|
|
311
|
+
filename: File.basename(built), byte_size: File.size(built),
|
|
312
|
+
checksum: Everywhere::Platform::Snapshot.md5_base64(built), content_type: content_type_for(built))
|
|
172
313
|
du = reservation["direct_upload"] || {}
|
|
173
|
-
res = @client.put_file(du["url"],
|
|
314
|
+
res = @client.put_file(du["url"], built, headers: du["headers"] || {})
|
|
174
315
|
UI.die!("artifact upload failed (HTTP #{res.code})") unless res.code.to_i.between?(200, 299)
|
|
175
316
|
reservation["signed_id"]
|
|
176
317
|
end
|
|
177
318
|
|
|
319
|
+
def content_type_for(path) = CONTENT_TYPES.fetch(File.extname(path), "application/octet-stream")
|
|
320
|
+
|
|
178
321
|
# ---- log streaming -------------------------------------------------------
|
|
179
322
|
|
|
180
323
|
# Run cmd, echoing output to this process's stdout AND forwarding it to the
|
|
@@ -241,6 +384,8 @@ module Everywhere
|
|
|
241
384
|
end
|
|
242
385
|
|
|
243
386
|
def restore_keychains
|
|
387
|
+
return unless Host.darwin?
|
|
388
|
+
|
|
244
389
|
run_security("list-keychains", "-d", "user", "-s", *@saved_keychains) if @saved_keychains
|
|
245
390
|
run_security("delete-keychain", @keychain) if @keychain && File.exist?(@keychain)
|
|
246
391
|
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "shellwords"
|
|
7
|
+
require "timeout"
|
|
8
|
+
require "uri"
|
|
9
|
+
require_relative "../shellout"
|
|
10
|
+
require_relative "../console"
|
|
11
|
+
require_relative "../child_processes"
|
|
12
|
+
require_relative "../child_supervision"
|
|
13
|
+
require_relative "../framework"
|
|
14
|
+
require_relative "../config"
|
|
15
|
+
require_relative "../jump"
|
|
16
|
+
|
|
17
|
+
module Everywhere
|
|
18
|
+
module Commands
|
|
19
|
+
# Put the running dev server in someone's hand: boot the app, open a
|
|
20
|
+
# Cloudflare tunnel to it, and print a QR code that opens the Jump app
|
|
21
|
+
# (spec: platform/docs/jump.md). The server gets EVERYWHERE_JUMP_TOKEN, so
|
|
22
|
+
# the Jump endpoints in MobileConfigEndpoint / MobileConfigsController are
|
|
23
|
+
# live for exactly as long as this command runs.
|
|
24
|
+
#
|
|
25
|
+
# Three transports, one seam (tunnel_url):
|
|
26
|
+
# default cloudflared quick tunnel — free, no account, HTTPS,
|
|
27
|
+
# best-effort (restarted with a fresh QR if it drops)
|
|
28
|
+
# --tunnel-name a named tunnel from the developer's own Cloudflare
|
|
29
|
+
# account, for a stable hostname (--tunnel-host says what
|
|
30
|
+
# that hostname is — cloudflared doesn't know its own DNS)
|
|
31
|
+
# --lan no tunnel: the LAN address, for offline use
|
|
32
|
+
class Preview < Dry::CLI::Command
|
|
33
|
+
include ChildSupervision
|
|
34
|
+
|
|
35
|
+
desc "Run the dev server behind a shareable tunnel for the Jump app"
|
|
36
|
+
|
|
37
|
+
option :port, default: "3000", desc: "Dev server port"
|
|
38
|
+
option :lan, type: :boolean, default: false, desc: "Skip the tunnel and share the LAN address"
|
|
39
|
+
option :tunnel_name, default: nil, desc: "Run a named cloudflared tunnel instead of a quick one"
|
|
40
|
+
option :tunnel_host, default: nil, desc: "Public hostname the named tunnel is routed to"
|
|
41
|
+
|
|
42
|
+
TRYCLOUDFLARE_URL = %r{https://[a-z0-9-]+\.trycloudflare\.com}
|
|
43
|
+
TUNNEL_BOOT_TIMEOUT = 30
|
|
44
|
+
|
|
45
|
+
def call(port: "3000", lan: false, tunnel_name: nil, tunnel_host: nil, **)
|
|
46
|
+
port = validate_port!(port)
|
|
47
|
+
# An Integer, because @port is interpolated into the cloudflared command
|
|
48
|
+
# strings — nothing that reaches a shell should be a free-form String.
|
|
49
|
+
@port = Integer(port, 10)
|
|
50
|
+
init_children!
|
|
51
|
+
@framework = detect_local_framework
|
|
52
|
+
@config = Everywhere::Config.load(@framework.root)
|
|
53
|
+
# Multi-use on purpose: a forwarded link must work for a second
|
|
54
|
+
# coworker's device. Dies with this process — bearers are HMACs over
|
|
55
|
+
# it, so they die too.
|
|
56
|
+
@token = SecureRandom.urlsafe_base64(24)
|
|
57
|
+
|
|
58
|
+
@lan = lan
|
|
59
|
+
if lan
|
|
60
|
+
@url = lan_url(port)
|
|
61
|
+
else
|
|
62
|
+
Shellout.ensure_tool!("cloudflared",
|
|
63
|
+
"preview shares your app through a Cloudflare tunnel — " \
|
|
64
|
+
"install it with `brew install cloudflared` (or use --lan)")
|
|
65
|
+
validate_tunnel_options!(tunnel_name, tunnel_host)
|
|
66
|
+
@tunnel_name = tunnel_name
|
|
67
|
+
@tunnel_host = tunnel_host
|
|
68
|
+
@url = start_tunnel
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
start_server(port)
|
|
72
|
+
warn_if_loopback_only if lan
|
|
73
|
+
announce
|
|
74
|
+
wait
|
|
75
|
+
rescue Interrupt
|
|
76
|
+
nil
|
|
77
|
+
ensure
|
|
78
|
+
teardown_children
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Preview exists to share a *local* dev server. A remote-mode root has
|
|
84
|
+
# no local server — the deployed URL is already shareable.
|
|
85
|
+
def detect_local_framework
|
|
86
|
+
Framework.detect
|
|
87
|
+
rescue Everywhere::Error
|
|
88
|
+
config = Everywhere::Config.load(Dir.pwd)
|
|
89
|
+
if config.remote?
|
|
90
|
+
UI.die!("this app runs in remote mode — #{config.remote_url} is already online, share that " \
|
|
91
|
+
"(preview tunnels a local dev server)")
|
|
92
|
+
end
|
|
93
|
+
UI.die!("no supported framework here (Rails, Hanami, Sinatra) — nothing to preview")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_tunnel_options!(name, host)
|
|
97
|
+
return if name.nil? && host.nil?
|
|
98
|
+
return if name && host
|
|
99
|
+
|
|
100
|
+
UI.die!("--tunnel-name and --tunnel-host go together: cloudflared runs the named tunnel, " \
|
|
101
|
+
"but only your Cloudflare DNS knows its public hostname")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- transports -----------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def lan_url(port)
|
|
107
|
+
"http://#{lan_ip}:#{port}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def lan_ip
|
|
111
|
+
Socket.ip_address_list.find { |a| a.ipv4_private? }&.ip_address ||
|
|
112
|
+
UI.die!("couldn't find a LAN address — is this machine on a network? (drop --lan to tunnel instead)")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def start_tunnel
|
|
116
|
+
if @tunnel_name
|
|
117
|
+
UI.step("starting named tunnel #{UI.bold(@tunnel_name)} → #{UI.cyan("https://#{@tunnel_host}")}")
|
|
118
|
+
spawn_tunnel("cloudflared tunnel run --url http://127.0.0.1:#{@port} #{@tunnel_name.shellescape}")
|
|
119
|
+
"https://#{@tunnel_host}"
|
|
120
|
+
else
|
|
121
|
+
UI.step("starting Cloudflare quick tunnel")
|
|
122
|
+
quick_tunnel_url
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Quick tunnels choose their hostname at startup and print it once;
|
|
127
|
+
# everything else cloudflared logs is noise here, so the relay drops it
|
|
128
|
+
# and the on_line hook fishes out the URL.
|
|
129
|
+
def quick_tunnel_url
|
|
130
|
+
found = Queue.new
|
|
131
|
+
spawn_tunnel("cloudflared tunnel --url http://127.0.0.1:#{@port}",
|
|
132
|
+
on_line: ->(line) { (url = line[TRYCLOUDFLARE_URL]) && found << url },
|
|
133
|
+
quiet: true)
|
|
134
|
+
begin
|
|
135
|
+
Timeout.timeout(TUNNEL_BOOT_TIMEOUT) { found.pop }
|
|
136
|
+
rescue Timeout::Error
|
|
137
|
+
UI.die!("cloudflared never reported a tunnel URL after #{TUNNEL_BOOT_TIMEOUT}s — " \
|
|
138
|
+
"check your network, or share on the local network with --lan")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# quiet drops the child's routine chatter but always lets errors
|
|
143
|
+
# through — a quick tunnel that can't connect must say why.
|
|
144
|
+
def spawn_tunnel(cmd, on_line: nil, quiet: false)
|
|
145
|
+
filter = quiet ? ->(line) { line.match?(/\bERR\b|error/i) ? line : :drop } : nil
|
|
146
|
+
supervise(:tunnel, {}, cmd, chdir: Dir.pwd, filter: filter, on_line: on_line)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Rails' puma template binds 0.0.0.0, but a rackup-style bin/dev often
|
|
150
|
+
# pins --host 127.0.0.1 — then the LAN URL on the QR is a promise the
|
|
151
|
+
# server can't keep. Say so, instead of letting the phone time out.
|
|
152
|
+
def warn_if_loopback_only
|
|
153
|
+
TCPSocket.new(URI.parse(@url).host, Integer(@port)).close
|
|
154
|
+
rescue SystemCallError
|
|
155
|
+
UI.warn("the dev server only listens on 127.0.0.1, so phones can't reach #{@url} — " \
|
|
156
|
+
"make bin/dev bind 0.0.0.0 (rackup --host 0.0.0.0), or drop --lan and tunnel instead")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# --- the dev server -------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def start_server(port)
|
|
162
|
+
# Attaching to an already-running server (dev's behavior) can't work
|
|
163
|
+
# here: that process has no EVERYWHERE_JUMP_TOKEN, so Jump could never
|
|
164
|
+
# pair with it.
|
|
165
|
+
if port_open?(port)
|
|
166
|
+
UI.die!("something is already running on port #{port} — preview must start the server itself " \
|
|
167
|
+
"(the Jump pairing token rides in on its environment); stop that server or use --port")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
UI.step("starting #{UI.bold(@framework.name)} dev server #{UI.dim("(" + @framework.dev_command + ")")}")
|
|
171
|
+
env = { "PORT" => port.to_s,
|
|
172
|
+
"EVERYWHERE_JUMP_TOKEN" => @token,
|
|
173
|
+
"EVERYWHERE_PREVIEW_HOST" => URI.parse(@url).host }
|
|
174
|
+
# `rails server` binds localhost by default, which would make the LAN
|
|
175
|
+
# address on the QR a lie. BINDING is Rails' own env override; rackup
|
|
176
|
+
# bin/devs that pin --host still get warn_if_loopback_only.
|
|
177
|
+
env["BINDING"] = "0.0.0.0" if @lan
|
|
178
|
+
pid = supervise(:web, env, @framework.dev_command, chdir: @framework.root)
|
|
179
|
+
wait_for_port(port, pid: pid)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# --- the invitation -------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def jump_base = ENV.fetch("EVERY_JUMP_URL", "https://jump.rubyeverywhere.com").chomp("/")
|
|
185
|
+
|
|
186
|
+
def connect_url
|
|
187
|
+
"#{jump_base}/connect?#{URI.encode_www_form(url: @url, token: @token)}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# POST /j on the Jump site trades the long connect URL for a short one —
|
|
191
|
+
# a nicer link to read out and a much smaller QR. Best-effort: preview
|
|
192
|
+
# works offline from the Jump site's point of view.
|
|
193
|
+
def share_link
|
|
194
|
+
require "net/http"
|
|
195
|
+
require "json"
|
|
196
|
+
uri = URI.parse("#{jump_base}/j")
|
|
197
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
198
|
+
open_timeout: 5, read_timeout: 5) do |http|
|
|
199
|
+
http.post(uri.path, JSON.generate(url: @url, token: @token),
|
|
200
|
+
"content-type" => "application/json")
|
|
201
|
+
end
|
|
202
|
+
response.code == "201" ? JSON.parse(response.body)["url"] : nil
|
|
203
|
+
rescue StandardError
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def announce
|
|
208
|
+
link = share_link || connect_url
|
|
209
|
+
UI.step("preview of #{UI.bold(@config.name)} is live → #{UI.cyan(@url)}")
|
|
210
|
+
Console.print(qr_ansi(link)) if $stdout.tty?
|
|
211
|
+
Console.print(" Scan with your phone's camera, or send:\n\n #{UI.cyan(link)}\n\n")
|
|
212
|
+
Console.print(" #{UI.dim("Manual entry in Jump:")} #{@url} #{UI.dim("token:")} #{@token}\n")
|
|
213
|
+
Console.print(UI.dim(" The link and QR die with this session — Ctrl-C to stop.\n"))
|
|
214
|
+
UI.note("waiting for devices…", marker: "⌁", color: :cyan)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Half-block rendering: ▀ painted fg-for-top-module, bg-for-bottom packs
|
|
218
|
+
# two QR rows into one terminal line and one module into one column —
|
|
219
|
+
# a quarter of the area of rqrcode's own as_ansi, which burns two full
|
|
220
|
+
# cells per module. Explicit black/white (not theme colors), because a
|
|
221
|
+
# camera needs dark-on-light no matter what the terminal theme thinks.
|
|
222
|
+
#
|
|
223
|
+
# rqrcode is pure Ruby. The rescue is portability hardening: a missing
|
|
224
|
+
# gem downgrades the session to link-only instead of killing it.
|
|
225
|
+
def qr_ansi(link)
|
|
226
|
+
require "rqrcode"
|
|
227
|
+
modules = RQRCode::QRCode.new(link, level: :l).modules
|
|
228
|
+
quiet = 2
|
|
229
|
+
size = modules.length + quiet * 2
|
|
230
|
+
dark = lambda do |x, y|
|
|
231
|
+
gx = x - quiet
|
|
232
|
+
gy = y - quiet
|
|
233
|
+
gy.between?(0, modules.length - 1) && gx.between?(0, modules.length - 1) && modules[gy][gx]
|
|
234
|
+
end
|
|
235
|
+
(0...size).step(2).map do |y|
|
|
236
|
+
" " + (0...size).map do |x|
|
|
237
|
+
top = dark.call(x, y)
|
|
238
|
+
bottom = (y + 1 < size) && dark.call(x, y + 1)
|
|
239
|
+
"\e[#{top ? 30 : 97};#{bottom ? 40 : 107}m▀"
|
|
240
|
+
end.join + "\e[0m"
|
|
241
|
+
end.join("\n") << "\e[0m\n"
|
|
242
|
+
rescue LoadError, StandardError => e
|
|
243
|
+
UI.warn("couldn't render the QR (#{e.class}) — use the link below")
|
|
244
|
+
""
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# --- the wait loop --------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
# The server's death ends the session; the tunnel's doesn't. Quick
|
|
250
|
+
# tunnels are explicitly best-effort — Cloudflare recycles them — so a
|
|
251
|
+
# dead tunnel is restarted and the fresh URL re-announced. (The token
|
|
252
|
+
# survives, but the origin changed, so recents/short links need the new
|
|
253
|
+
# invitation.)
|
|
254
|
+
def wait
|
|
255
|
+
loop do
|
|
256
|
+
sleep 1
|
|
257
|
+
web = @children.synchronize { @pids[:web] }
|
|
258
|
+
UI.die!("the dev server exited — scroll up for its output") unless ChildProcesses.alive?(web)
|
|
259
|
+
|
|
260
|
+
tunnel = @children.synchronize { @pids[:tunnel] }
|
|
261
|
+
next if tunnel.nil? || ChildProcesses.alive?(tunnel)
|
|
262
|
+
|
|
263
|
+
UI.warn("the tunnel dropped — starting a fresh one")
|
|
264
|
+
stop_child(:tunnel)
|
|
265
|
+
@url = start_tunnel
|
|
266
|
+
announce
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# --- child supervision (dev.rb's pattern, without the dock) ---------------
|
|
271
|
+
|
|
272
|
+
def spawn_child(env, cmd, chdir:)
|
|
273
|
+
return Shellout.spawn_pty(env, cmd, chdir: chdir) if Shellout.pty?
|
|
274
|
+
|
|
275
|
+
# No pty (Windows): a plain pipe loses colors but keeps the output —
|
|
276
|
+
# and the quick tunnel's URL parsing, which reads it. child_env for
|
|
277
|
+
# the same reason spawn_pty uses it: the CLI's own BUNDLE_* must not
|
|
278
|
+
# follow the app's server into its child.
|
|
279
|
+
io, writer = IO.pipe
|
|
280
|
+
pid = Process.spawn(Shellout.child_env(env), cmd, chdir: chdir, %i[out err] => writer)
|
|
281
|
+
writer.close
|
|
282
|
+
[io, pid]
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -8,6 +8,7 @@ require_relative "../s3"
|
|
|
8
8
|
require_relative "../update_manifest"
|
|
9
9
|
require_relative "../paths"
|
|
10
10
|
require_relative "../ui"
|
|
11
|
+
require_relative "build"
|
|
11
12
|
|
|
12
13
|
module Everywhere
|
|
13
14
|
module Commands
|
|
@@ -21,6 +22,15 @@ module Everywhere
|
|
|
21
22
|
# from everywhere.yml's updates.s3 section. See UpdateManifest for the
|
|
22
23
|
# layout/JSON contract.
|
|
23
24
|
class Publish < Dry::CLI::Command
|
|
25
|
+
# Only the .apk needs a specific type: Android refuses to install a
|
|
26
|
+
# download the server labelled application/octet-stream.
|
|
27
|
+
CONTENT_TYPES = {
|
|
28
|
+
".zip" => "application/zip",
|
|
29
|
+
".apk" => "application/vnd.android.package-archive",
|
|
30
|
+
".aab" => "application/octet-stream",
|
|
31
|
+
".ipa" => "application/octet-stream"
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
24
34
|
desc "Publish a released build to your update bucket (self-hosted auto-updates)"
|
|
25
35
|
|
|
26
36
|
option :root, default: ".", desc: "App root directory"
|
|
@@ -46,6 +56,12 @@ module Everywhere
|
|
|
46
56
|
publish!(receipt_path(receipt), notes: read_notes(notes, notes_file),
|
|
47
57
|
key: key, latest_alias: latest_alias, force: force)
|
|
48
58
|
end
|
|
59
|
+
rescue Everywhere::S3::Error => e
|
|
60
|
+
# RequestTimeExpired means the bucket rejected our SigV4 timestamp; the
|
|
61
|
+
# only realistic local cause is a wrong clock, and the raw message
|
|
62
|
+
# doesn't say so.
|
|
63
|
+
hint = e.message.include?("RequestTimeExpired") ? " — your system clock may be off by more than 15 minutes" : ""
|
|
64
|
+
UI.die!("#{e.message}#{hint}")
|
|
49
65
|
end
|
|
50
66
|
|
|
51
67
|
private
|
|
@@ -131,7 +147,7 @@ module Everywhere
|
|
|
131
147
|
def rollback!(version, latest_alias:)
|
|
132
148
|
# os/arch come from the receipt on publish; on rollback there may be no
|
|
133
149
|
# dist/, so take the target from build.targets (or the default).
|
|
134
|
-
target = Array(@config.targets).first ||
|
|
150
|
+
target = Array(@config.targets).first || Build::DEFAULT_TARGET
|
|
135
151
|
os, arch = target.split("-", 2)
|
|
136
152
|
loc = { channel: @channel, os: os, arch: arch, prefix: prefix }
|
|
137
153
|
|
|
@@ -157,6 +173,8 @@ module Everywhere
|
|
|
157
173
|
def preflight!
|
|
158
174
|
UI.die!("no updates.url in everywhere.yml — add an updates: section (see `every updates keygen`)") unless @config.updates_url
|
|
159
175
|
UI.die!("no updates.public_key in everywhere.yml — run `every updates keygen`") unless @config.updates_public_key
|
|
176
|
+
updates_errors = @config.updates_errors
|
|
177
|
+
UI.die!("updates: in config/everywhere.yml:\n#{updates_errors.join("\n")}") unless updates_errors.empty?
|
|
160
178
|
UI.die!("no updates.s3.bucket in everywhere.yml") if s3_config["bucket"].to_s.empty?
|
|
161
179
|
UI.die!("missing bucket credentials — set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY") if
|
|
162
180
|
ENV["AWS_ACCESS_KEY_ID"].to_s.empty? || ENV["AWS_SECRET_ACCESS_KEY"].to_s.empty?
|
|
@@ -207,6 +225,8 @@ module Everywhere
|
|
|
207
225
|
signature
|
|
208
226
|
rescue Minisign::PassphraseError
|
|
209
227
|
UI.die!("wrong or missing key passphrase — set EVERYWHERE_SIGNING_KEY_PASSPHRASE")
|
|
228
|
+
rescue Minisign::Error => e
|
|
229
|
+
UI.die!("couldn't sign with that key — #{e.message}")
|
|
210
230
|
end
|
|
211
231
|
|
|
212
232
|
def signing_key(key_option)
|
|
@@ -244,7 +264,7 @@ module Everywhere
|
|
|
244
264
|
end
|
|
245
265
|
|
|
246
266
|
def content_type(filename)
|
|
247
|
-
|
|
267
|
+
CONTENT_TYPES.fetch(File.extname(filename), "application/octet-stream")
|
|
248
268
|
end
|
|
249
269
|
|
|
250
270
|
def read_notes(notes, notes_file)
|