browserctl 0.10.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 +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +2 -1
- data/bin/browserctl +168 -78
- data/bin/browserd +8 -1
- data/lib/browserctl/client.rb +50 -6
- data/lib/browserctl/commands/cli_output.rb +36 -3
- data/lib/browserctl/commands/flow.rb +123 -0
- data/lib/browserctl/commands/migrate.rb +94 -0
- data/lib/browserctl/commands/state.rb +193 -0
- data/lib/browserctl/commands/trace.rb +187 -0
- data/lib/browserctl/commands/workflow.rb +62 -4
- data/lib/browserctl/constants.rb +4 -2
- data/lib/browserctl/crash_report.rb +96 -0
- data/lib/browserctl/detectors/auth_required.rb +128 -0
- data/lib/browserctl/detectors.rb +2 -0
- data/lib/browserctl/error/codes.rb +44 -0
- data/lib/browserctl/error/exit_codes.rb +54 -0
- data/lib/browserctl/error/suggested_actions.rb +41 -0
- data/lib/browserctl/errors.rb +72 -12
- data/lib/browserctl/flow.rb +22 -1
- data/lib/browserctl/flow_registry.rb +66 -0
- data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
- data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
- data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
- data/lib/browserctl/format_version.rb +37 -0
- data/lib/browserctl/logger.rb +102 -9
- data/lib/browserctl/migrations.rb +216 -0
- data/lib/browserctl/recording.rb +246 -28
- data/lib/browserctl/redactor.rb +58 -0
- data/lib/browserctl/replay/context.rb +40 -0
- data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
- data/lib/browserctl/replay/snapshot_diff.rb +51 -0
- data/lib/browserctl/replay/telemetry.rb +60 -0
- data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
- data/lib/browserctl/runner.rb +50 -10
- data/lib/browserctl/secret_resolver_registry.rb +23 -4
- data/lib/browserctl/server/command_dispatcher.rb +13 -1
- data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
- data/lib/browserctl/server/handlers/error_payload.rb +27 -0
- data/lib/browserctl/server/handlers/interaction.rb +21 -3
- data/lib/browserctl/server/handlers/navigation.rb +50 -5
- data/lib/browserctl/server/handlers/observation.rb +43 -2
- data/lib/browserctl/server/handlers/state.rb +149 -0
- data/lib/browserctl/server/page_session.rb +9 -7
- data/lib/browserctl/server/snapshot_builder.rb +21 -45
- data/lib/browserctl/session.rb +1 -1
- data/lib/browserctl/snapshot/annotator.rb +75 -0
- data/lib/browserctl/snapshot/extractor.rb +21 -0
- data/lib/browserctl/snapshot/fingerprint.rb +88 -0
- data/lib/browserctl/snapshot/ref.rb +70 -0
- data/lib/browserctl/snapshot/serializer.rb +17 -0
- data/lib/browserctl/state/bundle.rb +283 -0
- data/lib/browserctl/state/transport.rb +64 -0
- data/lib/browserctl/state/transports/file.rb +35 -0
- data/lib/browserctl/state/transports/one_password.rb +67 -0
- data/lib/browserctl/state/transports/s3.rb +42 -0
- data/lib/browserctl/state.rb +208 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
- data/lib/browserctl/workflow/promoter.rb +96 -0
- data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
- data/lib/browserctl/workflow.rb +235 -16
- metadata +44 -7
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "cli_output"
|
|
5
|
+
require_relative "../flow_registry"
|
|
6
|
+
require_relative "../runner"
|
|
7
|
+
|
|
8
|
+
module Browserctl
|
|
9
|
+
module Commands
|
|
10
|
+
module Flow
|
|
11
|
+
extend CliOutput
|
|
12
|
+
|
|
13
|
+
USAGE = "Usage: browserctl flow <run|list|describe> [args]"
|
|
14
|
+
|
|
15
|
+
def self.run(client, args)
|
|
16
|
+
sub = args.shift or abort USAGE
|
|
17
|
+
case sub
|
|
18
|
+
when "run" then run_flow(client, args)
|
|
19
|
+
when "list" then run_list
|
|
20
|
+
when "describe" then run_describe(args)
|
|
21
|
+
else abort "unknown flow subcommand '#{sub}'\n#{USAGE}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.run_flow(client, args)
|
|
26
|
+
name = args.shift or
|
|
27
|
+
abort "usage: browserctl flow run <name|file> [--page NAME] [--params FILE] [--key value ...]"
|
|
28
|
+
|
|
29
|
+
flow = resolve(name)
|
|
30
|
+
page_name, params = parse_run_args(args)
|
|
31
|
+
page_proxy = page_name ? Browserctl::PageProxy.new(page_name, client) : nil
|
|
32
|
+
|
|
33
|
+
result = flow.run(page: page_proxy, client: client, **params)
|
|
34
|
+
puts JSON.generate(ok: true, flow: flow.name, result: serialisable(result))
|
|
35
|
+
rescue Browserctl::FlowError => e
|
|
36
|
+
warn "Error: #{e.message}"
|
|
37
|
+
puts JSON.generate(ok: false, code: e.code, error: e.message)
|
|
38
|
+
exit 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.run_list
|
|
42
|
+
entries = Browserctl::FlowRegistry.list
|
|
43
|
+
puts JSON.generate(flows: entries)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.run_describe(args)
|
|
47
|
+
name = args.shift or abort "usage: browserctl flow describe <name>"
|
|
48
|
+
flow = resolve(name)
|
|
49
|
+
puts JSON.pretty_generate(
|
|
50
|
+
name: flow.name,
|
|
51
|
+
desc: flow.description,
|
|
52
|
+
version: flow.version_string,
|
|
53
|
+
requires_browserctl: flow.min_browserctl_version,
|
|
54
|
+
params: format_params(flow),
|
|
55
|
+
preconditions: flow.preconditions.map(&:label),
|
|
56
|
+
steps: flow.steps.map(&:label),
|
|
57
|
+
postconditions: flow.postconditions.map(&:label),
|
|
58
|
+
produces_state: !flow.produces_state_block.nil?
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.resolve(name_or_path)
|
|
63
|
+
if File.exist?(name_or_path)
|
|
64
|
+
before = Browserctl.flow_registry_snapshot.keys
|
|
65
|
+
load File.expand_path(name_or_path)
|
|
66
|
+
new_name = (Browserctl.flow_registry_snapshot.keys - before).first
|
|
67
|
+
new_name ||= File.basename(name_or_path, ".rb")
|
|
68
|
+
flow = Browserctl.lookup_flow(new_name)
|
|
69
|
+
else
|
|
70
|
+
flow = Browserctl::FlowRegistry.resolve(name_or_path)
|
|
71
|
+
end
|
|
72
|
+
flow or abort "flow '#{name_or_path}' not found"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.parse_run_args(args)
|
|
76
|
+
page_name = take_option(args, "--page")
|
|
77
|
+
params_path = take_option(args, "--params")
|
|
78
|
+
file_params = params_path ? load_params_file(params_path) : {}
|
|
79
|
+
cli_params = pair_args(args)
|
|
80
|
+
[page_name, file_params.merge(cli_params)]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.take_option(args, flag)
|
|
84
|
+
idx = args.index(flag)
|
|
85
|
+
return nil unless idx
|
|
86
|
+
|
|
87
|
+
value = args.delete_at(idx + 1)
|
|
88
|
+
args.delete_at(idx)
|
|
89
|
+
value
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.load_params_file(path)
|
|
93
|
+
Browserctl::Runner.load_params_file(path)
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
abort "Error loading params file: #{e.message}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.pair_args(args)
|
|
99
|
+
out = {}
|
|
100
|
+
args.each_slice(2) do |flag, val|
|
|
101
|
+
out[flag.sub(/\A--/, "").to_sym] = val
|
|
102
|
+
end
|
|
103
|
+
out
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.format_params(flow)
|
|
107
|
+
flow.param_defs.transform_values do |p|
|
|
108
|
+
entry = { required: p.required, secret: p.secret, default: p.default }
|
|
109
|
+
entry[:secret_ref] = p.secret_ref if p.secret_ref
|
|
110
|
+
entry
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.serialisable(value)
|
|
115
|
+
case value
|
|
116
|
+
when nil, true, false, Numeric, String, Hash, Array then value
|
|
117
|
+
when Symbol then value.to_s
|
|
118
|
+
else value.respond_to?(:to_h) ? value.to_h : value.to_s
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
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
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "cli_output"
|
|
6
|
+
|
|
7
|
+
module Browserctl
|
|
8
|
+
module Commands
|
|
9
|
+
# `browserctl state` — top-level command for portable, encrypted, origin-
|
|
10
|
+
# scoped browser state. Wraps the daemon's state_* RPCs.
|
|
11
|
+
module State
|
|
12
|
+
extend CliOutput
|
|
13
|
+
|
|
14
|
+
USAGE = "Usage: browserctl state <save|load|list|info|delete|rotate|export|import> [args]"
|
|
15
|
+
|
|
16
|
+
DAEMON_SUBCOMMANDS = {
|
|
17
|
+
"save" => :run_save, "load" => :run_load, "list" => :run_list,
|
|
18
|
+
"info" => :run_info, "delete" => :run_delete, "rotate" => :run_rotate
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
LOCAL_SUBCOMMANDS = { "export" => :run_export, "import" => :run_import }.freeze
|
|
22
|
+
|
|
23
|
+
def self.run(client, args)
|
|
24
|
+
sub = args.shift or abort USAGE
|
|
25
|
+
|
|
26
|
+
if (m = DAEMON_SUBCOMMANDS[sub])
|
|
27
|
+
sub == "list" ? send(m, client) : send(m, client, args)
|
|
28
|
+
elsif (m = LOCAL_SUBCOMMANDS[sub])
|
|
29
|
+
send(m, args)
|
|
30
|
+
else
|
|
31
|
+
abort "unknown state subcommand '#{sub}'\n#{USAGE}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.run_save(client, args)
|
|
36
|
+
encrypt = args.delete("--encrypt")
|
|
37
|
+
origins = extract_value!(args, "--origins")
|
|
38
|
+
flow = extract_value!(args, "--flow")
|
|
39
|
+
name = args.shift or abort "usage: browserctl state save <name> [--encrypt] " \
|
|
40
|
+
"[--origins a,b] [--flow NAME]"
|
|
41
|
+
|
|
42
|
+
passphrase = encrypt ? prompt_passphrase(confirm: true) : nil
|
|
43
|
+
origin_list = parse_origins(origins)
|
|
44
|
+
|
|
45
|
+
print_result(client.state_save(name, origins: origin_list, flow: flow, passphrase: passphrase))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.parse_origins(value)
|
|
49
|
+
return nil unless value
|
|
50
|
+
|
|
51
|
+
value.split(",").map(&:strip).reject(&:empty?)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.run_load(client, args)
|
|
55
|
+
name = args.shift or abort "usage: browserctl state load <name>"
|
|
56
|
+
passphrase = state_needs_passphrase?(client, name) ? prompt_passphrase : nil
|
|
57
|
+
print_result(client.state_load(name, passphrase: passphrase))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.run_list(client)
|
|
61
|
+
print_result(client.state_list)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.run_info(client, args)
|
|
65
|
+
name = args.shift or abort "usage: browserctl state info <name>"
|
|
66
|
+
print_result(client.state_info(name))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.run_delete(client, args)
|
|
70
|
+
name = args.shift or abort "usage: browserctl state delete <name>"
|
|
71
|
+
print_result(client.state_delete(name))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Re-runs the flow bound to <name> and re-saves the bundle. The flow is
|
|
75
|
+
# read from the manifest (set when the bundle was originally produced
|
|
76
|
+
# via `state save --flow ...`). Params come from --params or k=v pairs.
|
|
77
|
+
def self.run_rotate(client, args)
|
|
78
|
+
require "browserctl/flow_registry"
|
|
79
|
+
page_name = extract_value!(args, "--page")
|
|
80
|
+
params_path = extract_value!(args, "--params")
|
|
81
|
+
name = args.shift or abort "usage: browserctl state rotate <name> " \
|
|
82
|
+
"[--page NAME] [--params FILE] [--key value ...]"
|
|
83
|
+
|
|
84
|
+
manifest = read_manifest!(client, name)
|
|
85
|
+
flow = resolve_bound_flow!(manifest)
|
|
86
|
+
params = build_rotate_params(params_path, args)
|
|
87
|
+
page_proxy = page_name ? Browserctl::PageProxy.new(page_name, client) : nil
|
|
88
|
+
|
|
89
|
+
flow.run(page: page_proxy, client: client, **params)
|
|
90
|
+
|
|
91
|
+
save_result = client.state_save(name,
|
|
92
|
+
flow: flow.name,
|
|
93
|
+
flow_version: flow.version_string,
|
|
94
|
+
origins: manifest[:origins])
|
|
95
|
+
print_result(save_result.merge(rotated_flow: flow.name))
|
|
96
|
+
rescue Browserctl::FlowError => e
|
|
97
|
+
warn "Error: #{e.message}"
|
|
98
|
+
exit 1
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.read_manifest!(client, name)
|
|
102
|
+
info = client.state_info(name)
|
|
103
|
+
abort "Error: #{info[:error] || info['error']}" if info[:error] || info["error"]
|
|
104
|
+
|
|
105
|
+
info[:info] || info["info"] || {}
|
|
106
|
+
end
|
|
107
|
+
private_class_method :read_manifest!
|
|
108
|
+
|
|
109
|
+
def self.resolve_bound_flow!(manifest)
|
|
110
|
+
flow_name = manifest[:flow] || manifest["flow"]
|
|
111
|
+
abort "Error: state has no bound flow — re-save with `state save --flow NAME` first" if flow_name.nil? ||
|
|
112
|
+
flow_name.to_s.empty?
|
|
113
|
+
|
|
114
|
+
flow = Browserctl::FlowRegistry.resolve(flow_name)
|
|
115
|
+
abort "Error: flow '#{flow_name}' not found in registry" unless flow
|
|
116
|
+
|
|
117
|
+
flow
|
|
118
|
+
end
|
|
119
|
+
private_class_method :resolve_bound_flow!
|
|
120
|
+
|
|
121
|
+
def self.build_rotate_params(params_path, args)
|
|
122
|
+
require "browserctl/runner"
|
|
123
|
+
file_params = params_path ? Browserctl::Runner.load_params_file(params_path) : {}
|
|
124
|
+
cli_params = args.each_slice(2).to_h { |flag, val| [flag.to_s.sub(/\A--/, "").to_sym, val] }
|
|
125
|
+
file_params.merge(cli_params)
|
|
126
|
+
end
|
|
127
|
+
private_class_method :build_rotate_params
|
|
128
|
+
|
|
129
|
+
def self.run_export(args)
|
|
130
|
+
name = args.shift or abort "usage: browserctl state export <name> <destination>"
|
|
131
|
+
destination = args.shift or abort "usage: browserctl state export <name> <destination>"
|
|
132
|
+
require "browserctl/state"
|
|
133
|
+
result = Browserctl::State.export(name, destination)
|
|
134
|
+
puts result.to_json
|
|
135
|
+
rescue Browserctl::State::Transport::TransportError, Browserctl::Error, ArgumentError => e
|
|
136
|
+
warn "Error: #{e.message}"
|
|
137
|
+
exit 1
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.run_import(args)
|
|
141
|
+
name_override = extract_value!(args, "--name")
|
|
142
|
+
source = args.shift or abort "usage: browserctl state import <source> [--name NAME]"
|
|
143
|
+
require "browserctl/state"
|
|
144
|
+
result = Browserctl::State.import(source, name: name_override)
|
|
145
|
+
puts result.to_json
|
|
146
|
+
rescue Browserctl::State::Transport::TransportError,
|
|
147
|
+
Browserctl::State::Bundle::BundleError,
|
|
148
|
+
Browserctl::Error, ArgumentError => e
|
|
149
|
+
warn "Error: #{e.message}"
|
|
150
|
+
exit 1
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private_class_method :parse_origins
|
|
154
|
+
|
|
155
|
+
def self.extract_value!(args, flag)
|
|
156
|
+
idx = args.index(flag)
|
|
157
|
+
return nil unless idx
|
|
158
|
+
|
|
159
|
+
args.delete_at(idx)
|
|
160
|
+
args.delete_at(idx) or abort "missing value for #{flag}"
|
|
161
|
+
end
|
|
162
|
+
private_class_method :extract_value!
|
|
163
|
+
|
|
164
|
+
def self.prompt_passphrase(confirm: false)
|
|
165
|
+
return ENV["BROWSERCTL_STATE_PASSPHRASE"] if ENV["BROWSERCTL_STATE_PASSPHRASE"]
|
|
166
|
+
|
|
167
|
+
$stderr.print "Passphrase: "
|
|
168
|
+
pass = $stdin.noecho(&:gets).to_s.chomp
|
|
169
|
+
$stderr.puts
|
|
170
|
+
|
|
171
|
+
if confirm
|
|
172
|
+
$stderr.print "Confirm passphrase: "
|
|
173
|
+
confirm_pass = $stdin.noecho(&:gets).to_s.chomp
|
|
174
|
+
$stderr.puts
|
|
175
|
+
abort "Passphrases do not match." unless pass == confirm_pass
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
pass
|
|
179
|
+
end
|
|
180
|
+
private_class_method :prompt_passphrase
|
|
181
|
+
|
|
182
|
+
# Peek at the manifest first so we only prompt for a passphrase when needed.
|
|
183
|
+
def self.state_needs_passphrase?(client, name)
|
|
184
|
+
info = client.state_info(name)
|
|
185
|
+
return false if info[:error] || info["error"]
|
|
186
|
+
|
|
187
|
+
manifest = info[:info] || info["info"] || {}
|
|
188
|
+
manifest[:encrypted] || manifest["encrypted"] || false
|
|
189
|
+
end
|
|
190
|
+
private_class_method :state_needs_passphrase?
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "../logger"
|
|
6
|
+
require_relative "../redactor"
|
|
7
|
+
require_relative "../secret_resolver_registry"
|
|
8
|
+
|
|
9
|
+
module Browserctl
|
|
10
|
+
module Commands
|
|
11
|
+
# `browserctl trace [<session>] [--no-redact]` — pretty timeline of
|
|
12
|
+
# structured log events across cli.log + daemon.log. Defaults to most
|
|
13
|
+
# recent session.
|
|
14
|
+
#
|
|
15
|
+
# Loose categorisation by inspecting common keys (event/snapshot/request/
|
|
16
|
+
# error). No schema is enforced — this command is tolerant of any JSONL
|
|
17
|
+
# produced by Browserctl::JsonlFormatter.
|
|
18
|
+
#
|
|
19
|
+
# Redaction: ON by default. Secret values are sourced from current ENV
|
|
20
|
+
# patterns (`*_TOKEN`, `*_KEY`, `*_SECRET`, `*_PASSWORD`) and any values
|
|
21
|
+
# captured by `SecretResolverRegistry` during this process. Pass
|
|
22
|
+
# `--no-redact` to disable (local debugging only). Note: when replaying
|
|
23
|
+
# historical traces from a previous process, registry-captured values are
|
|
24
|
+
# gone — only current ENV patterns apply.
|
|
25
|
+
module Trace
|
|
26
|
+
USAGE = "Usage: browserctl trace [<session>] [--no-redact]"
|
|
27
|
+
NO_REDACT_WARNING = "[browserctl] traces include unredacted secret values; " \
|
|
28
|
+
"do not paste this output publicly."
|
|
29
|
+
|
|
30
|
+
LEVEL_COLORS = {
|
|
31
|
+
"DEBUG" => "\e[2;37m", # dim grey
|
|
32
|
+
"INFO" => "\e[36m", # cyan
|
|
33
|
+
"WARN" => "\e[33m", # yellow
|
|
34
|
+
"ERROR" => "\e[31m" # red
|
|
35
|
+
}.freeze
|
|
36
|
+
RESET = "\e[0m"
|
|
37
|
+
|
|
38
|
+
CATEGORY_ICONS = {
|
|
39
|
+
error: "!",
|
|
40
|
+
snapshot: "S",
|
|
41
|
+
network: "N",
|
|
42
|
+
event: "."
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
OMIT_KEYS = %w[ts level component event msg].freeze
|
|
46
|
+
|
|
47
|
+
def self.run(args, log_dir: Browserctl.log_dir, out: $stdout, err: $stderr)
|
|
48
|
+
abort USAGE if args.include?("-h") || args.include?("--help")
|
|
49
|
+
args = args.dup
|
|
50
|
+
redact = !args.delete("--no-redact")
|
|
51
|
+
session_filter = args.shift
|
|
52
|
+
|
|
53
|
+
if redact
|
|
54
|
+
redactor = build_redactor
|
|
55
|
+
else
|
|
56
|
+
redactor = nil
|
|
57
|
+
warn_no_redact(err)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
records = load_records(log_dir)
|
|
61
|
+
if records.empty?
|
|
62
|
+
out.puts "No log entries found in #{log_dir}"
|
|
63
|
+
return
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
records = filter_session(records, session_filter)
|
|
67
|
+
if records.empty?
|
|
68
|
+
out.puts "No entries match session=#{session_filter}"
|
|
69
|
+
return
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
render(records, out: out, redactor: redactor)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.build_redactor
|
|
76
|
+
extra = if defined?(Browserctl::SecretResolverRegistry)
|
|
77
|
+
Browserctl::SecretResolverRegistry.resolved_values
|
|
78
|
+
else
|
|
79
|
+
[]
|
|
80
|
+
end
|
|
81
|
+
Browserctl::Redactor.from_env(extra: extra)
|
|
82
|
+
rescue StandardError
|
|
83
|
+
Browserctl::Redactor.new(secrets: [])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.warn_no_redact(err)
|
|
87
|
+
err&.puts NO_REDACT_WARNING
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.load_records(log_dir)
|
|
91
|
+
paths = Dir.glob(File.join(log_dir, "{cli,daemon}.log"))
|
|
92
|
+
records = paths.flat_map do |path|
|
|
93
|
+
File.foreach(path).filter_map { |line| parse_line(line) }
|
|
94
|
+
end
|
|
95
|
+
records.sort_by { |r| r["ts"].to_s }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.parse_line(line)
|
|
99
|
+
line = line.strip
|
|
100
|
+
return nil if line.empty?
|
|
101
|
+
|
|
102
|
+
JSON.parse(line)
|
|
103
|
+
rescue JSON::ParserError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Session resolution. When session_id is stamped on records (future PR),
|
|
108
|
+
# filter/select by it. Otherwise, treat the entire merged stream as one
|
|
109
|
+
# session — caller can scope by tailing/rotating logs.
|
|
110
|
+
# TODO: stamp session_id on every log line so this scopes correctly.
|
|
111
|
+
def self.filter_session(records, session_filter)
|
|
112
|
+
if session_filter
|
|
113
|
+
records.select { |r| r["session_id"].to_s == session_filter }
|
|
114
|
+
else
|
|
115
|
+
ids = records.map { |r| r["session_id"] }.compact.uniq
|
|
116
|
+
if ids.empty?
|
|
117
|
+
records
|
|
118
|
+
else
|
|
119
|
+
recent = ids.last
|
|
120
|
+
records.select { |r| r["session_id"] == recent }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.render(records, out:, redactor: nil)
|
|
126
|
+
tty = out.respond_to?(:tty?) && out.tty?
|
|
127
|
+
records.each { |r| out.puts(format_line(r, tty: tty, redactor: redactor)) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.format_line(record, tty:, redactor: nil)
|
|
131
|
+
level = (record["level"] || "INFO").to_s
|
|
132
|
+
line = format("%-12<ts>s %<icon>s %-5<level>s %-7<comp>s %-22<label>s %<ctx>s",
|
|
133
|
+
ts: format_ts(record["ts"]),
|
|
134
|
+
icon: CATEGORY_ICONS.fetch(categorise(record), "."),
|
|
135
|
+
level: level,
|
|
136
|
+
comp: (record["component"] || "?").to_s,
|
|
137
|
+
label: event_label(record),
|
|
138
|
+
ctx: context_snippet(record)).rstrip
|
|
139
|
+
|
|
140
|
+
line = redactor.redact(line) if redactor
|
|
141
|
+
tty ? colourise(line, level) : line
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def self.format_ts(timestamp)
|
|
145
|
+
Time.iso8601(timestamp.to_s).strftime("%H:%M:%S.%L")
|
|
146
|
+
rescue ArgumentError, TypeError
|
|
147
|
+
"??:??:??.???"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.categorise(record)
|
|
151
|
+
return :error if record["level"] == "ERROR" || record["error"]
|
|
152
|
+
return :snapshot if record["snapshot"]
|
|
153
|
+
return :network if record["request"] || record["response"] || record["url"]
|
|
154
|
+
|
|
155
|
+
:event
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.event_label(record)
|
|
159
|
+
(record["event"] || record["snapshot"] || record["request"] ||
|
|
160
|
+
record["msg"] || "-").to_s.slice(0, 22)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Compact "k=v k=v" snippet of remaining structured keys, capped to keep
|
|
164
|
+
# the timeline scannable. Skips fields already shown in fixed columns.
|
|
165
|
+
def self.context_snippet(record)
|
|
166
|
+
pairs = record.except(*OMIT_KEYS)
|
|
167
|
+
return "" if pairs.empty?
|
|
168
|
+
|
|
169
|
+
pairs.map { |k, v| "#{k}=#{format_value(v)}" }.join(" ").slice(0, 120)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.format_value(value)
|
|
173
|
+
case value
|
|
174
|
+
when String then value.length > 40 ? "#{value[0, 37]}..." : value
|
|
175
|
+
when Array then "[#{value.length}]"
|
|
176
|
+
when Hash then "{#{value.keys.length}}"
|
|
177
|
+
else value.to_s
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def self.colourise(line, level)
|
|
182
|
+
colour = LEVEL_COLORS[level] || ""
|
|
183
|
+
"#{colour}#{line}#{RESET}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|