ruby_everywhere 0.0.1 → 0.1.1

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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/exe/every +6 -0
  3. data/exe/rbe +6 -0
  4. data/lib/everywhere/boot.rb +138 -0
  5. data/lib/everywhere/cli.rb +44 -0
  6. data/lib/everywhere/commands/build.rb +267 -0
  7. data/lib/everywhere/commands/dev.rb +61 -0
  8. data/lib/everywhere/commands/doctor.rb +54 -0
  9. data/lib/everywhere/commands/install.rb +264 -0
  10. data/lib/everywhere/commands/platform/auth_status.rb +42 -0
  11. data/lib/everywhere/commands/platform/build.rb +181 -0
  12. data/lib/everywhere/commands/platform/login.rb +81 -0
  13. data/lib/everywhere/commands/platform/logout.rb +34 -0
  14. data/lib/everywhere/commands/platform/runner.rb +224 -0
  15. data/lib/everywhere/commands/release.rb +142 -0
  16. data/lib/everywhere/config.rb +188 -0
  17. data/lib/everywhere/database.rb +89 -0
  18. data/lib/everywhere/framework.rb +197 -0
  19. data/lib/everywhere/ignore.rb +85 -0
  20. data/lib/everywhere/javascript/bridge.js +174 -0
  21. data/lib/everywhere/paths.rb +35 -0
  22. data/lib/everywhere/platform/client.rb +125 -0
  23. data/lib/everywhere/platform/credentials.rb +71 -0
  24. data/lib/everywhere/platform/snapshot.rb +48 -0
  25. data/lib/everywhere/png.rb +110 -0
  26. data/lib/everywhere/receipt.rb +185 -0
  27. data/lib/everywhere/shellout.rb +46 -0
  28. data/lib/everywhere/ui.rb +37 -0
  29. data/lib/everywhere/version.rb +11 -0
  30. data/lib/everywhere.rb +50 -0
  31. data/lib/ruby_everywhere.rb +2 -3
  32. data/support/github/build.yml +85 -0
  33. data/support/macos/entitlements.plist +23 -0
  34. data/support/macos/notarize.sh +123 -0
  35. data/support/shell/splash/index.html +40 -0
  36. data/support/shell/src-tauri/Cargo.lock +5446 -0
  37. data/support/shell/src-tauri/Cargo.toml +19 -0
  38. data/support/shell/src-tauri/build.rs +3 -0
  39. data/support/shell/src-tauri/capabilities/default.json +22 -0
  40. data/support/shell/src-tauri/gen/schemas/acl-manifests.json +1 -0
  41. data/support/shell/src-tauri/gen/schemas/capabilities.json +1 -0
  42. data/support/shell/src-tauri/gen/schemas/desktop-schema.json +2634 -0
  43. data/support/shell/src-tauri/gen/schemas/macOS-schema.json +2634 -0
  44. data/support/shell/src-tauri/icons/icon.png +0 -0
  45. data/support/shell/src-tauri/src/main.rs +723 -0
  46. data/support/shell/src-tauri/tauri.conf.json +20 -0
  47. metadata +80 -7
  48. data/README.md +0 -11
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # Framework adapters keep both the CLI (dev/build) and the packaged runtime
5
+ # (boot) framework-agnostic. Each adapter owns two sides of one framework:
6
+ #
7
+ # CLI side — dev_command / precompile_* / cleanup_command (build.rb, dev.rb)
8
+ # runtime side — prepare_runtime_env / boot! (boot.rb, inside the binary)
9
+ #
10
+ # Rails keeps its own boot path (Rails::Command server, multi-db prepare_all).
11
+ # Sinatra and Hanami are plain Rack apps and share RackFramework's boot: load
12
+ # config.ru, prepare the database, serve it under Puma bound to localhost.
13
+ class Framework
14
+ def self.detect(root = Dir.pwd)
15
+ return Rails.new(root) if rails?(root)
16
+ return Hanami.new(root) if hanami?(root)
17
+ return Sinatra.new(root) if sinatra?(root)
18
+
19
+ raise Error, "No supported framework detected in #{root} (looked for Rails, Hanami, Sinatra)"
20
+ end
21
+
22
+ def self.rails?(root)
23
+ File.exist?(File.join(root, "config", "application.rb")) &&
24
+ gemfile_mentions?(root, "rails")
25
+ end
26
+
27
+ def self.hanami?(root)
28
+ File.exist?(File.join(root, "config", "app.rb")) && gemfile_mentions?(root, "hanami")
29
+ end
30
+
31
+ def self.sinatra?(root)
32
+ File.exist?(File.join(root, "config.ru")) && gemfile_mentions?(root, "sinatra")
33
+ end
34
+
35
+ def self.gemfile_mentions?(root, gem_name)
36
+ gemfile = File.join(root, "Gemfile")
37
+ File.exist?(gemfile) && File.read(gemfile).match?(/gem ["']#{Regexp.escape(gem_name)}/)
38
+ end
39
+
40
+ attr_reader :root
41
+
42
+ def initialize(root)
43
+ @root = File.expand_path(root)
44
+ end
45
+
46
+ # ---- runtime (inside the packaged binary) --------------------------------
47
+
48
+ # Set framework-specific env before the app loads. Called by Boot.
49
+ def prepare_runtime_env; end
50
+
51
+ # Load the app, prepare its database, and start the server bound to
52
+ # 127.0.0.1:port. Blocks. Implemented per framework.
53
+ def boot!(port:)
54
+ raise NotImplementedError, "#{name} has no packaged boot path yet"
55
+ end
56
+
57
+ # Build the Rack app from config.ru. Rack 3 returns the app; Rack 2 returned
58
+ # [app, options].
59
+ def load_rack_app
60
+ require "rack"
61
+ built = ::Rack::Builder.parse_file(File.join(root, "config.ru"))
62
+ built.is_a?(Array) ? built.first : built
63
+ end
64
+
65
+ # ---- install (CLI side) --------------------------------------------------
66
+
67
+ # The native_boot.rb entry point `every install` writes. Frameworks differ
68
+ # in which env they set and how the bundle is loaded before the gem.
69
+ def boot_stub
70
+ render_boot_stub(env_exports, "require \"bundler/setup\"")
71
+ end
72
+
73
+ # Env each framework needs set before the app loads, beyond NATIVE_PACKAGED.
74
+ def env_exports = {}
75
+
76
+ def render_boot_stub(env, setup_line)
77
+ exports = ({ "NATIVE_PACKAGED" => "1" }.merge(env))
78
+ .map { |k, v| "ENV[#{k.inspect}] ||= #{v.inspect}" }.join("\n")
79
+ <<~RUBY
80
+ #!/usr/bin/env ruby
81
+ # frozen_string_literal: true
82
+
83
+ # RubyEverywhere packaged-app entry point (generated by `every install`).
84
+ # Keep this thin: the real boot lives in the ruby_everywhere gem.
85
+ #{exports}
86
+
87
+ #{setup_line}
88
+ require "ruby_everywhere"
89
+
90
+ Everywhere.boot!(root: __dir__)
91
+ RUBY
92
+ end
93
+
94
+ # Serve a Rack app under Puma on localhost. Keep-alives are disabled: WebKit
95
+ # (the webview) reuses connections Puma has already closed, which shows up as
96
+ # intermittent "network connection was lost" asset failures.
97
+ def serve_rack(app, port:)
98
+ require "puma"
99
+ require "puma/configuration"
100
+ require "puma/launcher"
101
+
102
+ configuration = ::Puma::Configuration.new do |c|
103
+ c.bind "tcp://127.0.0.1:#{port}"
104
+ c.app app
105
+ c.environment ENV.fetch("RACK_ENV", "production")
106
+ c.workers 0
107
+ c.threads 1, 5
108
+ c.enable_keep_alives false if c.respond_to?(:enable_keep_alives)
109
+ end
110
+ ::Puma::Launcher.new(configuration).run
111
+ end
112
+
113
+ # ---- build/dev (CLI side) ------------------------------------------------
114
+
115
+ class Rails < Framework
116
+ def name = "rails"
117
+
118
+ # CLI
119
+ def dev_command
120
+ File.exist?(File.join(root, "bin", "dev")) ? "bin/dev" : "bundle exec rails server"
121
+ end
122
+
123
+ def precompile_env
124
+ { "RAILS_ENV" => "production", "SECRET_KEY_BASE" => "precompile-only" }
125
+ end
126
+
127
+ def precompile_command = "bin/rails assets:precompile"
128
+
129
+ def cleanup_command = "bin/rails assets:clobber"
130
+
131
+ # Runtime
132
+ def prepare_runtime_env
133
+ ENV["RAILS_ENV"] ||= "production"
134
+ ENV["SOLID_QUEUE_IN_PUMA"] ||= "1"
135
+ end
136
+
137
+ def boot!(port:)
138
+ # Normally defined by bin/rails; the server command requires it.
139
+ ::Object.const_set(:APP_PATH, File.join(root, "config/application")) unless defined?(::APP_PATH)
140
+ require File.join(root, "config/environment")
141
+ Everywhere::Database.prepare!(root)
142
+
143
+ require "rails/command"
144
+ ::Rails::Command.invoke :server, ["--binding=127.0.0.1", "--port=#{port}"]
145
+ end
146
+
147
+ # Install
148
+ def env_exports = { "RAILS_ENV" => "production" }
149
+
150
+ # Rails loads its bundle through config/boot.rb (with bootsnap guarded).
151
+ def boot_stub
152
+ render_boot_stub(env_exports, "require_relative \"config/boot\"")
153
+ end
154
+ end
155
+
156
+ # Plain Rack frameworks (Sinatra, Hanami) — same packaged boot path.
157
+ class RackFramework < Framework
158
+ def prepare_runtime_env
159
+ ENV["RACK_ENV"] ||= "production"
160
+ end
161
+
162
+ def boot!(port:)
163
+ app = load_rack_app # loads app files (and any Everywhere.configure)
164
+ Everywhere::Database.prepare!(root)
165
+ serve_rack(app, port: port)
166
+ end
167
+
168
+ def precompile_env = {}
169
+ def cleanup_command = "true"
170
+ end
171
+
172
+ class Sinatra < RackFramework
173
+ def name = "sinatra"
174
+
175
+ def dev_command
176
+ File.exist?(File.join(root, "bin", "dev")) ? "bin/dev" : "bundle exec rackup --host 127.0.0.1"
177
+ end
178
+
179
+ # No asset pipeline by default; nothing to precompile.
180
+ def precompile_command = "true"
181
+
182
+ def env_exports = { "RACK_ENV" => "production" }
183
+ end
184
+
185
+ class Hanami < RackFramework
186
+ def name = "hanami"
187
+
188
+ def dev_command = "bundle exec hanami dev"
189
+
190
+ def precompile_command = "bundle exec hanami assets compile"
191
+
192
+ def env_exports = { "HANAMI_ENV" => "production", "RACK_ENV" => "production" }
193
+ end
194
+ end
195
+
196
+ class Error < StandardError; end
197
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # Shared source-exclusion rules, read from `.everywhereignore` (dockerignore
5
+ # style) when present, else a sensible default. ONE source of truth so the
6
+ # build receipt's source checksum (Receipt) and the Platform snapshot
7
+ # (`every platform build`, later) describe exactly the same set of files — a
8
+ # checksum that didn't match what actually ships would be worse than none.
9
+ #
10
+ # Pattern semantics are a pragmatic subset of .dockerignore/.gitignore:
11
+ # dist → a path segment named "dist", anywhere
12
+ # vendor/bundle → that exact path (and everything under it)
13
+ # *.log → glob against the full path and the basename
14
+ # !keep/this.log → NEGATION: re-include a file an earlier pattern excluded
15
+ #
16
+ # Rules are evaluated top to bottom and the LAST one that matches a path wins
17
+ # (dockerignore semantics), so ordering matters: put `!` re-includes after the
18
+ # broad exclude they carve out of.
19
+ class Ignore
20
+ FILE = ".everywhereignore"
21
+ FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH
22
+
23
+ # Build products, caches, VCS, local state, and secrets. Excluding `.env*`
24
+ # and the key/cert family keeps signing material out of both the checksum
25
+ # and any future snapshot.
26
+ DEFAULTS = %w[
27
+ dist tmp log node_modules .git storage .bundle vendor/bundle
28
+ coverage public/assets public/packs .DS_Store .env* *.log
29
+ *.key *.pem *.crt *.p12 *.mobileprovision
30
+ ].freeze
31
+
32
+ def self.for(root)
33
+ path = File.join(root, FILE)
34
+ source = File.exist?(path) ? File.read(path) : DEFAULTS.join("\n")
35
+ new(parse(source))
36
+ end
37
+
38
+ # → [{ negated:, pattern: }, ...] in file order. Blank lines and whole-line
39
+ # `#` comments are dropped; a leading `!` marks a re-include.
40
+ def self.parse(text)
41
+ text.lines.filter_map do |raw|
42
+ line = raw.chomp
43
+ next if line.strip.empty? || line.lstrip.start_with?("#")
44
+
45
+ negated = line.start_with?("!")
46
+ pattern = (negated ? line[1..] : line).strip.sub(%r{\A/}, "").chomp("/")
47
+ { negated: negated, pattern: pattern } unless pattern.empty?
48
+ end
49
+ end
50
+
51
+ attr_reader :rules
52
+
53
+ def initialize(rules)
54
+ @rules = rules
55
+ end
56
+
57
+ # Every non-ignored regular file under root, as sorted root-relative paths.
58
+ def files(root)
59
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: root)
60
+ .reject { |rel| ignored?(rel) }
61
+ .select { |rel| File.file?(File.join(root, rel)) }
62
+ .sort
63
+ end
64
+
65
+ # Last matching rule wins; a negated match re-includes. Default: kept.
66
+ def ignored?(rel)
67
+ base = rel.split("/").last
68
+ return true if [".", ".."].include?(base)
69
+
70
+ @rules.reduce(false) do |ignored, rule|
71
+ match?(rule[:pattern], rel, base) ? !rule[:negated] : ignored
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def match?(pat, rel, base)
78
+ rel == pat ||
79
+ rel.start_with?("#{pat}/") ||
80
+ (!pat.include?("/") && rel.split("/").include?(pat)) ||
81
+ File.fnmatch?(pat, rel, FLAGS) ||
82
+ File.fnmatch?(pat, base, FLAGS)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,174 @@
1
+ // @rubyeverywhere/bridge — one API for the browser, the RubyEverywhere desktop
2
+ // shell, and (soon) Hotwire Native mobile apps.
3
+ //
4
+ // App code writes to this surface only; platform differences live in the
5
+ // adapters below. Everything degrades: the same page works in a plain browser
6
+ // tab and gains native powers inside an app.
7
+ //
8
+ // import Everywhere from "@rubyeverywhere/bridge"
9
+ //
10
+ // Everywhere.platform // "desktop" | "mobile" | "browser"
11
+ // Everywhere.os // "macos" | "windows" | "linux" | "ios" | "android" | "chromeos" | "unknown"
12
+ // Everywhere.native // true inside a RubyEverywhere app
13
+ // Everywhere.notify({ title, body }) // native / web Notification / console
14
+ // Everywhere.confirm("Sure?") // Promise<boolean>, native dialog when possible
15
+ // Everywhere.on("menu", handler) // shell events; returns unsubscribe fn
16
+ // Everywhere.visit("/settings") // Turbo.visit with location fallback
17
+
18
+ const config =
19
+ (typeof window !== "undefined" && window.__EVERYWHERE_CONFIG__) || {}
20
+
21
+ function detectOS() {
22
+ if (config.os) return config.os // authoritative, injected by the desktop shell
23
+ if (typeof navigator === "undefined") return "unknown"
24
+ const ua = navigator.userAgent
25
+ if (/Hotwire Native iOS/i.test(ua)) return "ios"
26
+ if (/Hotwire Native Android/i.test(ua)) return "android"
27
+ if (/android/i.test(ua)) return "android"
28
+ if (/iPhone|iPad|iPod/.test(ua)) return "ios"
29
+ // iPadOS 13+ masquerades as a Mac; multitouch gives it away
30
+ if (/Mac/.test(ua) && navigator.maxTouchPoints > 1) return "ios"
31
+ if (/CrOS/.test(ua)) return "chromeos"
32
+ if (/Mac/.test(ua)) return "macos"
33
+ if (/Win/.test(ua)) return "windows"
34
+ if (/Linux/.test(ua)) return "linux"
35
+ return "unknown"
36
+ }
37
+
38
+ function detectPlatform() {
39
+ if (typeof window === "undefined") return "browser"
40
+ if (window.__TAURI__) return "desktop"
41
+ if (/Hotwire Native/i.test(navigator.userAgent)) return "mobile"
42
+ return "browser"
43
+ }
44
+
45
+ // --- desktop: the Tauri-based RubyEverywhere shell -------------------------
46
+
47
+ const desktop = {
48
+ notify({ title, body }) {
49
+ return window.__TAURI__.event.emit("everywhere:notify", { title, body })
50
+ },
51
+
52
+ confirm(message) {
53
+ return window.__TAURI__.dialog.ask(message, {
54
+ title: config.name || "Confirm",
55
+ kind: "warning"
56
+ })
57
+ },
58
+
59
+ on(event, handler) {
60
+ let unlisten = null
61
+ let active = true
62
+ window.__TAURI__.event
63
+ .listen(`everywhere:${event}`, (e) => handler(e.payload))
64
+ .then((fn) => (active ? (unlisten = fn) : fn()))
65
+ return () => {
66
+ active = false
67
+ if (unlisten) unlisten()
68
+ }
69
+ },
70
+
71
+ clipboardWrite(text) {
72
+ return window.__TAURI__.clipboardManager.writeText(text)
73
+ },
74
+
75
+ clipboardRead() {
76
+ return window.__TAURI__.clipboardManager.readText()
77
+ }
78
+ }
79
+
80
+ // --- browser: honest web fallbacks ------------------------------------------
81
+
82
+ const browser = {
83
+ async notify({ title, body }) {
84
+ if (!("Notification" in window)) {
85
+ console.log(`[everywhere] notify: ${title}${body ? ` — ${body}` : ""}`)
86
+ return
87
+ }
88
+ if (Notification.permission === "default") {
89
+ await Notification.requestPermission()
90
+ }
91
+ if (Notification.permission === "granted") {
92
+ new Notification(title, { body })
93
+ }
94
+ },
95
+
96
+ confirm(message) {
97
+ return Promise.resolve(window.confirm(message))
98
+ },
99
+
100
+ on(event, handler) {
101
+ const listener = (e) => handler(e.detail)
102
+ document.addEventListener(`everywhere:${event}`, listener)
103
+ return () => document.removeEventListener(`everywhere:${event}`, listener)
104
+ },
105
+
106
+ async clipboardWrite(text) {
107
+ if (navigator.clipboard) return navigator.clipboard.writeText(text)
108
+ console.log("[everywhere] clipboard unavailable (insecure context?)")
109
+ },
110
+
111
+ async clipboardRead() {
112
+ // Browsers gate reads hard (permission prompt, secure context, focus).
113
+ if (navigator.clipboard && navigator.clipboard.readText) {
114
+ try { return await navigator.clipboard.readText() } catch (_) { return null }
115
+ }
116
+ return null
117
+ }
118
+ }
119
+
120
+ // --- mobile: Hotwire Native (stub) ------------------------------------------
121
+ // Detection is real (Hotwire Native apps set their user agent); behavior falls
122
+ // back to the browser adapter until the bridge-component adapter lands. The
123
+ // native webview implements confirm() properly, so that fallback is already
124
+ // correct on mobile.
125
+
126
+ const mobile = {
127
+ ...browser
128
+ // TODO: notify via a Hotwire Native bridge component ("everywhere--notification")
129
+ // TODO: menu/tab events dispatched as document CustomEvents ("everywhere:menu")
130
+ }
131
+
132
+ // --- public surface ----------------------------------------------------------
133
+
134
+ const adapters = { desktop, browser, mobile }
135
+ const platform = detectPlatform()
136
+ const os = detectOS()
137
+ const adapter = adapters[platform]
138
+
139
+ export const Everywhere = {
140
+ platform,
141
+ os,
142
+
143
+ get native() {
144
+ return platform !== "browser"
145
+ },
146
+
147
+ notify(options = {}) {
148
+ return adapter.notify({ title: document.title, body: "", ...options })
149
+ },
150
+
151
+ confirm(message) {
152
+ return adapter.confirm(message)
153
+ },
154
+
155
+ on(event, handler) {
156
+ return adapter.on(event, handler)
157
+ },
158
+
159
+ visit(path) {
160
+ if (window.Turbo) window.Turbo.visit(path)
161
+ else window.location.assign(path)
162
+ },
163
+
164
+ clipboard: {
165
+ write(text) {
166
+ return adapter.clipboardWrite(text)
167
+ },
168
+ read() {
169
+ return adapter.clipboardRead()
170
+ }
171
+ }
172
+ }
173
+
174
+ export default Everywhere
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # Filesystem locations the CLI resolves relative to the gem itself, so
5
+ # `every dev`/`build`/`release` work in any project the gem is installed into.
6
+ module Paths
7
+ module_function
8
+
9
+ # The gem's own root — the dir holding lib/, exe/, support/. Correct whether
10
+ # the code runs from a git checkout or an installed gem.
11
+ def gem_root
12
+ File.expand_path("../..", __dir__)
13
+ end
14
+
15
+ # The generic Tauri shell vendored inside the gem: support/shell/ holds the
16
+ # src-tauri app and the splash/ dir it serves. This is the shell the CLI
17
+ # uses unless the caller overrides it with --shell-dir.
18
+ def bundled_shell_dir
19
+ File.join(gem_root, "support", "shell", "src-tauri")
20
+ end
21
+
22
+ # Absolute path to the shell's src-tauri directory, or nil if the vendored
23
+ # copy is missing (a corrupt/partial install).
24
+ def shell_dir
25
+ bundled_shell_dir if File.exist?(File.join(bundled_shell_dir, "tauri.conf.json"))
26
+ end
27
+
28
+ # Like #shell_dir but aborts with a helpful message when the shell is
29
+ # missing. The --shell-dir hint covers monorepo dev pointed at build-runner.
30
+ def shell_dir!
31
+ shell_dir or UI.die!("couldn't find the bundled shell at #{bundled_shell_dir}; " \
32
+ "reinstall ruby_everywhere or pass --shell-dir")
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require_relative "../version"
7
+
8
+ module Everywhere
9
+ # Talks to the RubyEverywhere Platform. Small JSON-over-HTTP wrapper — no gem
10
+ # dependency, so it works inside the packaged CLI too.
11
+ module Platform
12
+ # Production Platform API. rubyeverywhere.com is the marketing/docs site;
13
+ # the Platform lives on the `platform.` subdomain. Override for dev with
14
+ # --url or $EVERYWHERE_PLATFORM_URL (e.g. http://localhost:3000).
15
+ DEFAULT_URL = "https://platform.rubyeverywhere.com"
16
+
17
+ # Best-effort browser open; returns false (never raises) if it can't.
18
+ def self.open_url(url)
19
+ cmd =
20
+ if RUBY_PLATFORM.include?("darwin") then ["open", url]
21
+ elsif RUBY_PLATFORM.match?(/mingw|mswin/) then ["cmd", "/c", "start", "", url]
22
+ else ["xdg-open", url]
23
+ end
24
+ system(*cmd, out: File::NULL, err: File::NULL)
25
+ rescue StandardError
26
+ false
27
+ end
28
+
29
+ class Client
30
+ def self.base_url(explicit = nil)
31
+ (explicit || ENV["EVERYWHERE_PLATFORM_URL"] || DEFAULT_URL).chomp("/")
32
+ end
33
+
34
+ attr_reader :base_url
35
+
36
+ def initialize(base_url, token: nil)
37
+ @base_url = base_url
38
+ @base = URI.parse("#{base_url}/")
39
+ @token = token
40
+ end
41
+
42
+ def post(path, body = {}) = request(Net::HTTP::Post, path, body)
43
+ def patch(path, body = {}) = request(Net::HTTP::Patch, path, body)
44
+ def get(path) = request(Net::HTTP::Get, path)
45
+ def delete(path) = request(Net::HTTP::Delete, path)
46
+
47
+ # Stream a file with a raw PUT (Active Storage direct upload). `url` is
48
+ # absolute (from the reservation); `headers` come from the reservation too.
49
+ # Returns the Net::HTTP response.
50
+ def put_file(url, path, headers: {})
51
+ uri = URI.parse(url)
52
+ req = Net::HTTP::Put.new(uri)
53
+ headers.each { |k, v| req[k] = v }
54
+ req["Content-Length"] = File.size(path).to_s
55
+ File.open(path, "rb") do |io|
56
+ req.body_stream = io
57
+ http(uri) { |conn| conn.request(req) }
58
+ end
59
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
60
+ raise Error, "upload to #{uri.host} failed (#{e.class})"
61
+ end
62
+
63
+ # GET a (possibly redirecting, signed) URL and stream it to dest. Sends the
64
+ # bearer token only to the platform host (the first hop when the URL is a
65
+ # /runner or /cli endpoint); a redirect to a signed blob/S3 URL is followed
66
+ # without it.
67
+ def download(url, dest, redirects: 5)
68
+ uri = URI.parse(url)
69
+ req = Net::HTTP::Get.new(uri)
70
+ req["Authorization"] = "Bearer #{@token}" if @token && uri.host == @base.host && uri.port == @base.port
71
+ http(uri) do |conn|
72
+ conn.request(req) do |res|
73
+ case res
74
+ when Net::HTTPRedirection
75
+ raise Error, "too many redirects downloading #{url}" if redirects <= 0
76
+
77
+ return download(res["location"], dest, redirects: redirects - 1)
78
+ when Net::HTTPSuccess
79
+ File.open(dest, "wb") { |f| res.read_body { |chunk| f.write(chunk) } }
80
+ else
81
+ raise Error, "download failed (HTTP #{res.code})"
82
+ end
83
+ end
84
+ end
85
+ dest
86
+ end
87
+
88
+ private
89
+
90
+ def http(uri, &block)
91
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", &block)
92
+ end
93
+
94
+ def request(klass, path, body = nil)
95
+ uri = URI.join(@base, path.sub(%r{\A/}, ""))
96
+ req = klass.new(uri)
97
+ req["Accept"] = "application/json"
98
+ req["User-Agent"] = "every/#{Everywhere::VERSION}"
99
+ req["Authorization"] = "Bearer #{@token}" if @token
100
+ if body
101
+ req["Content-Type"] = "application/json"
102
+ req.body = JSON.generate(body)
103
+ end
104
+
105
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }
106
+ Response.new(res.code.to_i, parse(res.body))
107
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
108
+ raise Error, "couldn't reach the Platform at #{@base_url} (#{e.class})"
109
+ end
110
+
111
+ def parse(body)
112
+ return {} if body.to_s.empty?
113
+
114
+ JSON.parse(body)
115
+ rescue JSON::ParserError
116
+ {}
117
+ end
118
+
119
+ Response = Struct.new(:status, :body) do
120
+ def ok? = status.between?(200, 299)
121
+ def unauthorized? = status == 401
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Everywhere
7
+ module Platform
8
+ # Local credential store at ~/.config/everywhere/credentials.json (0600),
9
+ # keyed by Platform base URL so a dev localhost login and a production login
10
+ # coexist. Each entry holds an org-scoped token + the org it's bound to.
11
+ #
12
+ # { "https://platform.rubyeverywhere.com": {
13
+ # "token": "rbe_…",
14
+ # "organization": { "id": "org_…", "name": "Acme", "slug": "acme" } } }
15
+ class Credentials
16
+ def self.path
17
+ ENV["EVERYWHERE_CREDENTIALS"] || File.join(config_home, "everywhere", "credentials.json")
18
+ end
19
+
20
+ def self.config_home
21
+ ENV["XDG_CONFIG_HOME"].to_s.empty? ? File.expand_path("~/.config") : ENV["XDG_CONFIG_HOME"]
22
+ end
23
+
24
+ def self.load
25
+ data =
26
+ if File.exist?(path)
27
+ JSON.parse(File.read(path)) rescue {}
28
+ else
29
+ {}
30
+ end
31
+ new(data.is_a?(Hash) ? data : {})
32
+ end
33
+
34
+ def initialize(data)
35
+ @data = data
36
+ end
37
+
38
+ def for(url)
39
+ @data[normalize(url)]
40
+ end
41
+
42
+ def logged_in?(url)
43
+ !self.for(url).nil?
44
+ end
45
+
46
+ def set(url, entry)
47
+ @data[normalize(url)] = entry
48
+ save
49
+ end
50
+
51
+ def delete(url)
52
+ removed = @data.delete(normalize(url))
53
+ save
54
+ removed
55
+ end
56
+
57
+ private
58
+
59
+ def normalize(url) = url.to_s.chomp("/")
60
+
61
+ def save
62
+ path = self.class.path
63
+ dir = File.dirname(path)
64
+ FileUtils.mkdir_p(dir)
65
+ File.chmod(0o700, dir)
66
+ File.write(path, "#{JSON.pretty_generate(@data)}\n")
67
+ File.chmod(0o600, path)
68
+ end
69
+ end
70
+ end
71
+ end