browserctl 0.12.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +3 -3
  4. data/bin/browserctl +39 -32
  5. data/lib/browserctl/callable_definition.rb +114 -0
  6. data/lib/browserctl/client.rb +0 -27
  7. data/lib/browserctl/commands/cli_output.rb +17 -3
  8. data/lib/browserctl/commands/daemon.rb +10 -6
  9. data/lib/browserctl/commands/flow.rb +7 -5
  10. data/lib/browserctl/commands/init.rb +20 -7
  11. data/lib/browserctl/commands/migrate.rb +56 -8
  12. data/lib/browserctl/commands/output_format.rb +144 -0
  13. data/lib/browserctl/commands/page.rb +9 -5
  14. data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
  15. data/lib/browserctl/commands/resume.rb +1 -1
  16. data/lib/browserctl/commands/screenshot.rb +2 -2
  17. data/lib/browserctl/commands/snapshot.rb +8 -3
  18. data/lib/browserctl/commands/state.rb +3 -2
  19. data/lib/browserctl/commands/trace.rb +40 -11
  20. data/lib/browserctl/commands/workflow.rb +9 -7
  21. data/lib/browserctl/contextual_persistence.rb +58 -0
  22. data/lib/browserctl/driver/cdp.rb +2 -3
  23. data/lib/browserctl/encryption_service.rb +84 -0
  24. data/lib/browserctl/flow.rb +35 -59
  25. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
  26. data/lib/browserctl/recording/log_writer.rb +82 -0
  27. data/lib/browserctl/recording/redactor.rb +58 -0
  28. data/lib/browserctl/recording/state.rb +44 -0
  29. data/lib/browserctl/recording/workflow_renderer.rb +214 -0
  30. data/lib/browserctl/recording.rb +33 -294
  31. data/lib/browserctl/server/command_dispatcher.rb +25 -16
  32. data/lib/browserctl/server/handlers/state.rb +7 -5
  33. data/lib/browserctl/server.rb +2 -1
  34. data/lib/browserctl/state/bundle.rb +20 -47
  35. data/lib/browserctl/state.rb +46 -9
  36. data/lib/browserctl/version.rb +1 -1
  37. data/lib/browserctl/workflow/recovery_manager.rb +87 -0
  38. data/lib/browserctl/workflow.rb +61 -237
  39. metadata +11 -8
  40. data/examples/session_reuse.rb +0 -75
  41. data/lib/browserctl/commands/session.rb +0 -243
  42. data/lib/browserctl/driver/base.rb +0 -13
  43. data/lib/browserctl/driver.rb +0 -5
  44. data/lib/browserctl/server/handlers/session.rb +0 -94
  45. data/lib/browserctl/session.rb +0 -206
@@ -4,6 +4,7 @@ require_relative "../migrations"
4
4
  require_relative "../errors"
5
5
  require_relative "../error/codes"
6
6
  require_relative "../error/exit_codes"
7
+ require_relative "output_format"
7
8
 
8
9
  module Browserctl
9
10
  module Commands
@@ -43,21 +44,42 @@ module Browserctl
43
44
  end
44
45
 
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:)
46
58
  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
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?
51
68
 
52
- current = Browserctl::Migrations.detect_version(path, format)
53
69
  out.puts "Detected: format=#{format} version=#{current.inspect} path=#{path}"
70
+ end
71
+ private_class_method :emit_detected
54
72
 
55
- if dry_run
56
- plan_dry_run(format, current, target_version, out)
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)
57
79
  return
58
80
  end
81
+ return if fmt.silent?
59
82
 
60
- result = Browserctl::Migrations.run(path, target_version: target_version)
61
83
  if result.applied.empty?
62
84
  out.puts "No migrations registered for #{format} v#{current}; nothing to do."
63
85
  else
@@ -65,11 +87,36 @@ module Browserctl
65
87
  result.applied.each { |m| out.puts " - #{format} v#{m.from_version} -> v#{m.to_version}" }
66
88
  end
67
89
  end
