browserctl 0.11.0 → 0.13.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +4 -3
  4. data/bin/browserctl +171 -115
  5. data/bin/browserd +8 -1
  6. data/lib/browserctl/callable_definition.rb +114 -0
  7. data/lib/browserctl/client.rb +3 -30
  8. data/lib/browserctl/commands/cli_output.rb +38 -4
  9. data/lib/browserctl/commands/daemon.rb +10 -6
  10. data/lib/browserctl/commands/flow.rb +7 -5
  11. data/lib/browserctl/commands/init.rb +20 -7
  12. data/lib/browserctl/commands/migrate.rb +142 -0
  13. data/lib/browserctl/commands/output_format.rb +144 -0
  14. data/lib/browserctl/commands/page.rb +9 -5
  15. data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
  16. data/lib/browserctl/commands/resume.rb +1 -1
  17. data/lib/browserctl/commands/screenshot.rb +2 -2
  18. data/lib/browserctl/commands/snapshot.rb +8 -3
  19. data/lib/browserctl/commands/state.rb +3 -2
  20. data/lib/browserctl/commands/trace.rb +216 -0
  21. data/lib/browserctl/commands/workflow.rb +9 -7
  22. data/lib/browserctl/constants.rb +3 -1
  23. data/lib/browserctl/contextual_persistence.rb +58 -0
  24. data/lib/browserctl/crash_report.rb +96 -0
  25. data/lib/browserctl/driver/cdp.rb +2 -3
  26. data/lib/browserctl/encryption_service.rb +84 -0
  27. data/lib/browserctl/error/codes.rb +44 -0
  28. data/lib/browserctl/error/exit_codes.rb +54 -0
  29. data/lib/browserctl/error/suggested_actions.rb +41 -0
  30. data/lib/browserctl/errors.rb +44 -14
  31. data/lib/browserctl/flow.rb +35 -59
  32. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
  33. data/lib/browserctl/format_version.rb +37 -0
  34. data/lib/browserctl/logger.rb +102 -9
  35. data/lib/browserctl/migrations.rb +216 -0
  36. data/lib/browserctl/recording/log_writer.rb +82 -0
  37. data/lib/browserctl/recording/redactor.rb +58 -0
  38. data/lib/browserctl/recording/state.rb +44 -0
  39. data/lib/browserctl/recording/workflow_renderer.rb +214 -0
  40. data/lib/browserctl/recording.rb +39 -268
  41. data/lib/browserctl/redactor.rb +58 -0
  42. data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
  43. data/lib/browserctl/runner.rb +12 -6
  44. data/lib/browserctl/secret_resolver_registry.rb +23 -4
  45. data/lib/browserctl/server/command_dispatcher.rb +28 -16
  46. data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
  47. data/lib/browserctl/server/handlers/error_payload.rb +27 -0
  48. data/lib/browserctl/server/handlers/interaction.rb +21 -3
  49. data/lib/browserctl/server/handlers/navigation.rb +19 -3
  50. data/lib/browserctl/server/handlers/state.rb +7 -5
  51. data/lib/browserctl/server.rb +2 -1
  52. data/lib/browserctl/state/bundle.rb +63 -49
  53. data/lib/browserctl/state.rb +46 -9
  54. data/lib/browserctl/version.rb +1 -1
  55. data/lib/browserctl/workflow/flow_wrapper.rb +1 -1
  56. data/lib/browserctl/workflow/recovery_manager.rb +87 -0
  57. data/lib/browserctl/workflow.rb +117 -238
  58. metadata +25 -14
  59. data/examples/session_reuse.rb +0 -75
  60. data/lib/browserctl/commands/session.rb +0 -243
  61. data/lib/browserctl/driver/base.rb +0 -13
  62. data/lib/browserctl/driver.rb +0 -5
  63. data/lib/browserctl/server/handlers/session.rb +0 -94
  64. data/lib/browserctl/session.rb +0 -206
