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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +2 -1
  4. data/bin/browserctl +168 -78
  5. data/bin/browserd +8 -1
  6. data/lib/browserctl/client.rb +50 -6
  7. data/lib/browserctl/commands/cli_output.rb +36 -3
  8. data/lib/browserctl/commands/flow.rb +123 -0
  9. data/lib/browserctl/commands/migrate.rb +94 -0
  10. data/lib/browserctl/commands/state.rb +193 -0
  11. data/lib/browserctl/commands/trace.rb +187 -0
  12. data/lib/browserctl/commands/workflow.rb +62 -4
  13. data/lib/browserctl/constants.rb +4 -2
  14. data/lib/browserctl/crash_report.rb +96 -0
  15. data/lib/browserctl/detectors/auth_required.rb +128 -0
  16. data/lib/browserctl/detectors.rb +2 -0
  17. data/lib/browserctl/error/codes.rb +44 -0
  18. data/lib/browserctl/error/exit_codes.rb +54 -0
  19. data/lib/browserctl/error/suggested_actions.rb +41 -0
  20. data/lib/browserctl/errors.rb +72 -12
  21. data/lib/browserctl/flow.rb +22 -1
  22. data/lib/browserctl/flow_registry.rb +66 -0
  23. data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
  24. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
  25. data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
  26. data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
  27. data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
  28. data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
  29. data/lib/browserctl/format_version.rb +37 -0
  30. data/lib/browserctl/logger.rb +102 -9
  31. data/lib/browserctl/migrations.rb +216 -0
  32. data/lib/browserctl/recording.rb +246 -28
  33. data/lib/browserctl/redactor.rb +58 -0
  34. data/lib/browserctl/replay/context.rb +40 -0
  35. data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
  36. data/lib/browserctl/replay/snapshot_diff.rb +51 -0
  37. data/lib/browserctl/replay/telemetry.rb +60 -0
  38. data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
  39. data/lib/browserctl/runner.rb +50 -10
  40. data/lib/browserctl/secret_resolver_registry.rb +23 -4
  41. data/lib/browserctl/server/command_dispatcher.rb +13 -1
  42. data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
  43. data/lib/browserctl/server/handlers/error_payload.rb +27 -0
  44. data/lib/browserctl/server/handlers/interaction.rb +21 -3
  45. data/lib/browserctl/server/handlers/navigation.rb +50 -5
  46. data/lib/browserctl/server/handlers/observation.rb +43 -2
  47. data/lib/browserctl/server/handlers/state.rb +149 -0
  48. data/lib/browserctl/server/page_session.rb +9 -7
  49. data/lib/browserctl/server/snapshot_builder.rb +21 -45
  50. data/lib/browserctl/session.rb +1 -1
  51. data/lib/browserctl/snapshot/annotator.rb +75 -0
  52. data/lib/browserctl/snapshot/extractor.rb +21 -0
  53. data/lib/browserctl/snapshot/fingerprint.rb +88 -0
  54. data/lib/browserctl/snapshot/ref.rb +70 -0
  55. data/lib/browserctl/snapshot/serializer.rb +17 -0
  56. data/lib/browserctl/state/bundle.rb +283 -0
  57. data/lib/browserctl/state/transport.rb +64 -0
  58. data/lib/browserctl/state/transports/file.rb +35 -0
  59. data/lib/browserctl/state/transports/one_password.rb +67 -0
  60. data/lib/browserctl/state/transports/s3.rb +42 -0
  61. data/lib/browserctl/state.rb +208 -0
  62. data/lib/browserctl/version.rb +1 -1
  63. data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
  64. data/lib/browserctl/workflow/promoter.rb +96 -0
  65. data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
  66. data/lib/browserctl/workflow.rb +235 -16
  67. metadata +44 -7
@@ -3,20 +3,80 @@
3
3
  require "timeout"
4
4
  require_relative "client"
5
5
  require_relative "errors"
6
+ require_relative "flow_registry"
7
+ require_relative "replay/context"
8
+ require_relative "replay/fingerprint_matcher"
9
+ require_relative "replay/snapshot_diff"
6
10
  require_relative "secret_resolvers"
7
11
  require_relative "session"
8
12
 
9
13
  module Browserctl
