browserctl 0.11.0 → 0.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f20046bbdcf3ff57a52790137144c5f1197b6d3f8651f250bf6feb1cc9988f7
4
- data.tar.gz: 1d928edc69e4691cc720b9bb7f32ea4001d1fd5df636a4af7fc59341f7714174
3
+ metadata.gz: 2a7eb052c2bbfc4e1f5afc24ba08c5b2b0d471a85d2acf6ea61a7f160cbe201b
4
+ data.tar.gz: 2d4259da9b4a13a50ad80e68d404f11eda89b6249e5315c58247c8d80514d21f
5
5
  SHA512:
6
- metadata.gz: 87be1521e16f71d8f77c699712fb03c7a339b2421102e6ad0d4bc9c1a0e860ff29882c81d0e5819a802b3364962922594bd9a08d80cdf5521e50ea09659dac98
7
- data.tar.gz: 29256e3d109bdfa651e8bea5fb82eef18fcb2b299c648db937015cebbfddd0d947e3e042b848adf32b61006f99d69150aaf38acf2a1356946c1584e2dc7a00b6
6
+ metadata.gz: ca34ca3125de0686417b06919a8cb0eaf3311eaedd7ebe6538c1f159941d737ca549f3d0c3d8a10da73146bd23602cb145ed010539a48de97c3d8552d4f317c9
7
+ data.tar.gz: 61c89f8a4b2e61076ddd5fa603710e226ebfe1e176914ffb37f47a79ee35a2161b3700406a35c878d6fdc6cb129f051f00943d080b636076f6d1452b6ee290b3
data/CHANGELOG.md CHANGED
@@ -10,6 +10,34 @@ All notable changes to this project will be documented in this file.
10
10
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
11
11
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
12
12
 