@@ -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)
@@ -283,33 +283,6 @@ module Browserctl
283
283
  # @return [Hash] `{ ok: true }` or `{ error: }`
284
284
  def dialog_dismiss(name) = call("dialog_dismiss", name: name)
285
285
 
286
- # Saves the current browser state (cookies, localStorage, open pages) to a named session.
287
- # @param session_name [String] name for the saved session
288
- # @return [Hash] `{ ok: true, path:, pages: N, cookies: N }` or `{ error: }`
289
- def session_save(session_name, encrypt: false)
290
- call("session_save", session_name: session_name, encrypt: encrypt)
291
- end
292
-
293
- # Restores a previously saved session into the running daemon.
294
- # @param session_name [String] name of the session to load
295
- # @return [Hash] `{ ok: true, cookies: N, pages: N, local_storage_keys: N }` or `{ error: }`
296
- def session_load(session_name)
297
- call("session_load", session_name: session_name)
298
- end
299
-
300
- # Lists all saved sessions.
301
- # @return [Hash] `{ ok: true, sessions: [Hash] }` or `{ error: }`
302
- def session_list
303
- call("session_list")
304
- end
305
-
306
- # Permanently deletes a named session.
307
- # @param session_name [String] name of the session to delete
308
- # @return [Hash] `{ ok: true }` or `{ error: }`
309
- def session_delete(session_name)
310
- call("session_delete", session_name: session_name)
311
- end
312
-
313
286
  # Saves browser state (cookies + storage) into a single .bctl bundle.
314
287
  # @return [Hash] `{ ok:, path:, origins:, cookies:, encrypted: }` or `{ error: }`
315
288
  def state_save(name, origins: nil, flow: nil, flow_version: nil, passphrase: nil)
@@ -371,10 +344,10 @@ module Browserctl
371
344
  end
372
345
 
373
346
  def read_response(sock)
374
- raise "browserd response timeout after 60s" unless sock.wait_readable(60)
347
+ raise DaemonUnavailableError, "browserd response timeout after 60s" unless sock.wait_readable(60)
375
348
 
376
349
  raw = sock.gets
377
- raise "browserd closed connection" unless raw
350
+ raise DaemonUnavailableError, "browserd closed connection" unless raw
378
351
 
379
352
  JSON.parse(raw.chomp, symbolize_names: true)
380
353
  end
@@ -1,19 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "../errors"
5
+ require_relative "../error/suggested_actions"
6
+ require_relative "output_format"
4
7
 
5
8
  module Browserctl
6
9
  module Commands
7
10
  module CliOutput
8
11
  AUTH_REQUIRED_EXIT_CODE = Browserctl::AuthRequiredError::AUTH_REQUIRED_EXIT_CODE
9
12
 
10
- def print_result(res)
13
+ # Print the JSON-RPC daemon response, routed through the active
14
+ # `OutputFormat`. The historical default behaviour was `puts res.to_json`
15
+ # — keeping that as the `text` branch preserves byte-identical output
16
+ # for existing callers and golden files. `json` mode emits the same
17
+ # JSON explicitly. `silent` suppresses stdout entirely; errors still
18
+ # write the structured payload to stderr because errors are the
19
+ # result, not cosmetic output.
20
+ #
21
+ # `text_block` (optional) overrides the JSON dump in `text` mode for
22
+ # commands that have a distinct human-readable form (e.g. `init`).
23
+ def print_result(res, text_block = nil)
24
+ fmt = OutputFormat.current
25
+
11
26
  if res.is_a?(Hash) && (res[:error] || res["error"])
12
- warn "Error: #{res[:error] || res['error']}"
13
- puts res.to_json
27
+ message = res[:error] || res["error"]
28
+ warn "Error: #{message}"
29
+ warn structured_error_line(res, message)
30
+ puts res.to_json unless fmt.silent?
14
31
  exit exit_code_for(res)
15
32
  end
