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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +3 -3
- data/bin/browserctl +39 -32
- data/lib/browserctl/callable_definition.rb +114 -0
- data/lib/browserctl/client.rb +0 -27
- data/lib/browserctl/commands/cli_output.rb +17 -3
- data/lib/browserctl/commands/daemon.rb +10 -6
- data/lib/browserctl/commands/flow.rb +7 -5
- data/lib/browserctl/commands/init.rb +20 -7
- data/lib/browserctl/commands/migrate.rb +56 -8
- data/lib/browserctl/commands/output_format.rb +144 -0
- data/lib/browserctl/commands/page.rb +9 -5
- data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
- data/lib/browserctl/commands/resume.rb +1 -1
- data/lib/browserctl/commands/screenshot.rb +2 -2
- data/lib/browserctl/commands/snapshot.rb +8 -3
- data/lib/browserctl/commands/state.rb +3 -2
- data/lib/browserctl/commands/trace.rb +40 -11
- data/lib/browserctl/commands/workflow.rb +9 -7
- data/lib/browserctl/contextual_persistence.rb +58 -0
- data/lib/browserctl/driver/cdp.rb +2 -3
- data/lib/browserctl/encryption_service.rb +84 -0
- data/lib/browserctl/flow.rb +35 -59
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
- data/lib/browserctl/recording/log_writer.rb +82 -0
- data/lib/browserctl/recording/redactor.rb +58 -0
- data/lib/browserctl/recording/state.rb +44 -0
- data/lib/browserctl/recording/workflow_renderer.rb +214 -0
- data/lib/browserctl/recording.rb +33 -294
- data/lib/browserctl/server/command_dispatcher.rb +25 -16
- data/lib/browserctl/server/handlers/state.rb +7 -5
- data/lib/browserctl/server.rb +2 -1
- data/lib/browserctl/state/bundle.rb +20 -47
- data/lib/browserctl/state.rb +46 -9
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/recovery_manager.rb +87 -0
- data/lib/browserctl/workflow.rb +61 -237
- metadata +11 -8
- data/examples/session_reuse.rb +0 -75
- data/lib/browserctl/commands/session.rb +0 -243
- data/lib/browserctl/driver/base.rb +0 -13
- data/lib/browserctl/driver.rb +0 -5
- data/lib/browserctl/server/handlers/session.rb +0 -94
- 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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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"
|
|
17
|
-
when "close"
|
|
18
|
-
when "list"
|
|
19
|
-
when "focus"
|
|
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
|
|
11
|
-
USAGE = "Usage: browserctl
|
|
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
|
|
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
|
|
29
|
-
name = args.shift or abort "usage: browserctl
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
50
|
+
active = Browserctl::Recording.active
|
|
51
|
+
OutputFormat.current.emit({ active: active })
|
|
51
52
|
end
|
|
52
53
|
end
|
|
53
54
|
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 <
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
+
OutputFormat.current.emit({ ok: true, **result })
|
|
48
49
|
rescue Browserctl::Workflow::Promoter::IneligibleError => e
|
|
49
|
-
|
|
50
|
-
ok: false, error: "ineligible",
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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")
|
|
26
|
+
def initialize(headless: true, browser: "chrome")
|
|
28
27
|
@headless = headless
|
|
29
28
|
@browser = browser
|
|
30
29
|
@ferrum = Ferrum::Browser.new(**ferrum_options)
|