kyper 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 48a5c2c1884d61917d62514a4cdedab308dce0c15cab578d5b9ca1d34d80b823
4
+ data.tar.gz: 1a6814b989f4237596c2848fd366b4f92d2a9c6be71e73c0b9fd9d0bb6bada11
5
+ SHA512:
6
+ metadata.gz: 4fee94e7ae2d97f3376a4e58904de70c527522ce7aa147bf917f3700f23b0f35ad8f01ca9bf4ea754425f15cd2c9e670b18da846e32fd56d08c10ba770d711b9
7
+ data.tar.gz: 5da228cff5fc62b9ea4ace2597f650b1e757a11ffa61391ee348d1a270ba6900d5b1aea2c4578cdbb219765413e5085484a0649f6bf3d9336af0d56a95d37ecf
data/exe/kyper ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.join(__dir__, "../lib")
5
+ require "kyper_cli"
6
+ KyperCli::CLI.start(ARGV)
@@ -0,0 +1,71 @@
1
+ module KyperCli
2
+ class CLI < Thor
3
+ include Thor::Actions
4
+
5
+ package_name "kyper"
6
+
7
+ desc "login", "Authenticate with your Kyper account"
8
+ def login
9
+ Commands::Login.new.login
10
+ end
11
+
12
+ desc "init", "Create a kyper.yml in the current directory"
13
+ def init
14
+ Commands::Init.new.init
15
+ end
16
+
17
+ desc "push", "Validate, package, and submit the current version for review"
18
+ def push
19
+ Commands::Push.new.push
20
+ end
21
+
22
+ desc "validate", "Validate kyper.yml without pushing"
23
+ def validate
24
+ passed = Commands::Validate.new.validate
25
+ exit 1 unless passed
26
+ end
27
+
28
+ desc "status", "Show the review status of your app"
29
+ def status
30
+ Commands::Status.new.status
31
+ end
32
+
33
+ desc "logs", "Show the build log for the latest version"
34
+ def logs
35
+ Commands::Logs.new.logs
36
+ end
37
+
38
+ desc "retry", "Retry a failed build"
39
+ def retry
40
+ Commands::RetryBuild.new.retry_build
41
+ end
42
+
43
+ desc "whoami", "Show the currently authenticated user"
44
+ def whoami
45
+ token = Config.api_token
46
+ unless token
47
+ say "Not logged in. Run `kyper login`.", :red
48
+ exit 1
49
+ end
50
+ user = Client.new.me
51
+ say "#{user["email"]} (#{user["role"]})"
52
+ rescue KyperCli::Error => e
53
+ say "Error: #{e.message}", :red
54
+ exit 1
55
+ end
56
+
57
+ desc "env SUBCOMMAND", "Manage environment variables declared by your app"
58
+ subcommand "env", Commands::Env
59
+
60
+ desc "versions SUBCOMMAND", "Manage app versions"
61
+ subcommand "versions", Commands::Versions
62
+
63
+ map %w[--version -v] => :version
64
+ desc "version", "Print the kyper CLI version"
65
+ def version
66
+ say KyperCli::VERSION
67
+ end
68
+
69
+ def self.exit_on_failure? = true
70
+ end
71
+ end
@@ -0,0 +1,139 @@
1
+ require "faraday/multipart"
2
+
3
+ module KyperCli
4
+ class Client
5
+ def self.new_unauthenticated
6
+ new(nil)
7
+ end
8
+
9
+ def initialize(token = Config.api_token)
10
+ @token = token
11
+ end
12
+
13
+ def me
14
+ get("/api/v1/me")
15
+ end
16
+
17
+ def create_app(params)
18
+ post("/api/v1/apps", app: params)
19
+ end
20
+
21
+ def push_version(slug, kyper_yml_content, zip_path: nil)
22
+ if zip_path
23
+ response = multipart_connection.post("/api/v1/apps/#{slug}/versions") do |req|
24
+ req.headers["Authorization"] = "Bearer #{@token}"
25
+ req.body = {
26
+ source_zip: Faraday::Multipart::FilePart.new(zip_path, "application/zip"),
27
+ kyper_yml: kyper_yml_content
28
+ }
29
+ end
30
+ handle(response)
31
+ else
32
+ post("/api/v1/apps/#{slug}/versions", { kyper_yml: kyper_yml_content })
33
+ end
34
+ end
35
+
36
+ def app_status(slug)
37
+ get("/api/v1/apps/#{slug}/status")
38
+ end
39
+
40
+ def app_env(slug)
41
+ get("/api/v1/apps/#{slug}/env")
42
+ end
43
+
44
+ # cursor: byte offset of last-seen log content (0 = from start)
45
+ def version_build_log(version_id, cursor: 0)
46
+ get("/api/v1/versions/#{version_id}/build_log?cursor=#{cursor}")
47
+ end
48
+
49
+ def version_retry(version_id)
50
+ post("/api/v1/versions/#{version_id}/retry")
51
+ end
52
+
53
+ def version_withdraw(version_id)
54
+ delete("/api/v1/versions/#{version_id}")
55
+ end
56
+
57
+ # Device auth — unauthenticated calls used by `kyper login`
58
+ def request_device_code
59
+ post_unauthenticated("/api/v1/device/authorize")
60
+ end
61
+
62
+ def poll_device_token(code)
63
+ get_unauthenticated("/api/v1/device/token?code=#{code}")
64
+ end
65
+
66
+ private
67
+
68
+ def get(path)
69
+ response = connection.get(path)
70
+ handle(response)
71
+ rescue Faraday::Error => e
72
+ raise KyperCli::Error, "Cannot reach Kyper — check your connection (#{e.message})"
73
+ end
74
+
75
+ def post(path, body = {})
76
+ response = connection.post(path) do |req|
77
+ req.headers["Content-Type"] = "application/json"
78
+ req.body = body.to_json
79
+ end
80
+ handle(response)
81
+ rescue Faraday::Error => e
82
+ raise KyperCli::Error, "Cannot reach Kyper — check your connection (#{e.message})"
83
+ end
84
+
85
+ def delete(path)
86
+ response = connection.delete(path)
87
+ handle(response)
88
+ rescue Faraday::Error => e
89
+ raise KyperCli::Error, "Cannot reach Kyper — check your connection (#{e.message})"
90
+ end
91
+
92
+ def get_unauthenticated(path)
93
+ response = unauthenticated_connection.get(path)
94
+ handle(response)
95
+ end
96
+
97
+ def post_unauthenticated(path, body = {})
98
+ response = unauthenticated_connection.post(path) do |req|
99
+ req.headers["Content-Type"] = "application/json"
100
+ req.body = body.to_json
101
+ end
102
+ handle(response)
103
+ end
104
+
105
+ def handle(response)
106
+ body = JSON.parse(response.body) rescue response.body
107
+ raise Error, body["error"] || body.to_s if response.status >= 400
108
+ body
109
+ end
110
+
111
+ def connection
112
+ @connection ||= Faraday.new(url: Config.api_host) do |f|
113
+ f.headers["Authorization"] = "Bearer #{@token}" if @token
114
+ f.headers["Accept"] = "application/json"
115
+ f.request :url_encoded
116
+ f.adapter Faraday.default_adapter
117
+ end
118
+ end
119
+
120
+ def multipart_connection
121
+ @multipart_connection ||= Faraday.new(url: Config.api_host) do |f|
122
+ f.headers["Authorization"] = "Bearer #{@token}" if @token
123
+ f.headers["Accept"] = "application/json"
124
+ f.request :multipart
125
+ f.adapter Faraday.default_adapter
126
+ end
127
+ end
128
+
129
+ def unauthenticated_connection
130
+ @unauthenticated_connection ||= Faraday.new(url: Config.api_host) do |f|
131
+ f.headers["Accept"] = "application/json"
132
+ f.request :url_encoded
133
+ f.adapter Faraday.default_adapter
134
+ end
135
+ end
136
+ end
137
+
138
+ Error = Class.new(StandardError)
139
+ end
@@ -0,0 +1,38 @@
1
+ module KyperCli
2
+ module Commands
3
+ class Env < Thor
4
+ include Helpers
5
+
6
+ desc "list", "List environment variables declared by your app"
7
+ def list
8
+ # Try local kyper.yml first (works offline)
9
+ yml = Config.kyper_yml
10
+ env_keys = yml&.dig("env")
11
+
12
+ if env_keys.nil? && !Config.api_token.to_s.empty?
13
+ # Fall back to API if kyper.yml has no env section but we're logged in
14
+ slug = to_slug(yml["name"]) if yml
15
+ env_keys = Client.new.app_env(slug)["env"] if slug
16
+ end
17
+
18
+ env_keys = Array(env_keys)
19
+
20
+ say ""
21
+ if env_keys.empty?
22
+ say " No environment variables declared.", :yellow
23
+ say " Add an \"env:\" section to kyper.yml to declare required env vars."
24
+ else
25
+ say " Environment variables:"
26
+ say ""
27
+ env_keys.each { |key| say " #{key}" }
28
+ say ""
29
+ say " Consumers set these values when configuring their deployment.", :cyan
30
+ end
31
+ say ""
32
+ rescue KyperCli::Error => e
33
+ say "Error: #{e.message}", :red
34
+ exit 1
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,22 @@
1
+ module KyperCli
2
+ module Commands
3
+ module Helpers
4
+ def require_auth!
5
+ return if !Config.api_token.to_s.empty?
6
+ say "Error: Not logged in. Run `kyper login` first.", :red
7
+ exit 1
8
+ end
9
+
10
+ def load_kyper_yml!
11
+ yml = Config.kyper_yml
12
+ return yml if yml
13
+ say "Error: No kyper.yml found. Run `kyper init` to create one.", :red
14
+ exit 1
15
+ end
16
+
17
+ def to_slug(name)
18
+ name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,317 @@
1
+ require "yaml"
2
+ require "json"
3
+ require "tty-prompt"
4
+
5
+ module KyperCli
6
+ module Commands
7
+ class Init < Thor
8
+ KNOWN_DEPS = %w[postgres mysql redis elasticsearch opensearch].freeze
9
+ DB_DEPS = %w[postgres mysql].freeze
10
+
11
+ STACK_DEPLOY_HOOKS = {
12
+ rails: "bundle exec rails db:migrate",
13
+ django: "python manage.py migrate",
14
+ prisma: "npx prisma migrate deploy"
15
+ }.freeze
16
+
17
+ IMAGE_TO_DEP = {
18
+ "postgres" => "postgres",
19
+ "mysql" => "mysql",
20
+ "redis" => "redis",
21
+ "elasticsearch" => "elasticsearch",
22
+ "opensearch" => "opensearch"
23
+ }.freeze
24
+
25
+ CATEGORIES = {
26
+ "developer_tools" => "Developer Tools",
27
+ "productivity" => "Productivity",
28
+ "finance" => "Finance",
29
+ "health" => "Health",
30
+ "media" => "Media",
31
+ "education" => "Education",
32
+ "business_operations" => "Business Operations",
33
+ "data_analytics" => "Data & Analytics",
34
+ "gaming" => "Gaming"
35
+ }.freeze
36
+
37
+ TEMPLATE = <<~YAML
38
+ name: "%<name>s"
39
+ version: "0.1.0"
40
+ category: %<category>s
41
+ description: "A short description of your app"
42
+
43
+ docker:
44
+ dockerfile: "./Dockerfile" # Kyper builds from source — no registry account needed
45
+
46
+ # Define your process types. "web" is required and must bind to $PORT.
47
+ processes:
48
+ %<processes>s
49
+ # Managed backing services. Allowed values: #{KNOWN_DEPS.join(", ")}
50
+ deps:%<deps>s
51
+ %<hooks>s%<healthcheck>s%<pricing>s
52
+ resources:
53
+ min_memory_mb: 512
54
+ min_cpu: 1
55
+ YAML
56
+
57
+ desc "init", "Create a kyper.yml in the current directory"
58
+ def init
59
+ if File.exist?(Config::KYPER_FILE)
60
+ say " kyper.yml already exists. Edit it directly.", :yellow
61
+ exit 0
62
+ end
63
+
64
+ prompt = TTY::Prompt.new(interrupt: :exit)
65
+
66
+ say ""
67
+ say " Kyper — New App Setup"
68
+ say " " + ("─" * 30)
69
+ say ""
70
+
71
+ # App name
72
+ default_name = File.basename(Dir.pwd)
73
+ name = prompt.ask(" App name:", default: default_name) { |q| q.required(true) }.strip
74
+
75
+ # Category (arrow-key selection)
76
+ category = prompt.select(" Category:", CATEGORIES.invert, per_page: CATEGORIES.size, cycle: true)
77
+
78
+ # Detect processes from Procfile
79
+ processes = detect_processes
80
+ if processes.any? && processes != { "web" => "bundle exec puma -C config/puma.rb" }
81
+ say ""
82
+ say " Auto-detected from Procfile:", :cyan
83
+ processes.each { |k, v| say " ✔ #{k}: #{v}", :cyan }
84
+ keep = prompt.yes?(" Use these processes?", default: true)
85
+ processes = { "web" => "bundle exec puma -C config/puma.rb" } unless keep
86
+ end
87
+
88
+ # Detect deps from docker-compose
89
+ deps = detect_deps
90
+ if deps.any?
91
+ say ""
92
+ say " Auto-detected from docker-compose:", :cyan
93
+ deps.each { |d| say " ✔ #{d}", :cyan }
94
+ keep = prompt.yes?(" Include these dependencies?", default: true)
95
+ deps = [] unless keep
96
+ end
97
+
98
+ # Suggest dep versions from lockfiles
99
+ dep_versions = suggest_dep_versions(deps)
100
+ if dep_versions.any?
101
+ say ""
102
+ say " Suggested dep versions (from project lockfiles):", :cyan
103
+ dep_versions.each { |dep, ver| say " ✔ #{dep}: \"#{ver}\"", :cyan }
104
+ end
105
+
106
+ # Deploy hooks (only prompted when a DB dep is present)
107
+ hooks_block = prompt_hooks(prompt, deps)
108
+
109
+ # Health check path
110
+ healthcheck_block = prompt_healthcheck(prompt)
111
+
112
+ # Pricing
113
+ say ""
114
+ one_time_raw = prompt.ask(" One-time price in USD (leave blank to skip):") do |q|
115
+ q.convert(:float?) { |v| v.to_s.strip.empty? ? nil : v.to_f }
116
+ end
117
+ sub_raw = prompt.ask(" Monthly subscription price in USD (leave blank to skip):") do |q|
118
+ q.convert(:float?) { |v| v.to_s.strip.empty? ? nil : v.to_f }
119
+ end
120
+
121
+ pricing_lines = build_pricing_block(one_time_raw, sub_raw)
122
+
123
+ content = format(TEMPLATE,
124
+ name: name,
125
+ category: category,
126
+ processes: format_processes(processes),
127
+ deps: format_deps(deps, dep_versions),
128
+ hooks: hooks_block,
129
+ healthcheck: healthcheck_block,
130
+ pricing: pricing_lines)
131
+
132
+ # Preview before writing
133
+ say ""
134
+ say " Generated kyper.yml preview:", :cyan
135
+ say " " + ("─" * 40)
136
+ content.each_line { |line| say " #{line}" }
137
+ say " " + ("─" * 40)
138
+ say ""
139
+
140
+ write = prompt.yes?(" Write kyper.yml?", default: true)
141
+ unless write
142
+ say " Aborted — nothing written.", :yellow
143
+ say ""
144
+ exit 0
145
+ end
146
+
147
+ File.write(Config::KYPER_FILE, content)
148
+
149
+ say ""
150
+ say " ✔ kyper.yml created!", :green
151
+ say ""
152
+ say " Next steps:", :cyan
153
+ say " 1. Add a Dockerfile if you don't have one"
154
+ say " 2. Run `kyper validate` to check your config"
155
+ say " 3. Run `kyper push` to submit for review"
156
+ say ""
157
+ rescue TTY::Reader::InputInterrupt
158
+ say ""
159
+ say " Aborted.", :yellow
160
+ say ""
161
+ exit 0
162
+ end
163
+
164
+ private
165
+
166
+ def build_pricing_block(one_time, subscription)
167
+ return "pricing:\n one_time: 49.00 # USD — omit to disable\n # subscription: 12.00 # USD/month — omit to disable\n" if one_time.nil? && subscription.nil?
168
+
169
+ lines = [ "pricing:" ]
170
+ lines << " one_time: #{one_time}" if one_time
171
+ lines << " subscription: #{subscription}" if subscription
172
+ lines.join("\n") + "\n"
173
+ end
174
+
175
+ def detect_processes
176
+ return { "web" => "bundle exec puma -C config/puma.rb" } unless File.exist?("Procfile")
177
+
178
+ processes = {}
179
+ File.each_line("Procfile") do |line|
180
+ line.chomp!
181
+ next if line.strip.empty? || line.start_with?("#")
182
+
183
+ name, _, command = line.partition(":")
184
+ name = name.strip
185
+ command = command.strip
186
+ processes[name] = command if !name.empty? && !command.empty?
187
+ end
188
+
189
+ processes["web"] ||= "bundle exec puma -C config/puma.rb"
190
+ processes
191
+ end
192
+
193
+ def detect_deps
194
+ compose_file = %w[docker-compose.yml docker-compose.yaml compose.yml compose.yaml].find { |f| File.exist?(f) }
195
+ return [] unless compose_file
196
+
197
+ compose = YAML.safe_load(File.read(compose_file), permitted_classes: []) || {}
198
+ services = compose["services"] || {}
199
+
200
+ detected = []
201
+ services.each_value do |svc|
202
+ next unless svc.is_a?(Hash)
203
+
204
+ image = svc["image"].to_s.downcase
205
+ IMAGE_TO_DEP.each do |fragment, dep|
206
+ detected << dep if image.include?(fragment) && !detected.include?(dep)
207
+ end
208
+ end
209
+ detected
210
+ end
211
+
212
+ def format_processes(processes)
213
+ processes.map { |name, cmd| " #{name}: \"#{cmd}\"" }.join("\n")
214
+ end
215
+
216
+ def format_deps(deps, versions = {})
217
+ return "\n # - postgres\n # - redis" if deps.empty?
218
+
219
+ "\n" + deps.map { |d|
220
+ ver = versions[d]
221
+ ver ? " - #{d}: \"#{ver}\"" : " - #{d}"
222
+ }.join("\n")
223
+ end
224
+
225
+ # Suggests dep major version pins based on detected project lockfiles.
226
+ # Returns a hash of {dep_name => version_string} for deps with clear signals.
227
+ def suggest_dep_versions(deps)
228
+ versions = {}
229
+ has_gemfile_lock = File.exist?("Gemfile.lock")
230
+
231
+ deps.each do |dep|
232
+ case dep
233
+ when "postgres"
234
+ # Rails (Gemfile.lock) and Node+Prisma apps default to Postgres 16
235
+ versions["postgres"] = "16" if has_gemfile_lock || prisma_project?
236
+ when "mysql"
237
+ versions["mysql"] = "8"
238
+ end
239
+ end
240
+
241
+ versions
242
+ end
243
+
244
+ # Prompts for a deploy hook command when the app has a database dep.
245
+ # Returns a formatted YAML block string (empty string if no hook configured).
246
+ def prompt_hooks(prompt, deps)
247
+ return "" unless (deps & DB_DEPS).any?
248
+
249
+ say ""
250
+ needs_hook = prompt.yes?(
251
+ " Does your app need a deploy command (e.g. database migrations)?",
252
+ default: true
253
+ )
254
+ return "" unless needs_hook
255
+
256
+ stack = detect_stack
257
+ suggest = STACK_DEPLOY_HOOKS[stack]
258
+
259
+ command = if suggest
260
+ say " Auto-detected: #{suggest}", :cyan
261
+ use_it = prompt.yes?(" Use this deploy command?", default: true)
262
+ use_it ? suggest : prompt.ask(" Deploy command:", required: true)&.strip
263
+ else
264
+ prompt.ask(" Deploy command (e.g. bundle exec rails db:migrate):", required: true)&.strip
265
+ end
266
+
267
+ return "" if command.to_s.empty?
268
+
269
+ "hooks:\n on_deploy: \"#{command}\"\n on_update: \"#{command}\"\n\n"
270
+ end
271
+
272
+ # Detects the likely application stack from files in the working directory.
273
+ # Prompts for a health check path. Returns an empty string when the developer
274
+ # accepts the default (/up) to keep the YAML minimal.
275
+ def prompt_healthcheck(prompt)
276
+ suggested = stack_health_path
277
+ say ""
278
+ path = prompt.ask(
279
+ " Health check path:",
280
+ default: suggested
281
+ ) { |q| q.required(true) }.strip
282
+
283
+ return "" if path == "/up"
284
+
285
+ "healthcheck:\n path: \"#{path}\"\n\n"
286
+ end
287
+
288
+ # Returns the conventional health path for the detected stack.
289
+ def stack_health_path
290
+ stack = detect_stack
291
+ case stack
292
+ when :django then "/health/"
293
+ when :rails then "/up"
294
+ else
295
+ # Node/Express/Fastify and unknown stacks default to /health
296
+ File.exist?("package.json") ? "/health" : "/up"
297
+ end
298
+ end
299
+
300
+ def detect_stack
301
+ return :prisma if prisma_project?
302
+ return :django if File.exist?("manage.py")
303
+ return :rails if File.exist?("Gemfile")
304
+
305
+ nil
306
+ end
307
+
308
+ def prisma_project?
309
+ return false unless File.exist?("package.json")
310
+
311
+ json = JSON.parse(File.read("package.json")) rescue {}
312
+ !json.dig("dependencies", "@prisma/client").nil? ||
313
+ !json.dig("devDependencies", "prisma").nil?
314
+ end
315
+ end
316
+ end
317
+ end
@@ -0,0 +1,83 @@
1
+ module KyperCli
2
+ module Commands
3
+ class Login < Thor
4
+ desc "login", "Authenticate with your Kyper account via browser"
5
+ def login
6
+ client = Client.new_unauthenticated
7
+
8
+ spinner = TTY::Spinner.new("[:spinner] Requesting authorization code…", format: :dots)
9
+ spinner.auto_spin
10
+
11
+ grant = client.request_device_code
12
+ code = grant["code"]
13
+ uri = grant["verification_uri"]
14
+
15
+ spinner.success("Done")
16
+
17
+ say ""
18
+ say "Open this URL in your browser to authorize the CLI:", :cyan
19
+ say " #{uri}", :bold
20
+ say ""
21
+
22
+ open_browser(uri)
23
+
24
+ say "Waiting for authorization", :cyan
25
+ poll_spinner = TTY::Spinner.new("[:spinner] Polling…", format: :dots)
26
+ poll_spinner.auto_spin
27
+
28
+ deadline = Time.now + 300 # 5 minutes
29
+ token = nil
30
+
31
+ loop do
32
+ if Time.now > deadline
33
+ poll_spinner.error("Timed out")
34
+ say "Authorization timed out. Please run `kyper login` again.", :red
35
+ exit 1
36
+ end
37
+
38
+ begin
39
+ result = client.poll_device_token(code)
40
+ if result["api_token"]
41
+ token = result["api_token"]
42
+ break
43
+ end
44
+ rescue KyperCli::Error => e
45
+ if e.message.include?("not found") || e.message.include?("expired")
46
+ poll_spinner.error("Grant expired")
47
+ say "Authorization expired. Please run `kyper login` again.", :red
48
+ exit 1
49
+ end
50
+ # Other errors — keep polling
51
+ end
52
+
53
+ sleep 2
54
+ end
55
+
56
+ poll_spinner.success("Authorized")
57
+
58
+ Config.save("api_token" => token)
59
+
60
+ # Verify the token works
61
+ user = Client.new(token).me
62
+ say "Logged in as #{user["email"]} (#{user["role"]})", :green
63
+ rescue KyperCli::Error => e
64
+ say "Error: #{e.message}", :red
65
+ exit 1
66
+ end
67
+
68
+ private
69
+
70
+ no_commands do
71
+ def open_browser(uri)
72
+ if RUBY_PLATFORM =~ /darwin/
73
+ system("open", uri)
74
+ elsif RUBY_PLATFORM =~ /linux/
75
+ system("xdg-open", uri)
76
+ end
77
+ rescue StandardError
78
+ # Silently fail — user can copy-paste the URL
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end