16
- puts res.to_json
33
+
34
+ fmt.emit(res, text_block)
17
35
  end
18
36
 
19
37
  # Maps a daemon error response onto a process exit code. Defaults to 1;
@@ -23,6 +41,22 @@ module Browserctl
23
41
 
24
42
  1
25
43
  end
44
+
45
+ # Builds the single-line structured payload emitted to stderr after
46
+ # the human-readable line. Agents parse this JSON deterministically.
47
+ # Shape: { code, message, context, suggested_action }.
48
+ def structured_error_line(res, message)
49
+ code = (res[:code] || res["code"] || Browserctl::Error::Codes::GENERIC).to_s
50
+ context = res[:context] || res["context"] || {}
51
+ action = res[:suggested_action] || res["suggested_action"] ||
52
+ Browserctl::Error::SuggestedActions.for(code)
53
+ JSON.generate(
54
+ code: code,
55
+ message: message,
56
+ context: context,
57
+ suggested_action: action
58
+ )
59
+ end
26
60
  end
27
61
  end
28
62
  end
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "optimist"
5
5
  require_relative "cli_output"
6
+ require_relative "output_format"
6
7
 
7
8
  module Browserctl
8
9
  module Commands
@@ -18,7 +19,8 @@ module Browserctl
18
19
  begin
19
20
  print_result(client.ping)
20
21
  rescue Browserctl::DaemonUnavailableError => e
21
- puts JSON.generate({ ok: false, daemon: "offline", error: e.message })
22
+ payload = { ok: false, daemon: "offline", error: e.message }
23
+ OutputFormat.current.emit(payload)
22
24
  exit 1
23
25
  end
24
26
  when "status" then run_status(client)
@@ -36,14 +38,16 @@ module Browserctl
36
38
  url_res = client.url(name)
37
39
  { name: name, url: url_res[:url] || url_res[:error] }
38
40
  end
39
- puts JSON.pretty_generate(
41
+ payload = {
40
42
  daemon: "online",
41
43
  pid: ping[:pid],
42
44
  protocol_version: ping[:protocol_version],
43
45
  pages: page_info
44
- )
46
+ }
47
+ OutputFormat.current.emit(payload, JSON.pretty_generate(payload))
45
48
  rescue Browserctl::DaemonUnavailableError => e
46
- puts JSON.pretty_generate(daemon: "offline", error: e.message)
49
+ payload = { daemon: "offline", error: e.message }
50
+ OutputFormat.current.emit(payload, JSON.pretty_generate(payload))
47
51
  exit 1
48
52
  end
49
53
 
@@ -57,7 +61,7 @@ module Browserctl
57
61
  flags += ["--name", opts[:name]] if opts[:name]
58
62
  pid = Process.spawn("browserd", *flags, out: File::NULL, err: File::NULL)
59
63
  Process.detach(pid)
60
- puts "browserd started (pid #{pid})"
64
+ OutputFormat.current.emit({ ok: true, pid: pid }) { "browserd started (pid #{pid})" }
61
65
  end
62
66
 
63
67
  def self.run_list
@@ -74,7 +78,7 @@ module Browserctl
74
78
  rescue Browserctl::DaemonUnavailableError, RuntimeError
75
79
  nil
76
80
  end.compact
77
- puts({ daemons: rows }.to_json)
81
+ OutputFormat.current.emit({ daemons: rows })
78
82
  end
79
83
  end
80
84
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "cli_output"
5
+ require_relative "output_format"
5
6
  require_relative "../flow_registry"
6
7
  require_relative "../runner"
7
8
 
@@ -31,22 +32,22 @@ module Browserctl
31
32
  page_proxy = page_name ? Browserctl::PageProxy.new(page_name, client) : nil
32
33
 
33
34
  result = flow.run(page: page_proxy, client: client, **params)
