browserctl 0.13.1 → 0.14.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 +7 -0
- data/lib/browserctl/callable_definition.rb +7 -1
- data/lib/browserctl/client/recording_interceptor.rb +39 -0
- data/lib/browserctl/client.rb +45 -10
- data/lib/browserctl/commands/passphrase_prompt.rb +44 -0
- data/lib/browserctl/commands/state.rb +18 -95
- data/lib/browserctl/commands/trace.rb +23 -163
- data/lib/browserctl/driver/cdp.rb +5 -1
- data/lib/browserctl/error/codes.rb +17 -0
- data/lib/browserctl/error/exit_codes.rb +12 -1
- data/lib/browserctl/error/suggested_actions.rb +11 -0
- data/lib/browserctl/flow.rb +46 -9
- data/lib/browserctl/flow_registry.rb +5 -1
- data/lib/browserctl/flows/stdlib/totp_2fa.rb +5 -1
- data/lib/browserctl/format_version.rb +7 -1
- data/lib/browserctl/logger.rb +2 -1
- data/lib/browserctl/migrations.rb +8 -1
- data/lib/browserctl/rubocop/cops/typed_error.rb +27 -18
- data/lib/browserctl/server/command_dispatcher.rb +1 -1
- data/lib/browserctl/server/handlers/navigation.rb +5 -1
- data/lib/browserctl/server/handlers/state.rb +1 -1
- data/lib/browserctl/state/mutator.rb +73 -0
- data/lib/browserctl/state.rb +5 -1
- data/lib/browserctl/trace/event_stream.rb +75 -0
- data/lib/browserctl/trace/renderer.rb +107 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +6 -3
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '08df95ac04f1480c4e39d857c1472342b8b256a2047cb83808ba62daaf184330'
|
|
4
|
+
data.tar.gz: 1f67c6b991dd20af3907ab9a803f1021817e6d9b5dbe1774e42f0c509d907067
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5752121da2c9a9551773b6ab8b9c4f060744f14a6f505d7c38ab9338a99b2cda4012a22869b633be883ad1c810bb717e0edb77da8ef4eb3b7bf8eef19593efde
|
|
7
|
+
data.tar.gz: 4c44a6cfbb1b4402e30e1911c26bdfb608d3584ba463623fd8c75638ad836dcb428fcb0d99d58d37c2e557b0752c84f288288bad800667e18300cb36ace2570c
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,13 @@ All notable changes to this project will be documented in this file.
|
|
|
10
10
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
11
11
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
12
12
|
|
|
13
|
+
## [0.14.0](https://github.com/patrick204nqh/browserctl/compare/v0.13.1...v0.14.0) (2026-05-11)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* validation error codes (v0.14 WS-1 PR 1) ([#179](https://github.com/patrick204nqh/browserctl/issues/179)) ([5475272](https://github.com/patrick204nqh/browserctl/commit/547527264c606e70d0c5509b58a42a10a7a93470))
|
|
19
|
+
|
|
13
20
|
## [0.13.1](https://github.com/patrick204nqh/browserctl/compare/v0.13.0...v0.13.1) (2026-05-11)
|
|
14
21
|
|
|
15
22
|
|
|
@@ -48,7 +48,13 @@ module Browserctl
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def step(label, retry_count: 0, timeout: nil, &block)
|
|
51
|
-
|
|
51
|
+
unless block
|
|
52
|
+
raise Browserctl::Error.new(
|
|
53
|
+
"#{callable_kind} step '#{label}' requires a block",
|
|
54
|
+
code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
|
|
55
|
+
context: { dsl: callable_kind, action: :step, label: label }
|
|
56
|
+
)
|
|
57
|
+
end
|
|
52
58
|
|
|
53
59
|
@steps << StepDef.new(label: label, block: block, retry_count: retry_count, timeout: timeout)
|
|
54
60
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../recording"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
class Client
|
|
7
|
+
# Bridges Client#call to the Recording subsystem. Keeps Client itself a
|
|
8
|
+
# pure IPC shim — Recording is pluggable via constructor injection.
|
|
9
|
+
class RecordingInterceptor
|
|
10
|
+
def initialize(recording: Browserctl::Recording)
|
|
11
|
+
@recording = recording
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Whether recording is currently active. Call sites that need to vary
|
|
15
|
+
# their request shape (e.g. click/fill passing capture_post_snapshot:)
|
|
16
|
+
# can ask without touching Recording directly.
|
|
17
|
+
def active?
|
|
18
|
+
@recording.active
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns `true` when active, `nil` otherwise. Matches the shape that
|
|
22
|
+
# click/fill historically passed as the `capture_post_snapshot` param.
|
|
23
|
+
def capture_post_snapshot_flag
|
|
24
|
+
return true if active?
|
|
25
|
+
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Called after a successful Client#call to append the command and
|
|
30
|
+
# response to the active recording log. No-op if the response was
|
|
31
|
+
# not ok (recording only captures successful interactions).
|
|
32
|
+
def append(cmd, response:, params: {})
|
|
33
|
+
return unless response[:ok]
|
|
34
|
+
|
|
35
|
+
@recording.append(cmd, response: response, **params)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/browserctl/client.rb
CHANGED
|
@@ -4,18 +4,19 @@ require "fileutils"
|
|
|
4
4
|
require "socket"
|
|
5
5
|
require "json"
|
|
6
6
|
require_relative "constants"
|
|
7
|
-
require_relative "
|
|
7
|
+
require_relative "client/recording_interceptor"
|
|
8
8
|
|
|
9
9
|
module Browserctl
|
|
10
10
|
# Thin IPC client that wraps each browserd command as a Ruby method call.
|
|
11
11
|
class Client
|
|
12
|
-
def initialize(socket_path = nil)
|
|
12
|
+
def initialize(socket_path = nil, recording_interceptor: nil)
|
|
13
13
|
@socket_path = socket_path || auto_discover_socket
|
|
14
|
+
@recording_interceptor = recording_interceptor
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def call(cmd, **params)
|
|
17
18
|
result = communicate(JSON.generate({ cmd: cmd }.merge(params)))
|
|
18
|
-
|
|
19
|
+
recording_interceptor.append(cmd, response: result, params: params)
|
|
19
20
|
result
|
|
20
21
|
rescue Errno::ENOENT, Errno::ECONNREFUSED
|
|
21
22
|
raise DaemonUnavailableError, "browserd is not running — start it with: browserd"
|
|
@@ -48,10 +49,16 @@ module Browserctl
|
|
|
48
49
|
# @param ref [String, nil] snapshot ref (e.g. "e3")
|
|
49
50
|
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
50
51
|
def click(name, selector = nil, ref: nil)
|
|
51
|
-
|
|
52
|
+
unless selector || ref
|
|
53
|
+
raise Browserctl::Error.new(
|
|
54
|
+
"click: provide selector or ref",
|
|
55
|
+
code: Browserctl::Error::Codes::INVALID_SELECTOR_REF,
|
|
56
|
+
context: { method: :click, name: name }
|
|
57
|
+
)
|
|
58
|
+
end
|
|
52
59
|
|
|
53
60
|
call("click", name: name, selector: selector, ref: ref,
|
|
54
|
-
capture_post_snapshot:
|
|
61
|
+
capture_post_snapshot: recording_interceptor.capture_post_snapshot_flag)
|
|
55
62
|
end
|
|
56
63
|
|
|
57
64
|
# Fills an input element with a value.
|
|
@@ -61,10 +68,16 @@ module Browserctl
|
|
|
61
68
|
# @param ref [String, nil] snapshot ref
|
|
62
69
|
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
63
70
|
def fill(name, selector = nil, value = nil, ref: nil)
|
|
64
|
-
|
|
71
|
+
unless selector || ref
|
|
72
|
+
raise Browserctl::Error.new(
|
|
73
|
+
"fill: provide selector or ref",
|
|
74
|
+
code: Browserctl::Error::Codes::INVALID_SELECTOR_REF,
|
|
75
|
+
context: { method: :fill, name: name }
|
|
76
|
+
)
|
|
77
|
+
end
|
|
65
78
|
|
|
66
79
|
call("fill", name: name, selector: selector, ref: ref, value: value,
|
|
67
|
-
capture_post_snapshot:
|
|
80
|
+
capture_post_snapshot: recording_interceptor.capture_post_snapshot_flag)
|
|
68
81
|
end
|
|
69
82
|
|
|
70
83
|
# Takes a screenshot of a named page.
|
|
@@ -243,7 +256,13 @@ module Browserctl
|
|
|
243
256
|
# @param selector [String] CSS selector
|
|
244
257
|
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
245
258
|
def hover(name, selector = nil, ref: nil)
|
|
246
|
-
|
|
259
|
+
unless selector || ref
|
|
260
|
+
raise Browserctl::Error.new(
|
|
261
|
+
"hover: provide selector or ref",
|
|
262
|
+
code: Browserctl::Error::Codes::INVALID_SELECTOR_REF,
|
|
263
|
+
context: { method: :hover, name: name }
|
|
264
|
+
)
|
|
265
|
+
end
|
|
247
266
|
|
|
248
267
|
call("hover", name: name, selector: selector, ref: ref)
|
|
249
268
|
end
|
|
@@ -255,7 +274,13 @@ module Browserctl
|
|
|
255
274
|
# @param ref [String, nil] element ref from a prior snapshot
|
|
256
275
|
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
257
276
|
def upload(name, selector = nil, path = nil, ref: nil)
|
|
258
|
-
|
|
277
|
+
unless selector || ref
|
|
278
|
+
raise Browserctl::Error.new(
|
|
279
|
+
"upload: provide selector or ref",
|
|
280
|
+
code: Browserctl::Error::Codes::INVALID_SELECTOR_REF,
|
|
281
|
+
context: { method: :upload, name: name }
|
|
282
|
+
)
|
|
283
|
+
end
|
|
259
284
|
|
|
260
285
|
call("upload", name: name, selector: selector, ref: ref, path: path)
|
|
261
286
|
end
|
|
@@ -267,7 +292,13 @@ module Browserctl
|
|
|
267
292
|
# @param ref [String, nil] element ref from a prior snapshot
|
|
268
293
|
# @return [Hash] `{ ok: true }` or `{ error: }`
|
|
269
294
|
def select(name, selector = nil, value = nil, ref: nil)
|
|
270
|
-
|
|
295
|
+
unless selector || ref
|
|
296
|
+
raise Browserctl::Error.new(
|
|
297
|
+
"select: provide selector or ref",
|
|
298
|
+
code: Browserctl::Error::Codes::INVALID_SELECTOR_REF,
|
|
299
|
+
context: { method: :select, name: name }
|
|
300
|
+
)
|
|
301
|
+
end
|
|
271
302
|
|
|
272
303
|
call("select", name: name, selector: selector, ref: ref, value: value)
|
|
273
304
|
end
|
|
@@ -327,6 +358,10 @@ module Browserctl
|
|
|
327
358
|
|
|
328
359
|
private
|
|
329
360
|
|
|
361
|
+
def recording_interceptor
|
|
362
|
+
@recording_interceptor ||= RecordingInterceptor.new
|
|
363
|
+
end
|
|
364
|
+
|
|
330
365
|
def auto_discover_socket
|
|
331
366
|
default = Browserctl.socket_path
|
|
332
367
|
return default if File.exist?(default)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Commands
|
|
7
|
+
# Stdin/stderr passphrase prompting for `browserctl state` commands.
|
|
8
|
+
# Honours `BROWSERCTL_STATE_PASSPHRASE` for non-interactive use; otherwise
|
|
9
|
+
# reads from a tty with echo disabled and optional confirmation.
|
|
10
|
+
module PassphrasePrompt
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# @return [String]
|
|
14
|
+
def read(confirm: false)
|
|
15
|
+
return ENV["BROWSERCTL_STATE_PASSPHRASE"] if ENV["BROWSERCTL_STATE_PASSPHRASE"]
|
|
16
|
+
|
|
17
|
+
pass = ask("Passphrase: ")
|
|
18
|
+
if confirm
|
|
19
|
+
confirm_pass = ask("Confirm passphrase: ")
|
|
20
|
+
abort "Passphrases do not match." unless pass == confirm_pass
|
|
21
|
+
end
|
|
22
|
+
pass
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Peek at the manifest first so we only prompt when the bundle is
|
|
26
|
+
# actually encrypted.
|
|
27
|
+
def needed_for?(client, name)
|
|
28
|
+
info = client.state_info(name)
|
|
29
|
+
return false if info[:error] || info["error"]
|
|
30
|
+
|
|
31
|
+
manifest = info[:info] || info["info"] || {}
|
|
32
|
+
manifest[:encrypted] || manifest["encrypted"] || false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def ask(label)
|
|
36
|
+
$stderr.print(label)
|
|
37
|
+
value = $stdin.noecho(&:gets).to_s.chomp
|
|
38
|
+
$stderr.puts
|
|
39
|
+
value
|
|
40
|
+
end
|
|
41
|
+
private_class_method :ask
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "io/console"
|
|
4
3
|
require "json"
|
|
5
4
|
require_relative "cli_output"
|
|
6
5
|
require_relative "output_format"
|
|
6
|
+
require_relative "passphrase_prompt"
|
|
7
7
|
|
|
8
8
|
module Browserctl
|
|
9
9
|
module Commands
|
|
@@ -23,7 +23,6 @@ module Browserctl
|
|
|
23
23
|
|
|
24
24
|
def self.run(client, args)
|
|
25
25
|
sub = args.shift or abort USAGE
|
|
26
|
-
|
|
27
26
|
if (m = DAEMON_SUBCOMMANDS[sub])
|
|
28
27
|
sub == "list" ? send(m, client) : send(m, client, args)
|
|
29
28
|
elsif (m = LOCAL_SUBCOMMANDS[sub])
|
|
@@ -39,22 +38,14 @@ module Browserctl
|
|
|
39
38
|
flow = extract_value!(args, "--flow")
|
|
40
39
|
name = args.shift or abort "usage: browserctl state save <name> [--encrypt] " \
|
|
41
40
|
"[--origins a,b] [--flow NAME]"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
origin_list = parse_origins(origins)
|
|
45
|
-
|
|
41
|
+
passphrase = encrypt ? PassphrasePrompt.read(confirm: true) : nil
|
|
42
|
+
origin_list = origins ? origins.split(",").map(&:strip).reject(&:empty?) : nil
|
|
46
43
|
print_result(client.state_save(name, origins: origin_list, flow: flow, passphrase: passphrase))
|
|
47
44
|
end
|
|
48
45
|
|
|
49
|
-
def self.parse_origins(value)
|
|
50
|
-
return nil unless value
|
|
51
|
-
|
|
52
|
-
value.split(",").map(&:strip).reject(&:empty?)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
46
|
def self.run_load(client, args)
|
|
56
47
|
name = args.shift or abort "usage: browserctl state load <name>"
|
|
57
|
-
passphrase =
|
|
48
|
+
passphrase = PassphrasePrompt.needed_for?(client, name) ? PassphrasePrompt.read : nil
|
|
58
49
|
print_result(client.state_load(name, passphrase: passphrase))
|
|
59
50
|
end
|
|
60
51
|
|
|
@@ -72,67 +63,33 @@ module Browserctl
|
|
|
72
63
|
print_result(client.state_delete(name))
|
|
73
64
|
end
|
|
74
65
|
|
|
75
|
-
# Re-runs the flow bound to <name> and re-saves
|
|
76
|
-
#
|
|
77
|
-
# via `state save --flow ...`). Params come from --params or k=v pairs.
|
|
66
|
+
# Re-runs the flow bound to <name> and re-saves it. Mutation logic
|
|
67
|
+
# lives in {Browserctl::State::Mutator}; this method is CLI plumbing.
|
|
78
68
|
def self.run_rotate(client, args)
|
|
79
|
-
require "browserctl/
|
|
69
|
+
require "browserctl/state/mutator"
|
|
70
|
+
require "browserctl/runner"
|
|
80
71
|
page_name = extract_value!(args, "--page")
|
|
81
72
|
params_path = extract_value!(args, "--params")
|
|
82
73
|
name = args.shift or abort "usage: browserctl state rotate <name> " \
|
|
83
74
|
"[--page NAME] [--params FILE] [--key value ...]"
|
|
84
75
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
page_proxy = page_name ? Browserctl::PageProxy.new(page_name, client) : nil
|
|
89
|
-
|
|
90
|
-
flow.run(page: page_proxy, client: client, **params)
|
|
76
|
+
file_params = params_path ? Browserctl::Runner.load_params_file(params_path) : {}
|
|
77
|
+
cli_params = args.each_slice(2).to_h { |flag, val| [flag.to_s.sub(/\A--/, "").to_sym, val] }
|
|
78
|
+
page = page_name ? Browserctl::PageProxy.new(page_name, client) : nil
|
|
91
79
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
origins: manifest[:origins])
|
|
96
|
-
print_result(save_result.merge(rotated_flow: flow.name))
|
|
80
|
+
result = Browserctl::State::Mutator.new(client: client)
|
|
81
|
+
.rotate(name: name, params: file_params.merge(cli_params), page: page)
|
|
82
|
+
print_result(result.to_h)
|
|
97
83
|
rescue Browserctl::FlowError => e
|
|
98
84
|
warn "Error: #{e.message}"
|
|
99
85
|
exit 1
|
|
100
86
|
end
|
|
101
87
|
|
|
102
|
-
def self.read_manifest!(client, name)
|
|
103
|
-
info = client.state_info(name)
|
|
104
|
-
abort "Error: #{info[:error] || info['error']}" if info[:error] || info["error"]
|
|
105
|
-
|
|
106
|
-
info[:info] || info["info"] || {}
|
|
107
|
-
end
|
|
108
|
-
private_class_method :read_manifest!
|
|
109
|
-
|
|
110
|
-
def self.resolve_bound_flow!(manifest)
|
|
111
|
-
flow_name = manifest[:flow] || manifest["flow"]
|
|
112
|
-
abort "Error: state has no bound flow — re-save with `state save --flow NAME` first" if flow_name.nil? ||
|
|
113
|
-
flow_name.to_s.empty?
|
|
114
|
-
|
|
115
|
-
flow = Browserctl::FlowRegistry.resolve(flow_name)
|
|
116
|
-
abort "Error: flow '#{flow_name}' not found in registry" unless flow
|
|
117
|
-
|
|
118
|
-
flow
|
|
119
|
-
end
|
|
120
|
-
private_class_method :resolve_bound_flow!
|
|
121
|
-
|
|
122
|
-
def self.build_rotate_params(params_path, args)
|
|
123
|
-
require "browserctl/runner"
|
|
124
|
-
file_params = params_path ? Browserctl::Runner.load_params_file(params_path) : {}
|
|
125
|
-
cli_params = args.each_slice(2).to_h { |flag, val| [flag.to_s.sub(/\A--/, "").to_sym, val] }
|
|
126
|
-
file_params.merge(cli_params)
|
|
127
|
-
end
|
|
128
|
-
private_class_method :build_rotate_params
|
|
129
|
-
|
|
130
88
|
def self.run_export(args)
|
|
131
89
|
name = args.shift or abort "usage: browserctl state export <name> <destination>"
|
|
132
90
|
destination = args.shift or abort "usage: browserctl state export <name> <destination>"
|
|
133
91
|
require "browserctl/state"
|
|
134
|
-
|
|
135
|
-
OutputFormat.current.emit(result)
|
|
92
|
+
OutputFormat.current.emit(Browserctl::State.export(name, destination))
|
|
136
93
|
rescue Browserctl::State::Transport::TransportError, Browserctl::Error, ArgumentError => e
|
|
137
94
|
warn "Error: #{e.message}"
|
|
138
95
|
exit 1
|
|
@@ -142,53 +99,19 @@ module Browserctl
|
|
|
142
99
|
name_override = extract_value!(args, "--name")
|
|
143
100
|
source = args.shift or abort "usage: browserctl state import <source> [--name NAME]"
|
|
144
101
|
require "browserctl/state"
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
rescue Browserctl::State::Transport::TransportError,
|
|
148
|
-
Browserctl::State::Bundle::BundleError,
|
|
102
|
+
OutputFormat.current.emit(Browserctl::State.import(source, name: name_override))
|
|
103
|
+
rescue Browserctl::State::Transport::TransportError, Browserctl::State::Bundle::BundleError,
|
|
149
104
|
Browserctl::Error, ArgumentError => e
|
|
150
105
|
warn "Error: #{e.message}"
|
|
151
106
|
exit 1
|
|
152
107
|
end
|
|
153
108
|
|
|
154
|
-
private_class_method :parse_origins
|
|
155
|
-
|
|
156
109
|
def self.extract_value!(args, flag)
|
|
157
|
-
idx = args.index(flag)
|
|
158
|
-
return nil unless idx
|
|
159
|
-
|
|
110
|
+
idx = args.index(flag) or return nil
|
|
160
111
|
args.delete_at(idx)
|
|
161
112
|
args.delete_at(idx) or abort "missing value for #{flag}"
|
|
162
113
|
end
|
|
163
114
|
private_class_method :extract_value!
|
|
164
|
-
|
|
165
|
-
def self.prompt_passphrase(confirm: false)
|
|
166
|
-
return ENV["BROWSERCTL_STATE_PASSPHRASE"] if ENV["BROWSERCTL_STATE_PASSPHRASE"]
|
|
167
|
-
|
|
168
|
-
$stderr.print "Passphrase: "
|
|
169
|
-
pass = $stdin.noecho(&:gets).to_s.chomp
|
|
170
|
-
$stderr.puts
|
|
171
|
-
|
|
172
|
-
if confirm
|
|
173
|
-
$stderr.print "Confirm passphrase: "
|
|
174
|
-
confirm_pass = $stdin.noecho(&:gets).to_s.chomp
|
|
175
|
-
$stderr.puts
|
|
176
|
-
abort "Passphrases do not match." unless pass == confirm_pass
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
pass
|
|
180
|
-
end
|
|
181
|
-
private_class_method :prompt_passphrase
|
|
182
|
-
|
|
183
|
-
# Peek at the manifest first so we only prompt for a passphrase when needed.
|
|
184
|
-
def self.state_needs_passphrase?(client, name)
|
|
185
|
-
info = client.state_info(name)
|
|
186
|
-
return false if info[:error] || info["error"]
|
|
187
|
-
|
|
188
|
-
manifest = info[:info] || info["info"] || {}
|
|
189
|
-
manifest[:encrypted] || manifest["encrypted"] || false
|
|
190
|
-
end
|
|
191
|
-
private_class_method :state_needs_passphrase?
|
|
192
115
|
end
|
|
193
116
|
end
|
|
194
117
|
end
|
|
@@ -1,112 +1,67 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require "time"
|
|
5
4
|
require_relative "../logger"
|
|
6
5
|
require_relative "../redactor"
|
|
7
6
|
require_relative "../secret_resolver_registry"
|
|
7
|
+
require_relative "../trace/event_stream"
|
|
8
|
+
require_relative "../trace/renderer"
|
|
8
9
|
require_relative "output_format"
|
|
9
10
|
|
|
10
11
|
module Browserctl
|
|
11
12
|
module Commands
|
|
12
13
|
# `browserctl trace [<session>] [--no-redact]` — pretty timeline of
|
|
13
|
-
# structured log events across cli.log + daemon.log.
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# Loose categorisation by inspecting common keys (event/snapshot/request/
|
|
17
|
-
# error). No schema is enforced — this command is tolerant of any JSONL
|
|
18
|
-
# produced by Browserctl::JsonlFormatter.
|
|
19
|
-
#
|
|
20
|
-
# Redaction: ON by default. Secret values are sourced from current ENV
|
|
21
|
-
# patterns (`*_TOKEN`, `*_KEY`, `*_SECRET`, `*_PASSWORD`) and any values
|
|
22
|
-
# captured by `SecretResolverRegistry` during this process. Pass
|
|
23
|
-
# `--no-redact` to disable (local debugging only). Note: when replaying
|
|
24
|
-
# historical traces from a previous process, registry-captured values are
|
|
25
|
-
# gone — only current ENV patterns apply.
|
|
14
|
+
# structured log events across cli.log + daemon.log. Thin CLI dispatcher;
|
|
15
|
+
# parsing lives in `Browserctl::Trace::EventStream`, rendering in
|
|
16
|
+
# `Browserctl::Trace::Renderer`, redaction in `Browserctl::Redactor`.
|
|
26
17
|
module Trace
|
|
27
18
|
USAGE = "Usage: browserctl trace [<session>] [--no-redact]"
|
|
28
19
|
NO_REDACT_WARNING = "[browserctl] traces include unredacted secret values; " \
|
|
29
20
|
"do not paste this output publicly."
|
|
30
21
|
|
|
31
|
-
LEVEL_COLORS = {
|
|
32
|
-
"DEBUG" => "\e[2;37m", # dim grey
|
|
33
|
-
"INFO" => "\e[36m", # cyan
|
|
34
|
-
"WARN" => "\e[33m", # yellow
|
|
35
|
-
"ERROR" => "\e[31m" # red
|
|
36
|
-
}.freeze
|
|
37
|
-
RESET = "\e[0m"
|
|
38
|
-
|
|
39
|
-
CATEGORY_ICONS = {
|
|
40
|
-
error: "!",
|
|
41
|
-
snapshot: "S",
|
|
42
|
-
network: "N",
|
|
43
|
-
event: "."
|
|
44
|
-
}.freeze
|
|
45
|
-
|
|
46
|
-
OMIT_KEYS = %w[ts level component event msg].freeze
|
|
47
|
-
|
|
48
22
|
def self.run(args, log_dir: Browserctl.log_dir, out: $stdout, err: $stderr)
|
|
49
23
|
abort USAGE if args.include?("-h") || args.include?("--help")
|
|
50
24
|
args = args.dup
|
|
51
25
|
redact = !args.delete("--no-redact")
|
|
52
26
|
session_filter = args.shift
|
|
53
|
-
redactor =
|
|
54
|
-
records = collect_records(log_dir, session_filter, out)
|
|
55
|
-
emit_records(records, redactor, out) if records
|
|
56
|
-
end
|
|
27
|
+
redactor = redact ? build_redactor : (warn_no_redact(err) || nil)
|
|
57
28
|
|
|
58
|
-
|
|
59
|
-
|
|
29
|
+
stream = Browserctl::Trace::EventStream.new(log_dir, session_filter: session_filter)
|
|
30
|
+
if stream.empty?
|
|
31
|
+
emit_empty(empty_message(log_dir, session_filter), out)
|
|
32
|
+
return
|
|
33
|
+
end
|
|
60
34
|
|
|
61
|
-
|
|
62
|
-
ensure
|
|
63
|
-
warn_no_redact(err) unless redact
|
|
35
|
+
emit(stream, redactor, out)
|
|
64
36
|
end
|
|
65
37
|
|
|
66
|
-
def self.
|
|
67
|
-
|
|
68
|
-
if
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
records = filter_session(records, session_filter)
|
|
74
|
-
if records.empty?
|
|
75
|
-
emit_empty("No entries match session=#{session_filter}", out)
|
|
76
|
-
return nil
|
|
38
|
+
def self.emit(stream, redactor, out)
|
|
39
|
+
fmt = OutputFormat.current
|
|
40
|
+
if fmt.json?
|
|
41
|
+
fmt.emit({ records: stream.records.map { |r| redact_record(r, redactor) } }, io: out)
|
|
42
|
+
elsif !fmt.silent?
|
|
43
|
+
Browserctl::Trace::Renderer.new(io: out, redactor: redactor).render(stream)
|
|
77
44
|
end
|
|
45
|
+
end
|
|
78
46
|
|
|
79
|
-
|
|
47
|
+
def self.empty_message(log_dir, session_filter)
|
|
48
|
+
session_filter ? "No entries match session=#{session_filter}" : "No log entries found in #{log_dir}"
|
|
80
49
|
end
|
|
81
50
|
|
|
82
51
|
def self.emit_empty(message, out)
|
|
83
52
|
OutputFormat.current.emit({ records: [], message: message }, message, io: out)
|
|
84
53
|
end
|
|
85
54
|
|
|
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)
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
|
|
95
55
|
def self.redact_record(record, redactor)
|
|
96
56
|
return record unless redactor
|
|
97
57
|
|
|
98
|
-
|
|
99
|
-
JSON.parse(redactor.redact(line))
|
|
58
|
+
JSON.parse(redactor.redact(JSON.generate(record)))
|
|
100
59
|
rescue JSON::ParserError
|
|
101
60
|
record
|
|
102
61
|
end
|
|
103
62
|
|
|
104
63
|
def self.build_redactor
|
|
105
|
-
extra =
|
|
106
|
-
Browserctl::SecretResolverRegistry.resolved_values
|
|
107
|
-
else
|
|
108
|
-
[]
|
|
109
|
-
end
|
|
64
|
+
extra = defined?(Browserctl::SecretResolverRegistry) ? Browserctl::SecretResolverRegistry.resolved_values : []
|
|
110
65
|
Browserctl::Redactor.from_env(extra: extra)
|
|
111
66
|
rescue StandardError
|
|
112
67
|
Browserctl::Redactor.new(secrets: [])
|
|
@@ -114,103 +69,8 @@ module Browserctl
|
|
|
114
69
|
|
|
115
70
|
def self.warn_no_redact(err)
|
|
116
71
|
err&.puts NO_REDACT_WARNING
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def self.load_records(log_dir)
|
|
120
|
-
paths = Dir.glob(File.join(log_dir, "{cli,daemon}.log"))
|
|
121
|
-
records = paths.flat_map do |path|
|
|
122
|
-
File.foreach(path).filter_map { |line| parse_line(line) }
|
|
123
|
-
end
|
|
124
|
-
records.sort_by { |r| r["ts"].to_s }
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def self.parse_line(line)
|
|
128
|
-
line = line.strip
|
|
129
|
-
return nil if line.empty?
|
|
130
|
-
|
|
131
|
-
JSON.parse(line)
|
|
132
|
-
rescue JSON::ParserError
|
|
133
72
|
nil
|
|
134
73
|
end
|
|
135
|
-
|
|
136
|
-
# Session resolution. When session_id is stamped on records (future PR),
|
|
137
|
-
# filter/select by it. Otherwise, treat the entire merged stream as one
|
|
138
|
-
# session — caller can scope by tailing/rotating logs.
|
|
139
|
-
# TODO: stamp session_id on every log line so this scopes correctly.
|
|
140
|
-
def self.filter_session(records, session_filter)
|
|
141
|
-
if session_filter
|
|
142
|
-
records.select { |r| r["session_id"].to_s == session_filter }
|
|
143
|
-
else
|
|
144
|
-
ids = records.map { |r| r["session_id"] }.compact.uniq
|
|
145
|
-
if ids.empty?
|
|
146
|
-
records
|
|
147
|
-
else
|
|
148
|
-
recent = ids.last
|
|
149
|
-
records.select { |r| r["session_id"] == recent }
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def self.render(records, out:, redactor: nil)
|
|
155
|
-
tty = out.respond_to?(:tty?) && out.tty?
|
|
156
|
-
records.each { |r| out.puts(format_line(r, tty: tty, redactor: redactor)) }
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def self.format_line(record, tty:, redactor: nil)
|
|
160
|
-
level = (record["level"] || "INFO").to_s
|
|
161
|
-
line = format("%-12<ts>s %<icon>s %-5<level>s %-7<comp>s %-22<label>s %<ctx>s",
|
|
162
|
-
ts: format_ts(record["ts"]),
|
|
163
|
-
icon: CATEGORY_ICONS.fetch(categorise(record), "."),
|
|
164
|
-
level: level,
|
|
165
|
-
comp: (record["component"] || "?").to_s,
|
|
166
|
-
label: event_label(record),
|
|
167
|
-
ctx: context_snippet(record)).rstrip
|
|
168
|
-
|
|
169
|
-
line = redactor.redact(line) if redactor
|
|
170
|
-
tty ? colourise(line, level) : line
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def self.format_ts(timestamp)
|
|
174
|
-
Time.iso8601(timestamp.to_s).strftime("%H:%M:%S.%L")
|
|
175
|
-
rescue ArgumentError, TypeError
|
|
176
|
-
"??:??:??.???"
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def self.categorise(record)
|
|
180
|
-
return :error if record["level"] == "ERROR" || record["error"]
|
|
181
|
-
return :snapshot if record["snapshot"]
|
|
182
|
-
return :network if record["request"] || record["response"] || record["url"]
|
|
183
|
-
|
|
184
|
-
:event
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
def self.event_label(record)
|
|
188
|
-
(record["event"] || record["snapshot"] || record["request"] ||
|
|
189
|
-
record["msg"] || "-").to_s.slice(0, 22)
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
# Compact "k=v k=v" snippet of remaining structured keys, capped to keep
|
|
193
|
-
# the timeline scannable. Skips fields already shown in fixed columns.
|
|
194
|
-
def self.context_snippet(record)
|
|
195
|
-
pairs = record.except(*OMIT_KEYS)
|
|
196
|
-
return "" if pairs.empty?
|
|
197
|
-
|
|
198
|
-
pairs.map { |k, v| "#{k}=#{format_value(v)}" }.join(" ").slice(0, 120)
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
def self.format_value(value)
|
|
202
|
-
case value
|
|
203
|
-
when String then value.length > 40 ? "#{value[0, 37]}..." : value
|
|
204
|
-
when Array then "[#{value.length}]"
|
|
205
|
-
when Hash then "{#{value.keys.length}}"
|
|
206
|
-
else value.to_s
|
|
207
|
-
end
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def self.colourise(line, level)
|
|
211
|
-
colour = LEVEL_COLORS[level] || ""
|
|
212
|
-
"#{colour}#{line}#{RESET}"
|
|
213
|
-
end
|
|
214
74
|
end
|
|
215
75
|
end
|
|
216
76
|
end
|
|
@@ -80,7 +80,11 @@ module Browserctl
|
|
|
80
80
|
when "brave"
|
|
81
81
|
resolve_brave_path
|
|
82
82
|
else
|
|
83
|
-
raise
|
|
83
|
+
raise Browserctl::Error.new(
|
|
84
|
+
"Unknown browser: #{@browser.inspect}",
|
|
85
|
+
code: Browserctl::Error::Codes::VALIDATION_FAILED,
|
|
86
|
+
context: { browser: @browser }
|
|
87
|
+
)
|
|
84
88
|
end
|
|
85
89
|
end
|
|
86
90
|
|