14
+ # Workflow-file format version. Workflows are Ruby files; the schema gate
15
+ # is a top-of-file comment header:
16
+ #
17
+ # # format_version: 1
18
+ #
19
+ # Unlike bundles and recordings, an unsupported or missing version on a
20
+ # workflow file is a *warning*, not a hard failure. Workflows are
21
+ # human-authored Ruby — the loader prefers to surface drift via stderr
22
+ # and let the file run, rather than block execution. See
23
+ # docs/reference/format-versions.md.
24
+ WORKFLOW_FORMAT_VERSION = 1
25
+ SUPPORTED_WORKFLOW_FORMAT_VERSIONS = [WORKFLOW_FORMAT_VERSION].freeze
26
+
27
+ # Matches a leading-line comment of the form `# format_version: <int>`.
28
+ # Tolerates leading whitespace inside the comment body and ignores the
29
+ # `# frozen_string_literal: true` magic comment that conventionally
30
+ # precedes it.
31
+ WORKFLOW_FORMAT_VERSION_HEADER = /^\s*#\s*format_version:\s*(\d+)\s*$/
32
+
33
+ # Parses the `# format_version: N` header from a workflow file's source.
34
+ # Scans only the contiguous leading comment block (and blank lines) so
35
+ # the header cannot be smuggled in mid-file. Returns the integer if
36
+ # present, or nil if the file has no version header.
37
+ def self.parse_workflow_format_version(source)
38
+ source.each_line do |line|
39
+ stripped = line.strip
40
+ next if stripped.empty?
41
+ break unless stripped.start_with?("#")
42
+
43
+ if (m = line.match(WORKFLOW_FORMAT_VERSION_HEADER))
44
+ return Integer(m[1])
45
+ end
46
+ end
47
+ nil
48
+ end
49
+
50
+ # Reads a workflow file and warns to stderr when the `format_version:`
51
+ # header is missing or declares an unsupported version. Always returns
52
+ # the parsed integer (or nil) — never raises. Callers should still
53
+ # `load` the file regardless.
54
+ def self.verify_workflow_format_version!(path)
55
+ source = File.read(path)
56
+ version = parse_workflow_format_version(source)
57
+
58
+ if version.nil?
59
+ warn "[browserctl] workflow #{path} is missing a `# format_version: N` header " \
60
+ "(expected #{WORKFLOW_FORMAT_VERSION}); proceeding anyway"
61
+ elsif !SUPPORTED_WORKFLOW_FORMAT_VERSIONS.include?(version)
62
+ warn "[browserctl] workflow #{path} format_version=#{version} is not supported " \
63
+ "(expected #{WORKFLOW_FORMAT_VERSION}); proceeding anyway"
64
+ end
65
+
66
+ version
67
+ end
68
+
10
69
  ParamDef = Struct.new(:name, :required, :secret, :default, :secret_ref, keyword_init: true)
11
70
  StepResult = Struct.new(:name, :ok, :error, keyword_init: true)
12
71
  StepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
13
72
 
14
73
  class WorkflowContext
15
- attr_reader :client
74
+ attr_reader :client, :replay_context, :params
16
75
 
17
- def initialize(params, client)
76
+ def initialize(params, client, replay_context: nil)
18
77
  @params = params
19
78
  @client = client
79
+ @replay_context = replay_context
20
80
  end
21
81
 
22
82
  def store(key, value)
@@ -44,7 +104,7 @@ module Browserctl
44
104
  end
45
105
 
46
106
  def page(name)
47
- PageProxy.new(name.to_s, @client)
107
+ PageProxy.new(name.to_s, @client, replay_context: @replay_context)
48
108
  end
49
109
 
50
110
  def open_page(page_name, url: nil)
@@ -68,7 +128,39 @@ module Browserctl
68
128
  res
69
129
  end
70
130
 