34
- puts JSON.generate(ok: true, flow: flow.name, result: serialisable(result))
35
+ OutputFormat.current.emit({ ok: true, flow: flow.name, result: serialisable(result) })
35
36
  rescue Browserctl::FlowError => e
36
37
  warn "Error: #{e.message}"
37
- puts JSON.generate(ok: false, code: e.code, error: e.message)
38
+ OutputFormat.current.emit({ ok: false, code: e.code, error: e.message }) unless OutputFormat.current.silent?
38
39
  exit 1
39
40
  end
40
41
 
41
42
  def self.run_list
42
43
  entries = Browserctl::FlowRegistry.list
43
- puts JSON.generate(flows: entries)
44
+ OutputFormat.current.emit({ flows: entries })
44
45
  end
45
46
 
46
47
  def self.run_describe(args)
47
48
  name = args.shift or abort "usage: browserctl flow describe <name>"
48
49
  flow = resolve(name)
49
- puts JSON.pretty_generate(
50
+ payload = {
50
51
  name: flow.name,
51
52
  desc: flow.description,
52
53
  version: flow.version_string,
@@ -56,7 +57,8 @@ module Browserctl
56
57
  steps: flow.steps.map(&:label),
57
58
  postconditions: flow.postconditions.map(&:label),
58
59
  produces_state: !flow.produces_state_block.nil?
59
- )
60
+ }
61
+ OutputFormat.current.emit(payload, JSON.pretty_generate(payload))
60
62
  end
61
63
 
62
64
  def self.resolve(name_or_path)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "output_format"
4
5
 
5
6
  module Browserctl
6
7
  module Commands
@@ -14,15 +15,15 @@ module Browserctl
14
15
  YAML
15
16
 
16
17
  GITIGNORE_CONTENT = <<~GITIGNORE
17
- # Cookie session exports — contain credentials, never commit
18
- sessions/
18
+ # State bundles — contain credentials, never commit
19
+ state/
19
20
  GITIGNORE
20
21
 
21
22
  def self.run(_args)
22
23
  FileUtils.mkdir_p(".browserctl/workflows")
23
24
  FileUtils.touch(".browserctl/workflows/.keep")
24
25
 
25
- FileUtils.mkdir_p(".browserctl/sessions")
26
+ FileUtils.mkdir_p(".browserctl/state")
26
27
 
27
28
  gitignore_path = ".browserctl/.gitignore"
28
29
  File.write(gitignore_path, GITIGNORE_CONTENT) unless File.exist?(gitignore_path)
@@ -30,10 +31,22 @@ module Browserctl
30
31
  config_path = ".browserctl/config.yml"
31
32
  File.write(config_path, CONFIG_TEMPLATE) unless File.exist?(config_path)
32
33
 
33
- puts "Initialised browserctl project:"
34
- puts " .browserctl/workflows/ (place workflow .rb files here)"
35
- puts " .browserctl/sessions/ (cookie exports — git-ignored)"
36
- puts " .browserctl/config.yml (project settings)"
34
+ payload = {
35
+ ok: true,
36
+ paths: {
37
+ workflows: ".browserctl/workflows",
38
+ state: ".browserctl/state",
39
+ config: ".browserctl/config.yml"
40
+ }
41
+ }
42
+ OutputFormat.current.emit(payload) do
43
+ <<~TEXT.chomp
44
+ Initialised browserctl project:
45
+ .browserctl/workflows/ (place workflow .rb files here)
46
+ .browserctl/state/ (state bundles — git-ignored)
47
+ .browserctl/config.yml (project settings)
48
+ TEXT
49
+ end
37
50
  end
38
51
  end
39
52
  end