13
+ ## [0.12.0](https://github.com/patrick204nqh/browserctl/compare/v0.11.0...v0.12.0) (2026-05-10)
14
+
15
+
16
+ ### ⚠ BREAKING CHANGES
17
+
18
+ * Scripts that hard-coded `exit 7 = AUTH_REQUIRED` must update — AUTH_REQUIRED is now exit 3, and 7 is STATE_EXPIRED. Scripts treating any non-zero status as a generic failure are unaffected. Prior to this change the CLI exited 1 for everything except AUTH_REQUIRED (which already used 7); typed errors now surface their dedicated codes.
19
+
20
+ ### Features
21
+
22
+ * benchmark harness + bundle_codec target ([#154](https://github.com/patrick204nqh/browserctl/issues/154)) ([f4b815a](https://github.com/patrick204nqh/browserctl/commit/f4b815a617d4a7d22473e6028ce2a0f90467fa87))
23
+ * bundle format version ([#132](https://github.com/patrick204nqh/browserctl/issues/132)) ([b52d325](https://github.com/patrick204nqh/browserctl/commit/b52d32546d1b9ec3915fac014fecd8f929161ddb))
24
+ * crash reports ([#135](https://github.com/patrick204nqh/browserctl/issues/135)) ([b02adf4](https://github.com/patrick204nqh/browserctl/commit/b02adf4d41295dbe547d2965d838168d48efb8bf))
25
+ * error code enum ([#128](https://github.com/patrick204nqh/browserctl/issues/128)) ([feb0522](https://github.com/patrick204nqh/browserctl/commit/feb0522887935737fd96ad8a8b47d1407a5f585e))
26
+ * exit code map ([#140](https://github.com/patrick204nqh/browserctl/issues/140)) ([1a1d2b2](https://github.com/patrick204nqh/browserctl/commit/1a1d2b2f50693863cb508b5354e5546f340a2771))
27
+ * format version header convention ([#129](https://github.com/patrick204nqh/browserctl/issues/129)) ([405774d](https://github.com/patrick204nqh/browserctl/commit/405774dd6c99cc96f62f0c092010b30e7e710d41))
28
+ * migration helpers ([#143](https://github.com/patrick204nqh/browserctl/issues/143)) ([1c1762e](https://github.com/patrick204nqh/browserctl/commit/1c1762e755f83445c7cdd0df75fc5324a820fa19))
29
+ * recording format version ([#136](https://github.com/patrick204nqh/browserctl/issues/136)) ([4f1bc46](https://github.com/patrick204nqh/browserctl/commit/4f1bc467e653ecc1f508a85b3c329adf048b783d))
30
+ * structured error payload ([#137](https://github.com/patrick204nqh/browserctl/issues/137)) ([f028410](https://github.com/patrick204nqh/browserctl/commit/f02841069f68de749a76ba9a98a38b0c20836d76))
31
+ * structured JSONL logs ([#131](https://github.com/patrick204nqh/browserctl/issues/131)) ([c6daff4](https://github.com/patrick204nqh/browserctl/commit/c6daff488460606659c0641dd8cee6c23561be05))
32
+ * trace --redact ([#139](https://github.com/patrick204nqh/browserctl/issues/139)) ([19b4cec](https://github.com/patrick204nqh/browserctl/commit/19b4cec37daf95a06b0513780d991d0522069593))
33
+ * trace command ([#133](https://github.com/patrick204nqh/browserctl/issues/133)) ([156f304](https://github.com/patrick204nqh/browserctl/commit/156f304246d24fa6c3e06f8d941ac971629ea77c))
34
+ * workflow format version ([#138](https://github.com/patrick204nqh/browserctl/issues/138)) ([963776b](https://github.com/patrick204nqh/browserctl/commit/963776be785583fc3a3b0c04c97510e16c73ca9a))
35
+
36
+
37
+ ### Miscellaneous Chores
38
+
39
+ * target next release as 0.12.0 ([#156](https://github.com/patrick204nqh/browserctl/issues/156)) ([9797fcc](https://github.com/patrick204nqh/browserctl/commit/9797fcc0bae1a47bb72eec9cec5fda105e698406))
40
+
13
41
  ## [0.11.0](https://github.com/patrick204nqh/browserctl/compare/v0.10.0...v0.11.0) (2026-05-10)
14
42
 
15
43
 
data/README.md CHANGED
@@ -198,6 +198,7 @@ The daemon shuts itself down after 30 minutes of inactivity.
198
198
  | [Agent Integration](docs/guides/agent-integration.md) | Call browserctl from Python, shell, or Anthropic tool-use agents |
199
199
  | [Concepts](docs/concepts/) | Sessions, snapshots, [state](docs/concepts/state.md), [flows](docs/concepts/flows.md), human-in-the-loop |
200
200
  | [Guides](docs/guides/) | Writing workflows, handling challenges, smoke testing |
201
+ | [Debugging](docs/guides/debugging.md) | Read traces, redaction, crash reports, filing a good issue |
201
202
  | [Examples](examples/) | Runnable scripts: session reuse, Cloudflare HITL, and more |
202
203
  | [Command Reference](docs/reference/commands.md) | Every command and flag |
203
204
  | [API Stability](docs/reference/api-stability.md) | Wire protocol contract and stability zones |
data/bin/browserctl CHANGED
@@ -31,14 +31,29 @@ require "browserctl/commands/workflow"
31
31
  require "browserctl/commands/flow"
32
32
  require "browserctl/commands/dialog"
33
33
  require "browserctl/commands/ask"
34
+ require "browserctl/commands/trace"
35
+ require "browserctl/commands/migrate"
36
+
37
+ def structured_stderr_payload(res, message)
38
+ code = (res[:code] || res["code"] || Browserctl::Error::Codes::GENERIC).to_s
39
+ context = res[:context] || res["context"] || {}
40
+ action = res[:suggested_action] || res["suggested_action"] ||
41
+ Browserctl::Error::SuggestedActions.for(code)
42
+ [code, JSON.generate(code: code, message: message, context: context, suggested_action: action)]
43
+ end
34
44
 
35
45
  def print_result(res)
36
- if res.is_a?(Hash) && (res[:error] || res["error"])
37
- warn "Error: #{res[:error] || res['error']}"
46
+ unless res.is_a?(Hash) && (res[:error] || res["error"])
38
47
  puts res.to_json
39
- exit((res[:code] || res["code"]) == "AUTH_REQUIRED" ? 7 : 1)
48
+ return
40
49
  end
50
+
51
+ message = res[:error] || res["error"]
52
+ code, payload = structured_stderr_payload(res, message)
53
+ warn "Error: #{message}"
54
+ warn payload
41
55
  puts res.to_json
56
+ exit Browserctl::Error::ExitCodes.for(code)
42
57
  end
43
58
 
44
59
  def usage
@@ -129,6 +144,15 @@ def usage
129
144
  flow list
130
145
  flow describe <name>
131
146
 
147
+ Trace:
148
+ trace [<session>] Pretty timeline of CLI + daemon log events.
149
+
150
+ Migrate:
151
+ migrate <path> [--to-version N] [--dry-run]
152
+ # Upgrades a persisted artifact (.bctl / .jsonl recording / workflow .rb)
153
+ # using registered migrations. Empty registry in v0.12 — first real
154
+ # migration ships post-1.0 when a format actually changes.
155
+
132
156
  Daemon:
133
157
  daemon start [--headed] [--name NAME]
134
158
  daemon stop
@@ -149,6 +173,17 @@ daemon_name = if daemon_idx
149
173
  ARGV.delete_at(daemon_idx)
150
174
  end
151
175
 
176
+ log_level_idx = ARGV.index("--log-level") || ARGV.index("-l")
177
+ log_level = if log_level_idx
178
+ ARGV.delete_at(log_level_idx)
179
+ ARGV.delete_at(log_level_idx)
180
+ end || ENV["BROWSERCTL_LOG_LEVEL"] || "info"
181
+
182
+ # CLI invocations write structured JSON Lines to ~/.browserctl/logs/cli.log
183
+ # alongside the existing stderr behaviour. Stderr stays human-readable so
184
+ # scripted callers see no change.
185
+ Browserctl.logger = Browserctl.build_logger(log_level, component: "cli")
186
+
152
187
  cmd = ARGV.shift
153
188
  args = ARGV.dup
154
189
 
@@ -156,99 +191,113 @@ usage if cmd.nil? || %w[-h --help help].include?(cmd)
156
191
 
157
192
  runner = Browserctl::Runner.new
158
193
 
159
- case cmd
160
- when "workflow" then Browserctl::Commands::Workflow.run(runner, args)
161
- when "record" then Browserctl::Commands::Record.run(args)
162
- when "init" then Browserctl::Commands::Init.run(args)
163
- when "ask" then Browserctl::Commands::Ask.run(args)
194
+ begin
195
+ case cmd
196
+ when "workflow" then Browserctl::Commands::Workflow.run(runner, args)
197
+ when "record" then Browserctl::Commands::Record.run(args)
198
+ when "init" then Browserctl::Commands::Init.run(args)
199
+ when "ask" then Browserctl::Commands::Ask.run(args)
200
+ when "trace" then Browserctl::Commands::Trace.run(args)
201
+ when "migrate" then Browserctl::Commands::Migrate.run(args)
164
202
 
165
- else
166
- client = Browserctl::Client.new(Browserctl.socket_path(daemon_name))
203
+ else
204
+ client = Browserctl::Client.new(Browserctl.socket_path(daemon_name))
167
205
 
168
- case cmd
169
- when "flow" then Browserctl::Commands::Flow.run(client, args)
170
- when "page" then Browserctl::Commands::Page.run(client, args)
171
- when "cookie" then Browserctl::Commands::Cookie.run(client, args)
172
- when "storage" then Browserctl::Commands::Storage.run(client, args)
173
- when "session" then Browserctl::Commands::Session.run(client, args)
174
- when "state" then Browserctl::Commands::State.run(client, args)
175
- when "daemon" then Browserctl::Commands::Daemon.run(client, args)
176
- when "auth-check"
177
- page_name = args.shift or abort "usage: browserctl auth-check <page> [--cookies] [--state NAME] [--flow NAME]"
178
- include_cookies = args.delete("--cookies") ? true : false
179
- state_idx = args.index("--state")
180
- state_arg = if state_idx
181
- (args.delete_at(state_idx)
182
- args.delete_at(state_idx))
183
- end
184
- flow_idx = args.index("--flow")
185
- flow_arg = if flow_idx
186
- (args.delete_at(flow_idx)
187
- args.delete_at(flow_idx))
188
- end
189
- print_result(client.auth_check(page_name, include_cookies: include_cookies,
190
- state: state_arg, suggested_flow: flow_arg))
191
- when "navigate" then print_result(client.navigate(args[0], args[1]))
192
- when "fill" then Browserctl::Commands::Fill.run(client, args)
193
- when "click" then Browserctl::Commands::Click.run(client, args)
194
- when "snapshot" then Browserctl::Commands::Snapshot.run(client, args)
195
- when "screenshot" then Browserctl::Commands::Screenshot.run(client, args)
196
- when "evaluate" then print_result(client.evaluate(args[0], args[1]))
197
- when "url" then print_result(client.url(args[0]))
198
- when "wait"
199
- opts = Optimist.options(args) do
200
- opt :timeout, "Seconds to wait (default: 30)", default: 30.0, short: "-t"
201
- end
202
- name = args.shift
203
- selector = args.shift
204
- abort "usage: browserctl wait <page> <selector> [--timeout N]" unless name && selector
205
- print_result(client.wait(name, selector, timeout: opts[:timeout]))
206
- when "pause"
207
- opts = Optimist.options(args) do
208
- opt :message, "Message shown to human", type: :string, short: "-m"
206
+ case cmd
207
+ when "flow" then Browserctl::Commands::Flow.run(client, args)
208
+ when "page" then Browserctl::Commands::Page.run(client, args)
209
+ when "cookie" then Browserctl::Commands::Cookie.run(client, args)
210
+ when "storage" then Browserctl::Commands::Storage.run(client, args)
211
+ when "session" then Browserctl::Commands::Session.run(client, args)
212
+ when "state" then Browserctl::Commands::State.run(client, args)
213
+ when "daemon" then Browserctl::Commands::Daemon.run(client, args)
214
+ when "auth-check"
215
+ page_name = args.shift or abort "usage: browserctl auth-check <page> [--cookies] [--state NAME] [--flow NAME]"
216
+ include_cookies = args.delete("--cookies") ? true : false
217
+ state_idx = args.index("--state")
218
+ state_arg = if state_idx
219
+ (args.delete_at(state_idx)
220
+ args.delete_at(state_idx))
221
+ end
222
+ flow_idx = args.index("--flow")
223
+ flow_arg = if flow_idx
224
+ (args.delete_at(flow_idx)
225
+ args.delete_at(flow_idx))
226
+ end
227
+ print_result(client.auth_check(page_name, include_cookies: include_cookies,
228
+ state: state_arg, suggested_flow: flow_arg))
229
+ when "navigate" then print_result(client.navigate(args[0], args[1]))
230
+ when "fill" then Browserctl::Commands::Fill.run(client, args)
231
+ when "click" then Browserctl::Commands::Click.run(client, args)
232
+ when "snapshot" then Browserctl::Commands::Snapshot.run(client, args)
233
+ when "screenshot" then Browserctl::Commands::Screenshot.run(client, args)
234
+ when "evaluate" then print_result(client.evaluate(args[0], args[1]))
235
+ when "url" then print_result(client.url(args[0]))
236
+ when "wait"
237
+ opts = Optimist.options(args) do
238
+ opt :timeout, "Seconds to wait (default: 30)", default: 30.0, short: "-t"
239
+ end
240
+ name = args.shift
241
+ selector = args.shift
242
+ abort "usage: browserctl wait <page> <selector> [--timeout N]" unless name && selector
243
+ print_result(client.wait(name, selector, timeout: opts[:timeout]))
244
+ when "pause"
245
+ opts = Optimist.options(args) do
246
+ opt :message, "Message shown to human", type: :string, short: "-m"
247
+ end
248
+ name = args.shift or abort "usage: browserctl pause <page> [--message MSG]"
249
+ res = client.pause(name, message: opts[:message])
250
+ if res[:error]
251
+ warn "Error: #{res[:error]}"
252
+ exit 1
253
+ end
254
+ puts "Page '#{name}' paused. Browser is live — interact freely."
255
+ puts "(#{opts[:message]})" if opts[:message]
256
+ puts "When done: browserctl resume #{name}"
257
+ when "resume" then Browserctl::Commands::Resume.run(client, args)
258
+ when "press"
259
+ name = args.shift or abort "usage: browserctl press <page> <key>"
260
+ key = args.shift or abort "usage: browserctl press <page> <key>"
261
+ print_result(client.press(name, key))
262
+ when "hover"
263
+ name = args.shift or abort "usage: browserctl hover <page> <selector>"
264
+ selector = args.shift or abort "usage: browserctl hover <page> <selector>"
265
+ print_result(client.hover(name, selector))
266
+ when "upload"
267
+ name = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
268
+ selector = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
269
+ path = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
270
+ print_result(client.upload(name, selector, path))
271
+ when "select"
272
+ name = args.shift or abort "usage: browserctl select <page> <selector> <value>"
273
+ selector = args.shift or abort "usage: browserctl select <page> <selector> <value>"
274
+ value = args.shift or abort "usage: browserctl select <page> <selector> <value>"
275
+ print_result(client.select(name, selector, value))
276
+ when "dialog" then Browserctl::Commands::Dialog.run(client, args)
277
+ when "devtools"
278
+ name = args.shift or abort "usage: browserctl devtools <page>"
279
+ res = client.devtools(name)
280
+ if res[:error]
281
+ warn "Error: #{res[:error]}"
282
+ exit 1
283
+ end
284
+ url = res[:devtools_url]
285
+ puts "Opening DevTools for '#{name}':"
286
+ puts " #{url}"
287
+ opener = RUBY_PLATFORM =~ /darwin/ ? "open" : "xdg-open"
288
+ system(opener, url)
289
+ else
290
+ abort "unknown command: #{cmd}\nRun 'browserctl --help' for usage."
209
291
  end
210
- name = args.shift or abort "usage: browserctl pause <page> [--message MSG]"
211
- res = client.pause(name, message: opts[:message])
212
- if res[:error]
213
- warn "Error: #{res[:error]}"
214
- exit 1
215
- end
216
- puts "Page '#{name}' paused. Browser is live — interact freely."
217
- puts "(#{opts[:message]})" if opts[:message]
218
- puts "When done: browserctl resume #{name}"
219
- when "resume" then Browserctl::Commands::Resume.run(client, args)
220
- when "press"
221
- name = args.shift or abort "usage: browserctl press <page> <key>"
222
- key = args.shift or abort "usage: browserctl press <page> <key>"
223
- print_result(client.press(name, key))
224
- when "hover"
225
- name = args.shift or abort "usage: browserctl hover <page> <selector>"
226
- selector = args.shift or abort "usage: browserctl hover <page> <selector>"
227
- print_result(client.hover(name, selector))
228
- when "upload"
229
- name = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
230
- selector = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
231
- path = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
232
- print_result(client.upload(name, selector, path))
233
- when "select"
234
- name = args.shift or abort "usage: browserctl select <page> <selector> <value>"
235
- selector = args.shift or abort "usage: browserctl select <page> <selector> <value>"
236
- value = args.shift or abort "usage: browserctl select <page> <selector> <value>"
237
- print_result(client.select(name, selector, value))
238
- when "dialog" then Browserctl::Commands::Dialog.run(client, args)
239
- when "devtools"
240
- name = args.shift or abort "usage: browserctl devtools <page>"
241
- res = client.devtools(name)
242
- if res[:error]
243
- warn "Error: #{res[:error]}"
244
- exit 1
245
- end
246
- url = res[:devtools_url]
247
- puts "Opening DevTools for '#{name}':"
248
- puts " #{url}"
249
- opener = RUBY_PLATFORM =~ /darwin/ ? "open" : "xdg-open"
250
- system(opener, url)
251
- else
252
- abort "unknown command: #{cmd}\nRun 'browserctl --help' for usage."
253
292
  end
293
+ rescue Browserctl::Error => e
294
+ payload = {
295
+ code: e.code,
296
+ message: e.message,
297
+ context: e.respond_to?(:context) ? (e.context || {}) : {},
298
+ suggested_action: Browserctl::Error::SuggestedActions.for(e.code)
299
+ }
300
+ warn "Error: #{e.message}"
301
+ warn JSON.generate(payload)
302
+ exit Browserctl::Error::ExitCodes.for(e.code)
254
303
  end
data/bin/browserd CHANGED
@@ -5,6 +5,7 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
 
6
6
  require "optimist"
7
7
  require "nokogiri"
8
+ require "browserctl/crash_report"
8
9
  require "browserctl/logger"
9
10
  require "browserctl/server"
10
11
  require "browserctl/version"
@@ -35,7 +36,9 @@ end
35
36
 
36
37
  log_path = Browserctl.log_path(assigned_name)
37
38
  warn "browserd starting — log: #{log_path}"
38
- Browserctl.logger = Browserctl.build_logger(opts[:log_level], log_path: log_path)
39
+ warn " if browserd crashes, attach the crash report from ~/.browserctl/logs/crash-*.json"
40
+ Browserctl.logger = Browserctl.build_logger(opts[:log_level], log_path: log_path, component: "daemon")
41
+ daemon_jsonl_log = File.join(Browserctl.log_dir, "daemon.log")
39
42
  begin
40
43
  Browserctl::Server.new(
41
44
  headless: !opts[:headed],
@@ -45,4 +48,8 @@ begin
45
48
  ).run
46
49
  rescue Browserctl::BrowserNotFound => e
47
50
  abort e.message
51
+ rescue Exception => e # rubocop:disable Lint/RescueException
52
+ crash_path = Browserctl::CrashReport.write(error: e, log_path: daemon_jsonl_log)
53
+ warn "browserd crashed — crash report: #{crash_path}" if crash_path
54
+ raise
48
55
  end
@@ -182,7 +182,7 @@ module Browserctl
182
182
  # @param path [String] file path to read cookies from
183
183
  # @return [Hash] `{ ok: true, count: }` or `{ error: }`
184
184
  def import_cookies(name, path)
185
- raise "cookie file not found: #{path}" unless File.exist?(path)
185
+ raise Browserctl::Error, "cookie file not found: #{path}" unless File.exist?(path)
186
186
 
187
187
  cookies = JSON.parse(File.read(path), symbolize_names: true)
188
188
  call("import_cookies", name: name, cookies: cookies)
@@ -371,10 +371,10 @@ module Browserctl
371
371
  end
372
372
 
373
373
  def read_response(sock)
374
- raise "browserd response timeout after 60s" unless sock.wait_readable(60)
374
+ raise DaemonUnavailableError, "browserd response timeout after 60s" unless sock.wait_readable(60)
375
375
 
376
376
  raw = sock.gets
377
- raise "browserd closed connection" unless raw
377
+ raise DaemonUnavailableError, "browserd closed connection" unless raw
378
378
 
379
379
  JSON.parse(raw.chomp, symbolize_names: true)
380
380
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "../errors"
5
+ require_relative "../error/suggested_actions"
4
6
 
5
7
  module Browserctl
6
8
  module Commands
@@ -9,7 +11,9 @@ module Browserctl
9
11
 
10
12
  def print_result(res)
11
13
  if res.is_a?(Hash) && (res[:error] || res["error"])
12
- warn "Error: #{res[:error] || res['error']}"
14
+ message = res[:error] || res["error"]
15
+ warn "Error: #{message}"
16
+ warn structured_error_line(res, message)
13
17
  puts res.to_json
14
18
  exit exit_code_for(res)
15
19
  end
@@ -23,6 +27,22 @@ module Browserctl
23
27
 
24
28
  1
25
29
  end
30
+
31
+ # Builds the single-line structured payload emitted to stderr after
32
+ # the human-readable line. Agents parse this JSON deterministically.
33
+ # Shape: { code, message, context, suggested_action }.
34
+ def structured_error_line(res, message)
35
+ code = (res[:code] || res["code"] || Browserctl::Error::Codes::GENERIC).to_s
36
+ context = res[:context] || res["context"] || {}
37
+ action = res[:suggested_action] || res["suggested_action"] ||
38
+ Browserctl::Error::SuggestedActions.for(code)
39
+ JSON.generate(
40
+ code: code,
41
+ message: message,
42
+ context: context,
43
+ suggested_action: action
44
+ )
45
+ end
26
46
  end
27
47
  end
28
48
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../migrations"
4
+ require_relative "../errors"
5
+ require_relative "../error/codes"
6
+ require_relative "../error/exit_codes"
7
+
8
+ module Browserctl
9
+ module Commands
10
+ # `browserctl migrate <path> [--to-version N] [--dry-run]` — operator
11
+ # entry point for the {Browserctl::Migrations} registry. Detects the
12
+ # artifact's format and version, plans a chain of registered upgraders,
13
+ # and applies them in order (unless `--dry-run`).
14
+ #
15
+ # The registry ships empty in v0.12; this command exists so operators
16
+ # have a stable invocation the moment a real migration lands. On an
17
+ # already-current artifact the command is a no-op and exits 0.
18
+ module Migrate
19
+ USAGE = "Usage: browserctl migrate <path> [--to-version N] [--dry-run]"
20
+
21
+ def self.run(args, out: $stdout, err: $stderr)
22
+ abort USAGE if args.empty? || args.include?("-h") || args.include?("--help")
23
+ args = args.dup
24
+
25
+ dry_run = !args.delete("--dry-run").nil?
26
+ target_idx = args.index("--to-version")
27
+ target = if target_idx
28
+ args.delete_at(target_idx)
29
+ Integer(args.delete_at(target_idx))
30
+ end
31
+ path = args.shift
32
+ abort USAGE unless path
33
+
34
+ unless File.exist?(path)
35
+ err.puts "Error: file not found: #{path}"
36
+ exit Browserctl::Error::ExitCodes::GENERIC
37
+ end
38
+
39
+ execute(path, target_version: target, dry_run: dry_run, out: out, err: err)
40
+ rescue Browserctl::ProtocolMismatch => e
41
+ err.puts "Error: #{e.message}"
42
+ exit Browserctl::Error::ExitCodes.for(e.code)
43
+ end
44
+
45
+ def self.execute(path, target_version:, dry_run:, out:, err:)
46
+ format = Browserctl::Migrations.detect_format(path)
47
+ unless format
48
+ err.puts "Error: could not detect format for #{path} (expected .bctl, .jsonl, or .rb)"
49
+ exit Browserctl::Error::ExitCodes::PROTOCOL_MISMATCH
50
+ end
51
+
52
+ current = Browserctl::Migrations.detect_version(path, format)
53
+ out.puts "Detected: format=#{format} version=#{current.inspect} path=#{path}"
54
+
55
+ if dry_run
56
+ plan_dry_run(format, current, target_version, out)
57
+ return
58
+ end
59
+
60
+ result = Browserctl::Migrations.run(path, target_version: target_version)
61
+ if result.applied.empty?
62
+ out.puts "No migrations registered for #{format} v#{current}; nothing to do."
63
+ else
64
+ out.puts "Applied #{result.applied.size} migration(s): #{result.from} -> #{result.to}"
65
+ result.applied.each { |m| out.puts " - #{format} v#{m.from_version} -> v#{m.to_version}" }
66
+ end
67
+ end
68
+
69
+ def self.plan_dry_run(format, current, target_version, out)
70
+ target = target_version || latest_target(format, current)
71
+ chain = Browserctl::Migrations.find_path(format: format, from: current, to: target)
72
+
73
+ if chain.nil?
74
+ out.puts "No migration path #{format} v#{current} -> v#{target} (registered: " \
75
+ "#{registered_for(format).inspect})"
76
+ elsif chain.empty?
77
+ out.puts "Already at v#{target}; no migrations would run."
78
+ else
79
+ out.puts "Plan (#{chain.size} step(s)):"
80
+ chain.each { |m| out.puts " - #{format} v#{m.from_version} -> v#{m.to_version}" }
81
+ end
82
+ end
83
+
84
+ def self.latest_target(format, current)
85
+ targets = registered_for(format)
86
+ targets.empty? ? current : targets.max
87
+ end
88
+
89
+ def self.registered_for(format)
90
+ Browserctl::Migrations.all.select { |m| m.format == format }.map(&:to_version)
91
+ end
92
+ end
93
+ end
94
+ end