131
+ # Persists the daemon's current cookies + storage as a .bctl bundle.
132
+ # Optional flow binding lets `load_state` auto-rotate when the bundle
133
+ # is detected as needing authentication.
134
+ def save_state(name, flow: nil, origins: nil, encrypt: false)
135
+ passphrase = encrypt ? ENV.fetch("BROWSERCTL_STATE_PASSPHRASE", nil) : nil
136
+ res = @client.state_save(name.to_s,
137
+ flow: flow&.to_s, origins: origins, passphrase: passphrase)
138
+ raise WorkflowError, res[:error] if res[:error]
139
+
140
+ res
141
+ end
142
+
143
+ # Restores a .bctl bundle. When the daemon detects AUTH_REQUIRED before
144
+ # applying (e.g. expired cookies in the payload), this rotates the bound
145
+ # flow and retries — no caller code change required.
146
+ #
147
+ # @param on_auth_required [Proc, nil] override the auto-rotate path. The
148
+ # block runs in the workflow context, in lieu of invoking the manifest's
149
+ # bound flow. Use this when the recovery procedure is bespoke.
150
+ def load_state(name, on_auth_required: nil)
151
+ res = @client.state_load(name.to_s)
152
+ return res unless auth_required_response?(res)
153
+
154
+ recover_auth_required_state(name.to_s, res, on_auth_required)
155
+ end
156
+ DEPRECATED_LOAD_SESSION_FALLBACK = <<~MSG
157
+ [browserctl] DEPRECATION: `load_session(name, fallback:, expired_if:)` is superseded by
158
+ `load_state(name)` with a flow-bound bundle (`save_state(name, flow: :name)`).
159
+ `load_session` will be removed in v0.12. See docs/concepts/state.md.
160
+ MSG
161
+
71
162
  def load_session(session_name, fallback: nil, expired_if: nil)
163
+ warn DEPRECATED_LOAD_SESSION_FALLBACK if fallback || expired_if
72
164
  validate_expired_if!(expired_if)
73
165
  fallback_name = fallback&.to_s
74
166
  res = @client.session_load(session_name)
@@ -94,16 +186,41 @@ module Browserctl
94
186
  $stdin.gets.chomp
95
187
  end
96
188
 
97
- def invoke(workflow_name, **override_params)
98
- name = workflow_name.to_s
189
+ def invoke(target_name, page: nil, **override_params)
190
+ name = target_name.to_s
99
191
  guard_circular!(name)
100
- track_invoke(name) { run_nested(workflow_name, **override_params) }
192
+
193
+ flow = lookup_flow_target(name)
194
+ if flow
195
+ track_invoke(name) { run_invoked_flow(flow, page_name: page, **override_params) }
196
+ else
197
+ track_invoke(name) { run_nested(target_name, **override_params) }
198
+ end
101
199
  end
102
200
 
103
201
  def assert(condition, msg = "assertion failed")
104
202
  raise WorkflowError, msg unless condition
105
203
  end
106
204
 
205
+ # Snapshots the named page and compares its digest against `expected_digest`.
206
+ # Under `workflow run --check` (a replay context is attached), a mismatch is
207
+ # recorded as a drift event with reason "post-snapshot mismatch" and the
208
+ # step still passes. Outside --check, mismatch raises WorkflowError so the
209
+ # workflow fails fast.
210
+ def assert_snapshot_stable(page_name, expected_digest:)
211
+ res = @client.snapshot(page_name.to_s, format: "elements")
212
+ snapshot = res[:snapshot]
213
+ actual = Replay::SnapshotDiff.digest(snapshot)
214
+ return if actual == expected_digest
215
+
216
+ msg = "post-snapshot mismatch on :#{page_name} — expected #{expected_digest}, got #{actual}"
217
+ raise WorkflowError, msg unless @replay_context
218
+
219
+ @replay_context.record(command: :assert_snapshot_stable, selector: page_name.to_s,
220
+ matched_ref: nil, score: nil, reason: "post-snapshot mismatch")
221
+ warn "[browserctl replay] #{msg}"
222
+ end
223
+
107
224
  def compose(*)
108
225
  raise WorkflowError,
109
226
  "`compose` must be called at the workflow definition level, not inside a step block. " \
@@ -112,6 +229,45 @@ module Browserctl
112
229
 
113
230
  private
114
231
 