@@ -0,0 +1,142 @@
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
+ require_relative "output_format"
8
+
9
+ module Browserctl
10
+ module Commands
11
+ # `browserctl migrate <path> [--to-version N] [--dry-run]` — operator
12
+ # entry point for the {Browserctl::Migrations} registry. Detects the
13
+ # artifact's format and version, plans a chain of registered upgraders,
14
+ # and applies them in order (unless `--dry-run`).
15
+ #
16
+ # The registry ships empty in v0.12; this command exists so operators
17
+ # have a stable invocation the moment a real migration lands. On an
18
+ # already-current artifact the command is a no-op and exits 0.
19
+ module Migrate
20
+ USAGE = "Usage: browserctl migrate <path> [--to-version N] [--dry-run]"
21
+
22
+ def self.run(args, out: $stdout, err: $stderr)
23
+ abort USAGE if args.empty? || args.include?("-h") || args.include?("--help")
24
+ args = args.dup
25
+
26
+ dry_run = !args.delete("--dry-run").nil?
27
+ target_idx = args.index("--to-version")
28
+ target = if target_idx
29
+ args.delete_at(target_idx)
30
+ Integer(args.delete_at(target_idx))
31
+ end
32
+ path = args.shift
33
+ abort USAGE unless path
34
+
35
+ unless File.exist?(path)
36
+ err.puts "Error: file not found: #{path}"
37
+ exit Browserctl::Error::ExitCodes::GENERIC
38
+ end
39
+
40
+ execute(path, target_version: target, dry_run: dry_run, out: out, err: err)
41
+ rescue Browserctl::ProtocolMismatch => e
42
+ err.puts "Error: #{e.message}"
43
+ exit Browserctl::Error::ExitCodes.for(e.code)
44
+ end
45
+
46
+ def self.execute(path, target_version:, dry_run:, out:, err:)
47
+ format = detect_format!(path, err: err)
48
+ current = Browserctl::Migrations.detect_version(path, format)
49
+ emit_detected(format, current, path, out)
50
+
51
+ return plan_dry_run(format, current, target_version, out) if dry_run
52
+
53
+ result = Browserctl::Migrations.run(path, target_version: target_version)
54
+ emit_applied(format, current, result, out)
55
+ end
56
+
57
+ def self.detect_format!(path, err:)
58
+ format = Browserctl::Migrations.detect_format(path)
59
+ return format if format
60
+
61
+ err.puts "Error: could not detect format for #{path} (expected .bctl, .jsonl, or .rb)"
62
+ exit Browserctl::Error::ExitCodes::PROTOCOL_MISMATCH
63
+ end
64
+ private_class_method :detect_format!
65
+
66
+ def self.emit_detected(format, current, path, out)
67
+ return unless OutputFormat.current.text?
68
+
69
+ out.puts "Detected: format=#{format} version=#{current.inspect} path=#{path}"
70
+ end
71
+ private_class_method :emit_detected
72
+
73
+ def self.emit_applied(format, current, result, out)
74
+ fmt = OutputFormat.current
75
+ if fmt.json?
76
+ fmt.emit({ ok: true, format: format, from: result.from, to: result.to,
77
+ applied: result.applied.map { |m| { from: m.from_version, to: m.to_version } } },
78
+ io: out)
79
+ return
80
+ end
81
+ return if fmt.silent?
82
+
83
+ if result.applied.empty?
84
+ out.puts "No migrations registered for #{format} v#{current}; nothing to do."
85
+ else
86
+ out.puts "Applied #{result.applied.size} migration(s): #{result.from} -> #{result.to}"
87
+ result.applied.each { |m| out.puts " - #{format} v#{m.from_version} -> v#{m.to_version}" }
88
+ end
89
+ end
90
+ private_class_method :emit_applied
91
+
92
+ def self.plan_dry_run(format, current, target_version, out)
93
+ target = target_version || latest_target(format, current)
94
+ chain = Browserctl::Migrations.find_path(format: format, from: current, to: target)
95
+ emit_plan(format, current, target, chain, out)
96
+ end
97
+
98
+ def self.emit_plan(format, current, target, chain, out)
99
+ fmt = OutputFormat.current
100
+ if fmt.json?
101
+ fmt.emit(plan_payload(format, current, target, chain), io: out)
102
+ return
103
+ end
104
+ return if fmt.silent?
105
+
106
+ emit_plan_text(format, current, target, chain, out)
107
+ end
108
+ private_class_method :emit_plan
109
+
110
+ def self.plan_payload(format, current, target, chain)
111
+ {
112
+ format: format, from: current, to: target, dry_run: true,
113
+ plan: chain&.map { |m| { from: m.from_version, to: m.to_version } },
114
+ registered: registered_for(format)
115
+ }
116
+ end
117
+ private_class_method :plan_payload
118
+
119
+ def self.emit_plan_text(format, current, target, chain, out)
120
+ if chain.nil?
121
+ out.puts "No migration path #{format} v#{current} -> v#{target} (registered: " \
122
+ "#{registered_for(format).inspect})"
123
+ elsif chain.empty?
124
+ out.puts "Already at v#{target}; no migrations would run."
125
+ else
126
+ out.puts "Plan (#{chain.size} step(s)):"
127
+ chain.each { |m| out.puts " - #{format} v#{m.from_version} -> v#{m.to_version}" }
128
+ end
129
+ end
130
+ private_class_method :emit_plan_text
131
+
132
+ def self.latest_target(format, current)
133
+ targets = registered_for(format)
134
+ targets.empty? ? current : targets.max
135
+ end
136
+
137
+ def self.registered_for(format)
138
+ Browserctl::Migrations.all.select { |m| m.format == format }.map(&:to_version)
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Browserctl
6
+ module Commands
7
+ # Resolves and applies the unified `--output {json,text,silent}` flag.
8
+ #
9
+ # Resolution order: explicit flag value -> `BROWSERCTL_OUTPUT` env var ->
10
+ # `text` (default).
11
+ #
12
+ # Usage from a command:
13
+ #
14
+ # fmt = OutputFormat.from(flag, ENV)
15
+ # fmt.emit(payload_hash) { "human readable text" }
16
+ #
17
+ # Per the v0.13 contract:
18
+ #
19
+ # text - prints the human-readable block; this is byte-identical to
20
+ # today's output. For most commands the human block already IS
21
+ # the JSON payload (legacy CLI shape) so the two collapse.
22
+ # json - prints the JSON payload via `to_json` (no pretty printing).
23
+ # silent - prints nothing on stdout. Exit codes still carry the result.
24
+ #
25
+ # The current format is also exposed as a process-wide default
26
+ # (`OutputFormat.current`) so that `CliOutput#print_result` and other
27
+ # legacy helpers can consult it without every callsite threading the
28
+ # value through.
29
+ module OutputFormat
30
+ VALID = %w[json text silent].freeze
31
+ DEFAULT = "text"
32
+ ENV_VAR = "BROWSERCTL_OUTPUT"
33
+ FLAG = "--output"
34
+
35
+ class InvalidFormat < ArgumentError; end
36
+
37
+ class Formatter
38
+ attr_reader :mode
39
+
40
+ def initialize(mode)
41
+ @mode = mode
42
+ end
43
+
44
+ # Print success output for a command.
45
+ # `payload` is a JSON-serialisable Hash (or Array). `text_block` is
46
+ # either a String or a block that returns a String — only evaluated
47
+ # when needed.
48
+ def emit(payload, text_block = nil, io: $stdout)
49
+ case @mode
50
+ when "silent"
51
+ nil
52
+ when "json"
53
+ io.puts payload.to_json
54
+ else # "text"
55
+ text = if block_given?
56
+ yield
57
+ elsif text_block.respond_to?(:call)
58
+ text_block.call
59
+ elsif text_block.nil?
60
+ payload.to_json
61
+ else
62
+ text_block
63
+ end
64
+ io.puts text
65
+ end
66
+ end
67
+
68
+ def silent?
69
+ @mode == "silent"
70
+ end
71
+
72
+ def json?
73
+ @mode == "json"
74
+ end
75
+
76
+ def text?
77
+ @mode == "text"
78
+ end
79
+ end
80
+
81
+ module_function
82
+
83
+ # Build a Formatter from an explicit flag value (or nil) and an env hash.
84
+ def from(flag, env = ENV)
85
+ raw = flag || env[ENV_VAR] || DEFAULT
86
+ mode = raw.to_s.strip.downcase
87
+ mode = DEFAULT if mode.empty?
88
+ unless VALID.include?(mode)
89
+ raise InvalidFormat,
90
+ "invalid --output value '#{raw}' (expected one of: #{VALID.join(', ')})"
91
+ end
92
+ Formatter.new(mode)
93
+ end
94
+
95
+ # Strip `--output VALUE` (or `--output=VALUE`) from `args` in place and
96
+ # return the extracted value (or nil). Recognises the long form only —
97
+ # there is intentionally no short alias.
98
+ def extract!(args)
99
+ i = 0
100
+ while i < args.length
101
+ arg = args[i]
102
+ if arg == FLAG
103
+ args.delete_at(i)
104
+ value = args.delete_at(i) or
105
+ raise InvalidFormat, "missing value for #{FLAG}"
106
+ return value
107
+ elsif arg.is_a?(String) && arg.start_with?("#{FLAG}=")
108
+ value = arg.split("=", 2)[1]
109
+ args.delete_at(i)
110
+ return value
111
+ else
112
+ i += 1
113
+ end
114
+ end
115
+ nil
116
+ end
117
+
118
+ # Process-wide current format. Set once by the CLI entry point after
119
+ # parsing the global flag; consulted by helpers that don't otherwise
120
+ # have a reference to a Formatter (notably `CliOutput#print_result`).
121
+ def current
122
+ @current ||= Formatter.new(DEFAULT)
123
+ end
124
+
125
+ def current=(formatter)
126
+ @current = formatter
127
+ end
128
+
129
+ # Convenience: parse the flag out of `args`, build a Formatter, set it
130
+ # as current, and return it.
131
+ def install!(args, env = ENV)
132
+ flag = extract!(args)
133
+ fmt = from(flag, env)
134
+ self.current = fmt
135
+ fmt
136
+ end
137
+
138
+ # Reset to default — for tests.
139
+ def reset!
140
+ @current = Formatter.new(DEFAULT)
141
+ end
142
+ end
143
+ end
144
+ end
@@ -2,21 +2,25 @@
2
2
 