90
+ private_class_method :emit_applied
68
91
 
69
92
  def self.plan_dry_run(format, current, target_version, out)
70
93
  target = target_version || latest_target(format, current)
71
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
72
118
 
119
+ def self.emit_plan_text(format, current, target, chain, out)
73
120
  if chain.nil?
74
121
  out.puts "No migration path #{format} v#{current} -> v#{target} (registered: " \
75
122
  "#{registered_for(format).inspect})"
@@ -80,6 +127,7 @@ module Browserctl
80
127
  chain.each { |m| out.puts " - #{format} v#{m.from_version} -> v#{m.to_version}" }
81
128
  end
82
129
  end
130
+ private_class_method :emit_plan_text
83
131
 
84
132
  def self.latest_target(format, current)
85
133
  targets = registered_for(format)
@@ -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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "optimist"
5
+ require_relative "output_format"
5
6
 
6
7
  module Browserctl
7
8
  module Commands
@@ -10,11 +11,11 @@ module Browserctl
10
11
 
11
12
  def self.run(client, args)
12
13
  opts = Optimist.options(args) do
13
- banner "Usage: browserctl snapshot <page> [--format elements|html] [--diff]"
14
+ banner "Usage: browserctl page snapshot <name> [--format elements|html] [--diff]"
14
15
  opt :format, "Output format: elements (default) or html", default: "elements", short: "-f"
15
16
  opt :diff, "Return only changed elements", default: false, short: "-d"
16
17
  end
17
- name = args.shift or abort "usage: browserctl snapshot <page> [--format elements|html] [--diff]"
18
+ name = args.shift or abort "usage: browserctl page snapshot <name> [--format elements|html] [--diff]"
18
19
  unless VALID_FORMATS.include?(opts[:format])
19
20
  warn "Error: --format must be one of: #{VALID_FORMATS.join(', ')}"
20
21
  exit 1
@@ -31,7 +32,11 @@ module Browserctl
31
32
  warn "Error: #{res[:error]}"
32
33
  exit 1
33
34
  end
34
- puts(format == "elements" ? JSON.pretty_generate(res[:snapshot]) : res[:html])
35
+ if format == "elements"
36
+ OutputFormat.current.emit(res[:snapshot], JSON.pretty_generate(res[:snapshot]))
37
+ else
38
+ OutputFormat.current.emit({ html: res[:html] }, res[:html])
39
+ end
35
40
  end
36
41
  end
37
42
  end
@@ -3,6 +3,7 @@
3
3
  require "io/console"
4
4
  require "json"
5
5
  require_relative "cli_output"
6
+ require_relative "output_format"
6
7
 
7
8
  module Browserctl
8
9
  module Commands
@@ -131,7 +132,7 @@ module Browserctl
131
132
  destination = args.shift or abort "usage: browserctl state export <name> <destination>"
132
133
  require "browserctl/state"
133
134
  result = Browserctl::State.export(name, destination)
134
- puts result.to_json
135
+ OutputFormat.current.emit(result)
135
136
  rescue Browserctl::State::Transport::TransportError, Browserctl::Error, ArgumentError => e
136
137
  warn "Error: #{e.message}"
137
138
  exit 1
@@ -142,7 +143,7 @@ module Browserctl
142
143
  source = args.shift or abort "usage: browserctl state import <source> [--name NAME]"
143
144
  require "browserctl/state"
144
145
  result = Browserctl::State.import(source, name: name_override)
145
- puts result.to_json
146
+ OutputFormat.current.emit(result)
146
147
  rescue Browserctl::State::Transport::TransportError,
147
148
  Browserctl::State::Bundle::BundleError,
148
149
  Browserctl::Error, ArgumentError => e
@@ -5,6 +5,7 @@ require "time"
5
5
  require_relative "../logger"
6
6
  require_relative "../redactor"
7
7
  require_relative "../secret_resolver_registry"
8
+ require_relative "output_format"
8
9
 
9
10
  module Browserctl
10
11
  module Commands
@@ -49,27 +50,55 @@ module Browserctl
49
50
  args = args.dup