232
+ def auth_required_response?(res)
233
+ (res[:code] || res["code"]) == "AUTH_REQUIRED"
234
+ end
235
+
236
+ def recover_auth_required_state(name, initial_res, on_auth_required)
237
+ if on_auth_required
238
+ on_auth_required.call
239
+ else
240
+ flow_name = initial_res[:suggested_flow] || initial_res["suggested_flow"]
241
+ unless flow_name && !flow_name.to_s.empty?
242
+ raise WorkflowError,
243
+ "state '#{name}' needs auth but bundle has no bound flow — " \
244
+ "save with `save_state('#{name}', flow: :NAME)` or pass on_auth_required:"
245
+ end
246
+
247
+ # Match the daemon's `state load` preflight: it auth-checks the first
248
+ # open page (insertion order). Passing that same name to the flow
249
+ # gives stdlib flows a `page` proxy to drive (oauth_github reads
250
+ # `page.url`, totp_2fa calls `page.fill`, etc.). Falls back to no
251
+ # page only when nothing is open — `state_save` would have errored
252
+ # earlier in that case, so this is a defence-in-depth nil.
253
+ invoke(flow_name, page: first_open_page)
254
+ end
255
+
256
+ after_save = @client.state_save(name)
257
+ raise WorkflowError, after_save[:error] if after_save[:error]
258
+
259
+ retry_res = @client.state_load(name, skip_auth_check: true)
260
+ raise WorkflowError, retry_res[:error] if retry_res[:error]
261
+
262
+ retry_res.merge(rotated: true)
263
+ end
264
+
265
+ def first_open_page
266
+ res = @client.page_list
267
+ pages = res[:pages] || res["pages"] || []
268
+ pages.first
269
+ end
270
+
115
271
  def validate_expired_if!(expired_if)
116
272
  return unless expired_if
117
273
 
@@ -182,22 +338,43 @@ module Browserctl
182
338
  def run_nested(workflow_name, **override_params)
183
339
  Runner.new.run_workflow(workflow_name, **@params, **override_params)
184
340
  end
341
+
342
+ def lookup_flow_target(name)
343
+ Browserctl.lookup_flow(name) || begin
344
+ FlowRegistry.resolve(name)
345
+ rescue ArgumentError
346
+ nil
347
+ end
348
+ end
349
+
350
+ def run_invoked_flow(flow, page_name:, **params)
351
+ proxy = page_name ? page(page_name) : nil
352
+ flow.run(page: proxy, client: @client, **params)
353
+ end
185
354
  end
186
355
 
187
356
  class PageProxy
188
- def initialize(name, client)
189
- @name = name
190
- @client = client
357
+ attr_accessor :replay_context
358
+
359
+ def initialize(name, client, replay_context: nil, matcher: nil)
360
+ @name = name
361
+ @client = client
362
+ @replay_context = replay_context
363
+ @matcher = matcher || Replay::FingerprintMatcher.new
191
364
  end
192
365
 
193
366
  def navigate(url) = unwrap @client.navigate(@name, url)
194
367
 
195
368
  def fill(selector = nil, value = nil, ref: nil)
196
- unwrap @client.fill(@name, selector, value, ref: ref)
369
+ with_selector_fallback(:fill, selector, ref) do |sel, r|
370
+ @client.fill(@name, sel, value, ref: r)
371
+ end
197
372
  end
198
373
 
199
374
  def click(selector = nil, ref: nil)
200
- unwrap @client.click(@name, selector, ref: ref)
375
+ with_selector_fallback(:click, selector, ref) do |sel, r|
376
+ @client.click(@name, sel, ref: r)
377
+ end
201
378
  end
202
379
 
203
380
  def snapshot(**) = unwrap @client.snapshot(@name, **)
@@ -219,15 +396,21 @@ module Browserctl
219
396
  def press(key) = unwrap @client.press(@name, key)
220
397
 
221
398
  def hover(selector = nil, ref: nil)
222
- unwrap @client.hover(@name, selector, ref: ref)
399
+ with_selector_fallback(:hover, selector, ref) do |sel, r|
400
+ @client.hover(@name, sel, ref: r)
401
+ end
223
402
  end
224
403
 
225
404
  def upload(selector = nil, path = nil, ref: nil)
226
- unwrap @client.upload(@name, selector, path, ref: ref)
405
+ with_selector_fallback(:upload, selector, ref) do |sel, r|
406
+ @client.upload(@name, sel, path, ref: r)
407
+ end
227
408
  end
228
409
 
229
410
  def select(selector = nil, value = nil, ref: nil)
230
- unwrap @client.select(@name, selector, value, ref: ref)
411
+ with_selector_fallback(:select, selector, ref) do |sel, r|
412
+ @client.select(@name, sel, value, ref: r)
413
+ end
231
414
  end
232
415
 
233
416
  def dialog_accept(text: nil) = unwrap @client.dialog_accept(@name, text: text)
@@ -235,6 +418,42 @@ module Browserctl
235
418
 
236
419
  private
237
420
 