3
3
  require "optimist"
4
4
  require_relative "cli_output"
5
+ require_relative "snapshot"
6
+ require_relative "screenshot"
5
7
 
6
8
  module Browserctl
7
9
  module Commands
8
10
  module Page
9
11
  extend CliOutput
10
12
 
11
- USAGE = "Usage: browserctl page <open|close|list|focus> [args]"
13
+ USAGE = "Usage: browserctl page <open|close|list|focus|snapshot|screenshot> [args]"
12
14
 
13
15
  def self.run(client, args)
14
16
  sub = args.shift or abort USAGE
15
17
  case sub
16
- when "open" then run_open(client, args)
17
- when "close" then run_close(client, args)
18
- when "list" then run_list(client)
19
- when "focus" then run_focus(client, args)
18
+ when "open" then run_open(client, args)
19
+ when "close" then run_close(client, args)
20
+ when "list" then run_list(client)
21
+ when "focus" then run_focus(client, args)
22
+ when "snapshot" then Browserctl::Commands::Snapshot.run(client, args)
23
+ when "screenshot" then Browserctl::Commands::Screenshot.run(client, args)
20
24
  else abort "unknown page subcommand '#{sub}'\n#{USAGE}"
21
25
  end
22
26
  end
@@ -4,11 +4,12 @@ require "fileutils"
4
4
  require "json"