50
51
  redact = !args.delete("--no-redact")
51
52
  session_filter = args.shift
53
+ redactor = resolve_redactor(redact, err)
54
+ records = collect_records(log_dir, session_filter, out)
55
+ emit_records(records, redactor, out) if records
56
+ end
52
57
 
53
- if redact
54
- redactor = build_redactor
55
- else
56
- redactor = nil
57
- warn_no_redact(err)
58
- end
58
+ def self.resolve_redactor(redact, err)
59
+ return nil unless redact
59
60
 
61
+ build_redactor
62
+ ensure
63
+ warn_no_redact(err) unless redact
64
+ end
65
+
66
+ def self.collect_records(log_dir, session_filter, out)
60
67
  records = load_records(log_dir)
61
68
  if records.empty?
62
- out.puts "No log entries found in #{log_dir}"
63
- return
69
+ emit_empty("No log entries found in #{log_dir}", out)
70
+ return nil
64
71
  end
65
72
 
66
73
  records = filter_session(records, session_filter)
67
74
  if records.empty?
68
- out.puts "No entries match session=#{session_filter}"
69
- return
75
+ emit_empty("No entries match session=#{session_filter}", out)
76
+ return nil
77
+ end
78
+
79
+ records
80
+ end
81
+
82
+ def self.emit_empty(message, out)
83
+ OutputFormat.current.emit({ records: [], message: message }, message, io: out)
84
+ end
85
+
86
+ def self.emit_records(records, redactor, out)
87
+ fmt = OutputFormat.current
88
+ if fmt.json?
89
+ fmt.emit({ records: records.map { |r| redact_record(r, redactor) } }, io: out)
90
+ elsif !fmt.silent?
91
+ render(records, out: out, redactor: redactor)
70
92
  end
93
+ end
94
+
95
+ def self.redact_record(record, redactor)
96
+ return record unless redactor
71
97
 
72
- render(records, out: out, redactor: redactor)
98
+ line = JSON.generate(record)
99
+ JSON.parse(redactor.redact(line))
100
+ rescue JSON::ParserError
101
+ record
73
102
  end
74
103
 
75
104
  def self.build_redactor
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "json"
5
5
  require_relative "cli_output"
6
+ require_relative "output_format"
6
7
  require_relative "../recording"
7
8
  require_relative "../workflow/promoter"
8
9
 
@@ -44,11 +45,11 @@ module Browserctl
44
45
  result = Browserctl::Workflow::Promoter.promote(
45
46
  workflow: name, force: force, threshold: threshold, as_flow: as_flow
46
47
  )
47
- puts JSON.generate(ok: true, **result)
48
+ OutputFormat.current.emit({ ok: true, **result })
48
49
  rescue Browserctl::Workflow::Promoter::IneligibleError => e