421
+ # Issues the wrapped command. If the daemon returns selector_not_found
422
+ # and a replay context has a fingerprint for this selector, takes a
423
+ # fresh snapshot, asks the matcher for a candidate, and retries by ref.
424
+ def with_selector_fallback(cmd, selector, ref)
425
+ res = yield(selector, ref)
426
+ return unwrap(res) if !selector_not_found?(res) || ref || !@replay_context || !selector
427
+
428
+ fp = @replay_context.fingerprint_for(selector)
429
+ return unwrap(res) unless fp
430
+
431
+ match = @matcher.best(fp, snapshot_entries)
432
+ unless match
433
+ @replay_context.record(command: cmd, selector: selector, reason: "no candidate above threshold")
434
+ return unwrap(res)
435
+ end
436
+
437
+ log_rematch(cmd, selector, match)
438
+ @replay_context.record(command: cmd, selector: selector,
439
+ matched_ref: match.candidate[:ref], score: match.score, reason: "rematch")
440
+ unwrap(yield(nil, match.candidate[:ref]))
441
+ end
442
+
443
+ def snapshot_entries
444
+ res = @client.snapshot(@name, format: "elements")
445
+ Array(res[:snapshot])
446
+ end
447
+
448
+ def selector_not_found?(res)
449
+ res.is_a?(Hash) && res[:code] == Browserctl::Error::Codes::SELECTOR_NOT_FOUND
450
+ end
451
+
452
+ def log_rematch(cmd, selector, match)
453
+ warn "[browserctl replay] #{cmd} selector #{selector.inspect} not found — " \
454
+ "rematched to ref=#{match.candidate[:ref]} (score=#{format('%.2f', match.score)})"
455
+ end
456
+
238
457
  def unwrap(res)
239
458
  raise WorkflowError, res[:error] if res[:error]
240
459
 
@@ -273,8 +492,8 @@ module Browserctl
273
492
  @steps.concat(source.steps)
274
493
  end
275
494
 
276
- def call(params, client)
277
- ctx = WorkflowContext.new(resolve_params(params), client)
495
+ def call(params, client, replay_context: nil)
496
+ ctx = WorkflowContext.new(resolve_params(params), client, replay_context: replay_context)
278
497
  execute_steps(ctx)
279
498
  end
280
499
 
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: browserctl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-05-09 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: ferrum
@@ -127,8 +126,8 @@ description: Named browser sessions, Ruby workflow DSL, and a token-efficient DO
127
126
  email:
128
127
  - patrick204nqh@gmail.com
129
128
  executables:
130
- - browserd
131
129
  - browserctl
130
+ - browserd
132
131
  extensions: []
133
132
  extra_rdoc_files: []
134
133
  files:
@@ -174,26 +173,50 @@ files:
174
173
  - lib/browserctl/commands/daemon.rb
175
174
  - lib/browserctl/commands/dialog.rb
176
175
  - lib/browserctl/commands/fill.rb
176
+ - lib/browserctl/commands/flow.rb
177
177
  - lib/browserctl/commands/init.rb
178
+ - lib/browserctl/commands/migrate.rb
178
179
  - lib/browserctl/commands/page.rb
179
180
  - lib/browserctl/commands/record.rb
180
181
  - lib/browserctl/commands/resume.rb
181
182
  - lib/browserctl/commands/screenshot.rb
182
183
  - lib/browserctl/commands/session.rb
183
184
  - lib/browserctl/commands/snapshot.rb
185
+ - lib/browserctl/commands/state.rb
184
186
  - lib/browserctl/commands/storage.rb
187
+ - lib/browserctl/commands/trace.rb
185
188
  - lib/browserctl/commands/workflow.rb
186
189
  - lib/browserctl/constants.rb
190
+ - lib/browserctl/crash_report.rb
187
191
  - lib/browserctl/detectors.rb
192
+ - lib/browserctl/detectors/auth_required.rb
188
193
  - lib/browserctl/driver.rb
189
194
  - lib/browserctl/driver/base.rb
190
195
  - lib/browserctl/driver/cdp.rb
191
196
  - lib/browserctl/driver/cdp_page.rb
197
+ - lib/browserctl/error/codes.rb
198
+ - lib/browserctl/error/exit_codes.rb
199
+ - lib/browserctl/error/suggested_actions.rb
192
200
  - lib/browserctl/errors.rb