5
5
  require "optimist"
6
6
  require "browserctl/recording"
7
+ require_relative "output_format"
7
8
 
8
9
  module Browserctl
9
10
  module Commands
10
- class Record
11
- USAGE = "Usage: browserctl record start <name> | stop [--out PATH] | status"
11
+ class Recording
12
+ USAGE = "Usage: browserctl recording start <name> | stop [--out PATH] | status"
12
13
 
13
14
  def self.run(args)
14
15
  subcmd = args.shift
@@ -17,7 +18,7 @@ module Browserctl
17
18
  when "stop" then run_stop(args)
18
19
  when "status" then run_status
19
20
  else
20
- abort "#{USAGE}\nRun 'browserctl record <subcommand> --help' for details."
21
+ abort "#{USAGE}\nRun 'browserctl recording <subcommand> --help' for details."
21
22
  end
22
23
  end
23
24
 
@@ -25,29 +26,29 @@ module Browserctl
25
26
  private
26
27
 
27
28
  def run_start(args)
28
- Optimist.options(args) { banner "Usage: browserctl record start <name>" }
29
- name = args.shift or abort "usage: browserctl record start <name>"
29
+ Optimist.options(args) { banner "Usage: browserctl recording start <name>" }
30
+ name = args.shift or abort "usage: browserctl recording start <name>"
30
31
  abort "Invalid recording name #{name.inspect} — use only letters, digits, _ or -" \