49
- puts JSON.generate(
50
- ok: false, error: "ineligible",
51
- message: e.message, streak: e.streak, threshold: e.threshold
50
+ OutputFormat.current.emit(
51
+ { ok: false, error: "ineligible",
52
+ message: e.message, streak: e.streak, threshold: e.threshold }
52
53
  )
53
54
  exit 1
54
55
  rescue Browserctl::Workflow::Promoter::NotFoundError => e
@@ -68,7 +69,7 @@ module Browserctl
68
69
  end
69
70
  FileUtils.mkdir_p(File.dirname(out))
70
71
  Browserctl::Recording.generate_workflow(name, output_path: out, keep_log: true)
71
- puts JSON.generate({ ok: true, name: name, path: out })
72
+ OutputFormat.current.emit({ ok: true, name: name, path: out })
72
73
  rescue StandardError => e
73
74
  abort "Error generating workflow: #{e.message}"
74
75
  end
@@ -111,12 +112,13 @@ module Browserctl
111
112
 
112
113
  def self.run_list(runner)
113
114
  list = runner.list_workflows
114
- puts JSON.generate({ workflows: list.map { |w| { name: w[:name], desc: w[:desc] } } })
115
+ OutputFormat.current.emit({ workflows: list.map { |w| { name: w[:name], desc: w[:desc] } } })
115
116
  end
116
117
 
117
118
  def self.run_describe(runner, args)
118
119
  name = args.shift or abort "usage: browserctl workflow describe <name>"
119
- puts JSON.pretty_generate(runner.describe_workflow(name))
120
+ payload = runner.describe_workflow(name)
121
+ OutputFormat.current.emit(payload, JSON.pretty_generate(payload))
120
122
  end
121
123
  end
122
124
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "workflow/recovery_manager"
4
+
5
+ module Browserctl
6
+ # Persistence-and-state DSL mixed into {WorkflowContext}. {FlowContext}
7
+ # does NOT mix this in — that absence is the structural enforcement of
8
+ # the doctrinal split: flows return state, workflows share state through
9
+ # the daemon-backed `store`/`fetch` and `.bctl` bundles.
10
+ #
11
+ # Hosts must expose `@client` and may expose `@replay_context` (for the
12
+ # selector-rematch fallback used elsewhere). Auth-required recovery is
13
+ # delegated to {Workflow::RecoveryManager}, which calls back into the
14
+ # host's `invoke` so flows bound to a saved bundle can rotate
15
+ # credentials transparently.
16
+ module ContextualPersistence
17
+ def store(key, value)
18
+ res = @client.store(key.to_s, value)
19
+ raise WorkflowError, res[:error] if res[:error]
20
+
21
+ value
22
+ end
23
+
24
+ def fetch(key)
25
+ res = @client.fetch(key.to_s)
26
+ raise WorkflowError, res[:error] if res[:error]
27
+
28
+ res[:value]
29
+ end
30
+
31
+ # Persists the daemon's current cookies + storage as a .bctl bundle.
32
+ # Optional flow binding lets `load_state` auto-rotate when the bundle
33
+ # is detected as needing authentication.
34
+ def save_state(name, flow: nil, origins: nil, encrypt: false)
35
+ passphrase = encrypt ? ENV.fetch("BROWSERCTL_STATE_PASSPHRASE", nil) : nil
36
+ res = @client.state_save(name.to_s,
37
+ flow: flow&.to_s, origins: origins, passphrase: passphrase)
38
+ raise WorkflowError, res[:error] if res[:error]
39
+
40
+ res
41
+ end
42
+
43
+ # Restores a .bctl bundle. When the daemon detects AUTH_REQUIRED before
44
+ # applying (e.g. expired cookies in the payload), this delegates to
45
+ # {Workflow::RecoveryManager}, which rotates the bound flow and retries
46
+ # — no caller code change required.
47
+ #
48
+ # @param on_auth_required [Proc, nil] override the auto-rotate path. The
49
+ # block runs in the workflow context, in lieu of invoking the manifest's
50
+ # bound flow. Use this when the recovery procedure is bespoke.
51
+ def load_state(name, on_auth_required: nil)
52
+ res = @client.state_load(name.to_s)
53
+ return res unless Workflow::RecoveryManager.auth_required?(res)
54
+
55
+ Workflow::RecoveryManager.new(self).recover(name.to_s, res, on_auth_required: on_auth_required)
56
+ end
57
+ end
58
+ end
@@ -1,13 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ferrum"
4
- require_relative "base"
5
4
  require_relative "cdp_page"
6
5
  require_relative "../errors"
7
6
 
8
7
  module Browserctl
9
8
  module Driver
10
- class CDP < Base
9
+ class CDP
11
10
  BRAVE_PATHS = {
12
11
  darwin: [
13
12
  "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
@@ -24,7 +23,7 @@ module Browserctl
24
23
  ]
25
24
  }.freeze
26
25
 
27
- def initialize(headless: true, browser: "chrome") # rubocop:disable Lint/MissingSuper
26
+ def initialize(headless: true, browser: "chrome")
28
27
  @headless = headless
29
28
  @browser = browser
30
29
  @ferrum = Ferrum::Browser.new(**ferrum_options)