193
201
  - lib/browserctl/flow.rb
202
+ - lib/browserctl/flow_registry.rb
203
+ - lib/browserctl/flows/stdlib/basic_auth.rb
204
+ - lib/browserctl/flows/stdlib/cloudflare_solve.rb
205
+ - lib/browserctl/flows/stdlib/magic_link_email.rb
206
+ - lib/browserctl/flows/stdlib/oauth_github.rb
207
+ - lib/browserctl/flows/stdlib/oauth_google.rb
208
+ - lib/browserctl/flows/stdlib/totp_2fa.rb
209
+ - lib/browserctl/format_version.rb
194
210
  - lib/browserctl/logger.rb
211
+ - lib/browserctl/migrations.rb
195
212
  - lib/browserctl/policy.rb
196
213
  - lib/browserctl/recording.rb
214
+ - lib/browserctl/redactor.rb
215
+ - lib/browserctl/replay/context.rb
216
+ - lib/browserctl/replay/fingerprint_matcher.rb
217
+ - lib/browserctl/replay/snapshot_diff.rb
218
+ - lib/browserctl/replay/telemetry.rb
219
+ - lib/browserctl/rubocop/cops/typed_error.rb
197
220
  - lib/browserctl/runner.rb
198
221
  - lib/browserctl/secret_resolver_registry.rb
199
222
  - lib/browserctl/secret_resolvers.rb
@@ -206,19 +229,35 @@ files:
206
229
  - lib/browserctl/server/handlers/cookies.rb
207
230
  - lib/browserctl/server/handlers/daemon_control.rb
208
231
  - lib/browserctl/server/handlers/devtools.rb
232
+ - lib/browserctl/server/handlers/error_payload.rb
209
233
  - lib/browserctl/server/handlers/hitl.rb
210
234
  - lib/browserctl/server/handlers/interaction.rb
211
235
  - lib/browserctl/server/handlers/navigation.rb
212
236
  - lib/browserctl/server/handlers/observation.rb
213
237
  - lib/browserctl/server/handlers/page_lifecycle.rb
214
238
  - lib/browserctl/server/handlers/session.rb
239
+ - lib/browserctl/server/handlers/state.rb
215
240
  - lib/browserctl/server/handlers/storage.rb
216
241
  - lib/browserctl/server/idle_watcher.rb
217
242
  - lib/browserctl/server/page_session.rb
218
243
  - lib/browserctl/server/snapshot_builder.rb
219
244
  - lib/browserctl/session.rb
245
+ - lib/browserctl/snapshot/annotator.rb
246
+ - lib/browserctl/snapshot/extractor.rb
247
+ - lib/browserctl/snapshot/fingerprint.rb
248
+ - lib/browserctl/snapshot/ref.rb
249
+ - lib/browserctl/snapshot/serializer.rb
250
+ - lib/browserctl/state.rb
251
+ - lib/browserctl/state/bundle.rb
252
+ - lib/browserctl/state/transport.rb
253
+ - lib/browserctl/state/transports/file.rb
254
+ - lib/browserctl/state/transports/one_password.rb
255
+ - lib/browserctl/state/transports/s3.rb
220
256
  - lib/browserctl/version.rb
221
257
  - lib/browserctl/workflow.rb
258
+ - lib/browserctl/workflow/flow_wrapper.rb
259
+ - lib/browserctl/workflow/promoter.rb
260
+ - lib/browserctl/workflow/promotion_ledger.rb
222
261
  homepage: https://github.com/patrick204nqh/browserctl
223
262
  licenses:
224
263
  - MIT
@@ -229,7 +268,6 @@ metadata:
229
268
  bug_tracker_uri: https://github.com/patrick204nqh/browserctl/issues
230
269
  documentation_uri: https://github.com/patrick204nqh/browserctl/tree/main/docs
231
270
  rubygems_mfa_required: 'true'
232
- post_install_message:
233
271
  rdoc_options: []
234
272
  require_paths:
235
273
  - lib
@@ -244,8 +282,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
244
282
  - !ruby/object:Gem::Version
245
283
  version: '0'
246
284
  requirements: []
247
- rubygems_version: 3.5.22
248
- signing_key:
285
+ rubygems_version: 3.6.9
249
286
  specification_version: 4
250
287
  summary: Persistent browser automation daemon and CLI for AI agents and developer
251
288
  workflows