31
32
  unless name =~ /\A[a-zA-Z0-9_-]{1,64}\z/
32
- Recording.start(name)
33
- puts JSON.generate({ ok: true, name: name })
33
+ Browserctl::Recording.start(name)
34
+ OutputFormat.current.emit({ ok: true, name: name })
34
35
  end
35
36
 
36
37
  def run_stop(args)
37
38
  opts = Optimist.options(args) do
38
- banner "Usage: browserctl record stop [--out PATH]"
39
+ banner "Usage: browserctl recording stop [--out PATH]"
39
40
  opt :out, "Output path for workflow file", type: :string, short: "-o"
40
41
  end
41
- name = Recording.stop
42
+ name = Browserctl::Recording.stop
42
43
  out = opts[:out] || File.join(".browserctl/workflows", "#{name}.rb")
43
44
  FileUtils.mkdir_p(File.dirname(out))
44
- Recording.generate_workflow(name, output_path: out)
45
- puts JSON.generate({ ok: true, name: name, path: out })
45
+ Browserctl::Recording.generate_workflow(name, output_path: out)
46
+ OutputFormat.current.emit({ ok: true, name: name, path: out })
46
47
  end
47
48
 
48
49
  def run_status
49
- active = Recording.active
50
- puts JSON.generate({ active: active })
50
+ active = Browserctl::Recording.active
51
+ OutputFormat.current.emit({ active: active })
51
52
  end
52
53
  end
53
54
  end
@@ -14,7 +14,7 @@ module Browserctl
14
14
  warn "Error: #{res[:error]}"
15
15
  exit 1
16
16
  end
17
- puts "Page '#{name}' resumed."
17
+ print_result(res.merge(resumed: name)) { "Page '#{name}' resumed." }
18
18
  end
19
19
  end
20
20
  end
@@ -10,11 +10,11 @@ module Browserctl
10
10
 
11
11
  def self.run(client, args)
12
12
  opts = Optimist.options(args) do
13
- banner "Usage: browserctl screenshot <page> [--out PATH] [--full]"
13
+ banner "Usage: browserctl page screenshot <name> [--out PATH] [--full]"
14
14
  opt :out, "Output file path", type: :string, short: "-o"
15
15
  opt :full, "Capture full page", default: false, short: "-f"
16
16
  end
17
- name = args.shift or abort "usage: browserctl screenshot <page> [--out PATH] [--full]"
17
+ name = args.shift or abort "usage: browserctl page screenshot <name> [--out PATH] [--full]"
18
18
  print_result(client.screenshot(name, path: opts[:out], full: opts[:full]))
19
19
  end
20
20
  end