browserctl 0.9.0 → 0.11.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 +45 -0
- data/README.md +1 -1
- data/bin/browserctl +45 -4
- data/lib/browserctl/client.rb +47 -3
- data/lib/browserctl/commands/cli_output.rb +16 -3
- data/lib/browserctl/commands/flow.rb +123 -0
- data/lib/browserctl/commands/state.rb +193 -0
- data/lib/browserctl/commands/workflow.rb +62 -4
- data/lib/browserctl/constants.rb +1 -1
- data/lib/browserctl/detectors/auth_required.rb +128 -0
- data/lib/browserctl/detectors.rb +2 -0
- data/lib/browserctl/errors.rb +36 -0
- data/lib/browserctl/flow.rb +215 -0
- 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/recording.rb +212 -26
- 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/runner.rb +38 -4
- data/lib/browserctl/server/command_dispatcher.rb +10 -1
- data/lib/browserctl/server/handlers/interaction.rb +3 -3
- data/lib/browserctl/server/handlers/navigation.rb +33 -4
- 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/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 +242 -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 +180 -16
- metadata +32 -2
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
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
|
|
|
@@ -12,11 +16,12 @@ module Browserctl
|
|
|
12
16
|
StepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
|
|
13
17
|
|
|
14
18
|
class WorkflowContext
|
|
15
|
-
attr_reader :client
|
|
19
|
+
attr_reader :client, :replay_context, :params
|
|
16
20
|
|
|
17
|
-
def initialize(params, client)
|
|
21
|
+
def initialize(params, client, replay_context: nil)
|
|
18
22
|
@params = params
|
|
19
23
|
@client = client
|
|
24
|
+
@replay_context = replay_context
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
def store(key, value)
|
|
@@ -44,7 +49,7 @@ module Browserctl
|
|
|
44
49
|
end
|
|
45
50
|
|
|
46
51
|
def page(name)
|
|
47
|
-
PageProxy.new(name.to_s, @client)
|
|
52
|
+
PageProxy.new(name.to_s, @client, replay_context: @replay_context)
|
|
48
53
|
end
|
|
49
54
|
|
|
50
55
|
def open_page(page_name, url: nil)
|
|
@@ -68,7 +73,39 @@ module Browserctl
|
|
|
68
73
|
res
|
|
69
74
|
end
|
|
70
75
|
|
|
76
|
+
# Persists the daemon's current cookies + storage as a .bctl bundle.
|
|
77
|
+
# Optional flow binding lets `load_state` auto-rotate when the bundle
|
|
78
|
+
# is detected as needing authentication.
|
|
79
|
+
def save_state(name, flow: nil, origins: nil, encrypt: false)
|
|
80
|
+
passphrase = encrypt ? ENV.fetch("BROWSERCTL_STATE_PASSPHRASE", nil) : nil
|
|
81
|
+
res = @client.state_save(name.to_s,
|
|
82
|
+
flow: flow&.to_s, origins: origins, passphrase: passphrase)
|
|
83
|
+
raise WorkflowError, res[:error] if res[:error]
|
|
84
|
+
|
|
85
|
+
res
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Restores a .bctl bundle. When the daemon detects AUTH_REQUIRED before
|
|
89
|
+
# applying (e.g. expired cookies in the payload), this rotates the bound
|
|
90
|
+
# flow and retries — no caller code change required.
|
|
91
|
+
#
|
|
92
|
+
# @param on_auth_required [Proc, nil] override the auto-rotate path. The
|
|
93
|
+
# block runs in the workflow context, in lieu of invoking the manifest's
|
|
94
|
+
# bound flow. Use this when the recovery procedure is bespoke.
|
|
95
|
+
def load_state(name, on_auth_required: nil)
|
|
96
|
+
res = @client.state_load(name.to_s)
|
|
97
|
+
return res unless auth_required_response?(res)
|
|
98
|
+
|
|
99
|
+
recover_auth_required_state(name.to_s, res, on_auth_required)
|
|
100
|
+
end
|
|
101
|
+
DEPRECATED_LOAD_SESSION_FALLBACK = <<~MSG
|
|
102
|
+
[browserctl] DEPRECATION: `load_session(name, fallback:, expired_if:)` is superseded by
|
|
103
|
+
`load_state(name)` with a flow-bound bundle (`save_state(name, flow: :name)`).
|
|
104
|
+
`load_session` will be removed in v0.12. See docs/concepts/state.md.
|
|
105
|
+
MSG
|
|
106
|
+
|
|
71
107
|
def load_session(session_name, fallback: nil, expired_if: nil)
|
|
108
|
+
warn DEPRECATED_LOAD_SESSION_FALLBACK if fallback || expired_if
|
|
72
109
|
validate_expired_if!(expired_if)
|
|
73
110
|
fallback_name = fallback&.to_s
|
|
74
111
|
res = @client.session_load(session_name)
|
|
@@ -94,16 +131,41 @@ module Browserctl
|
|
|
94
131
|
$stdin.gets.chomp
|
|
95
132
|
end
|
|
96
133
|
|
|
97
|
-
def invoke(
|
|
98
|
-
name =
|
|
134
|
+
def invoke(target_name, page: nil, **override_params)
|
|
135
|
+
name = target_name.to_s
|
|
99
136
|
guard_circular!(name)
|
|
100
|
-
|
|
137
|
+
|
|
138
|
+
flow = lookup_flow_target(name)
|
|
139
|
+
if flow
|
|
140
|
+
track_invoke(name) { run_invoked_flow(flow, page_name: page, **override_params) }
|
|
141
|
+
else
|
|
142
|
+
track_invoke(name) { run_nested(target_name, **override_params) }
|
|
143
|
+
end
|
|
101
144
|
end
|
|
102
145
|
|
|
103
146
|
def assert(condition, msg = "assertion failed")
|
|
104
147
|
raise WorkflowError, msg unless condition
|
|
105
148
|
end
|
|
106
149
|
|
|
150
|
+
# Snapshots the named page and compares its digest against `expected_digest`.
|
|
151
|
+
# Under `workflow run --check` (a replay context is attached), a mismatch is
|
|
152
|
+
# recorded as a drift event with reason "post-snapshot mismatch" and the
|
|
153
|
+
# step still passes. Outside --check, mismatch raises WorkflowError so the
|
|
154
|
+
# workflow fails fast.
|
|
155
|
+
def assert_snapshot_stable(page_name, expected_digest:)
|
|
156
|
+
res = @client.snapshot(page_name.to_s, format: "elements")
|
|
157
|
+
snapshot = res[:snapshot]
|
|
158
|
+
actual = Replay::SnapshotDiff.digest(snapshot)
|
|
159
|
+
return if actual == expected_digest
|
|
160
|
+
|
|
161
|
+
msg = "post-snapshot mismatch on :#{page_name} — expected #{expected_digest}, got #{actual}"
|
|
162
|
+
raise WorkflowError, msg unless @replay_context
|
|
163
|
+
|
|
164
|
+
@replay_context.record(command: :assert_snapshot_stable, selector: page_name.to_s,
|
|
165
|
+
matched_ref: nil, score: nil, reason: "post-snapshot mismatch")
|
|
166
|
+
warn "[browserctl replay] #{msg}"
|
|
167
|
+
end
|
|
168
|
+
|
|
107
169
|
def compose(*)
|
|
108
170
|
raise WorkflowError,
|
|
109
171
|
"`compose` must be called at the workflow definition level, not inside a step block. " \
|
|
@@ -112,6 +174,45 @@ module Browserctl
|
|
|
112
174
|
|
|
113
175
|
private
|
|
114
176
|
|
|
177
|
+
def auth_required_response?(res)
|
|
178
|
+
(res[:code] || res["code"]) == "AUTH_REQUIRED"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def recover_auth_required_state(name, initial_res, on_auth_required)
|
|
182
|
+
if on_auth_required
|
|
183
|
+
on_auth_required.call
|
|
184
|
+
else
|
|
185
|
+
flow_name = initial_res[:suggested_flow] || initial_res["suggested_flow"]
|
|
186
|
+
unless flow_name && !flow_name.to_s.empty?
|
|
187
|
+
raise WorkflowError,
|
|
188
|
+
"state '#{name}' needs auth but bundle has no bound flow — " \
|
|
189
|
+
"save with `save_state('#{name}', flow: :NAME)` or pass on_auth_required:"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Match the daemon's `state load` preflight: it auth-checks the first
|
|
193
|
+
# open page (insertion order). Passing that same name to the flow
|
|
194
|
+
# gives stdlib flows a `page` proxy to drive (oauth_github reads
|
|
195
|
+
# `page.url`, totp_2fa calls `page.fill`, etc.). Falls back to no
|
|
196
|
+
# page only when nothing is open — `state_save` would have errored
|
|
197
|
+
# earlier in that case, so this is a defence-in-depth nil.
|
|
198
|
+
invoke(flow_name, page: first_open_page)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
after_save = @client.state_save(name)
|
|
202
|
+
raise WorkflowError, after_save[:error] if after_save[:error]
|
|
203
|
+
|
|
204
|
+
retry_res = @client.state_load(name, skip_auth_check: true)
|
|
205
|
+
raise WorkflowError, retry_res[:error] if retry_res[:error]
|
|
206
|
+
|
|
207
|
+
retry_res.merge(rotated: true)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def first_open_page
|
|
211
|
+
res = @client.page_list
|
|
212
|
+
pages = res[:pages] || res["pages"] || []
|
|
213
|
+
pages.first
|
|
214
|
+
end
|
|
215
|
+
|
|
115
216
|
def validate_expired_if!(expired_if)
|
|
116
217
|
return unless expired_if
|
|
117
218
|
|
|
@@ -182,22 +283,43 @@ module Browserctl
|
|
|
182
283
|
def run_nested(workflow_name, **override_params)
|
|
183
284
|
Runner.new.run_workflow(workflow_name, **@params, **override_params)
|
|
184
285
|
end
|
|
286
|
+
|
|
287
|
+
def lookup_flow_target(name)
|
|
288
|
+
Browserctl.lookup_flow(name) || begin
|
|
289
|
+
FlowRegistry.resolve(name)
|
|
290
|
+
rescue ArgumentError
|
|
291
|
+
nil
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def run_invoked_flow(flow, page_name:, **params)
|
|
296
|
+
proxy = page_name ? page(page_name) : nil
|
|
297
|
+
flow.run(page: proxy, client: @client, **params)
|
|
298
|
+
end
|
|
185
299
|
end
|
|
186
300
|
|
|
187
301
|
class PageProxy
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
302
|
+
attr_accessor :replay_context
|
|
303
|
+
|
|
304
|
+
def initialize(name, client, replay_context: nil, matcher: nil)
|
|
305
|
+
@name = name
|
|
306
|
+
@client = client
|
|
307
|
+
@replay_context = replay_context
|
|
308
|
+
@matcher = matcher || Replay::FingerprintMatcher.new
|
|
191
309
|
end
|
|
192
310
|
|
|
193
311
|
def navigate(url) = unwrap @client.navigate(@name, url)
|
|
194
312
|
|
|
195
313
|
def fill(selector = nil, value = nil, ref: nil)
|
|
196
|
-
|
|
314
|
+
with_selector_fallback(:fill, selector, ref) do |sel, r|
|
|
315
|
+
@client.fill(@name, sel, value, ref: r)
|
|
316
|
+
end
|
|
197
317
|
end
|
|
198
318
|
|
|
199
319
|
def click(selector = nil, ref: nil)
|
|
200
|
-
|
|
320
|
+
with_selector_fallback(:click, selector, ref) do |sel, r|
|
|
321
|
+
@client.click(@name, sel, ref: r)
|
|
322
|
+
end
|
|
201
323
|
end
|
|
202
324
|
|
|
203
325
|
def snapshot(**) = unwrap @client.snapshot(@name, **)
|
|
@@ -219,15 +341,21 @@ module Browserctl
|
|
|
219
341
|
def press(key) = unwrap @client.press(@name, key)
|
|
220
342
|
|
|
221
343
|
def hover(selector = nil, ref: nil)
|
|
222
|
-
|
|
344
|
+
with_selector_fallback(:hover, selector, ref) do |sel, r|
|
|
345
|
+
@client.hover(@name, sel, ref: r)
|
|
346
|
+
end
|
|
223
347
|
end
|
|
224
348
|
|
|
225
349
|
def upload(selector = nil, path = nil, ref: nil)
|
|
226
|
-
|
|
350
|
+
with_selector_fallback(:upload, selector, ref) do |sel, r|
|
|
351
|
+
@client.upload(@name, sel, path, ref: r)
|
|
352
|
+
end
|
|
227
353
|
end
|
|
228
354
|
|
|
229
355
|
def select(selector = nil, value = nil, ref: nil)
|
|
230
|
-
|
|
356
|
+
with_selector_fallback(:select, selector, ref) do |sel, r|
|
|
357
|
+
@client.select(@name, sel, value, ref: r)
|
|
358
|
+
end
|
|
231
359
|
end
|
|
232
360
|
|
|
233
361
|
def dialog_accept(text: nil) = unwrap @client.dialog_accept(@name, text: text)
|
|
@@ -235,6 +363,42 @@ module Browserctl
|
|
|
235
363
|
|
|
236
364
|
private
|
|
237
365
|
|
|
366
|
+
# Issues the wrapped command. If the daemon returns selector_not_found
|
|
367
|
+
# and a replay context has a fingerprint for this selector, takes a
|
|
368
|
+
# fresh snapshot, asks the matcher for a candidate, and retries by ref.
|
|
369
|
+
def with_selector_fallback(cmd, selector, ref)
|
|
370
|
+
res = yield(selector, ref)
|
|
371
|
+
return unwrap(res) if !selector_not_found?(res) || ref || !@replay_context || !selector
|
|
372
|
+
|
|
373
|
+
fp = @replay_context.fingerprint_for(selector)
|
|
374
|
+
return unwrap(res) unless fp
|
|
375
|
+
|
|
376
|
+
match = @matcher.best(fp, snapshot_entries)
|
|
377
|
+
unless match
|
|
378
|
+
@replay_context.record(command: cmd, selector: selector, reason: "no candidate above threshold")
|
|
379
|
+
return unwrap(res)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
log_rematch(cmd, selector, match)
|
|
383
|
+
@replay_context.record(command: cmd, selector: selector,
|
|
384
|
+
matched_ref: match.candidate[:ref], score: match.score, reason: "rematch")
|
|
385
|
+
unwrap(yield(nil, match.candidate[:ref]))
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def snapshot_entries
|
|
389
|
+
res = @client.snapshot(@name, format: "elements")
|
|
390
|
+
Array(res[:snapshot])
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def selector_not_found?(res)
|
|
394
|
+
res.is_a?(Hash) && res[:code] == "selector_not_found"
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def log_rematch(cmd, selector, match)
|
|
398
|
+
warn "[browserctl replay] #{cmd} selector #{selector.inspect} not found — " \
|
|
399
|
+
"rematched to ref=#{match.candidate[:ref]} (score=#{format('%.2f', match.score)})"
|
|
400
|
+
end
|
|
401
|
+
|
|
238
402
|
def unwrap(res)
|
|
239
403
|
raise WorkflowError, res[:error] if res[:error]
|
|
240
404
|
|
|
@@ -273,8 +437,8 @@ module Browserctl
|
|
|
273
437
|
@steps.concat(source.steps)
|
|
274
438
|
end
|
|
275
439
|
|
|
276
|
-
def call(params, client)
|
|
277
|
-
ctx = WorkflowContext.new(resolve_params(params), client)
|
|
440
|
+
def call(params, client, replay_context: nil)
|
|
441
|
+
ctx = WorkflowContext.new(resolve_params(params), client, replay_context: replay_context)
|
|
278
442
|
execute_steps(ctx)
|
|
279
443
|
end
|
|
280
444
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: browserctl
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ferrum
|
|
@@ -174,6 +174,7 @@ files:
|
|
|
174
174
|
- lib/browserctl/commands/daemon.rb
|
|
175
175
|
- lib/browserctl/commands/dialog.rb
|
|
176
176
|
- lib/browserctl/commands/fill.rb
|
|
177
|
+
- lib/browserctl/commands/flow.rb
|
|
177
178
|
- lib/browserctl/commands/init.rb
|
|
178
179
|
- lib/browserctl/commands/page.rb
|
|
179
180
|
- lib/browserctl/commands/record.rb
|
|
@@ -181,18 +182,32 @@ files:
|
|
|
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
|
|
185
187
|
- lib/browserctl/commands/workflow.rb
|
|
186
188
|
- lib/browserctl/constants.rb
|
|
187
189
|
- lib/browserctl/detectors.rb
|
|
190
|
+
- lib/browserctl/detectors/auth_required.rb
|
|
188
191
|
- lib/browserctl/driver.rb
|
|
189
192
|
- lib/browserctl/driver/base.rb
|
|
190
193
|
- lib/browserctl/driver/cdp.rb
|
|
191
194
|
- lib/browserctl/driver/cdp_page.rb
|
|
192
195
|
- lib/browserctl/errors.rb
|
|
196
|
+
- lib/browserctl/flow.rb
|
|
197
|
+
- lib/browserctl/flow_registry.rb
|
|
198
|
+
- lib/browserctl/flows/stdlib/basic_auth.rb
|
|
199
|
+
- lib/browserctl/flows/stdlib/cloudflare_solve.rb
|
|
200
|
+
- lib/browserctl/flows/stdlib/magic_link_email.rb
|
|
201
|
+
- lib/browserctl/flows/stdlib/oauth_github.rb
|
|
202
|
+
- lib/browserctl/flows/stdlib/oauth_google.rb
|
|
203
|
+
- lib/browserctl/flows/stdlib/totp_2fa.rb
|
|
193
204
|
- lib/browserctl/logger.rb
|
|
194
205
|
- lib/browserctl/policy.rb
|
|
195
206
|
- lib/browserctl/recording.rb
|
|
207
|
+
- lib/browserctl/replay/context.rb
|
|
208
|
+
- lib/browserctl/replay/fingerprint_matcher.rb
|
|
209
|
+
- lib/browserctl/replay/snapshot_diff.rb
|
|
210
|
+
- lib/browserctl/replay/telemetry.rb
|
|
196
211
|
- lib/browserctl/runner.rb
|
|
197
212
|
- lib/browserctl/secret_resolver_registry.rb
|
|
198
213
|
- lib/browserctl/secret_resolvers.rb
|
|
@@ -211,13 +226,28 @@ files:
|
|
|
211
226
|
- lib/browserctl/server/handlers/observation.rb
|
|
212
227
|
- lib/browserctl/server/handlers/page_lifecycle.rb
|
|
213
228
|
- lib/browserctl/server/handlers/session.rb
|
|
229
|
+
- lib/browserctl/server/handlers/state.rb
|
|
214
230
|
- lib/browserctl/server/handlers/storage.rb
|
|
215
231
|
- lib/browserctl/server/idle_watcher.rb
|
|
216
232
|
- lib/browserctl/server/page_session.rb
|
|
217
233
|
- lib/browserctl/server/snapshot_builder.rb
|
|
218
234
|
- lib/browserctl/session.rb
|
|
235
|
+
- lib/browserctl/snapshot/annotator.rb
|
|
236
|
+
- lib/browserctl/snapshot/extractor.rb
|
|
237
|
+
- lib/browserctl/snapshot/fingerprint.rb
|
|
238
|
+
- lib/browserctl/snapshot/ref.rb
|
|
239
|
+
- lib/browserctl/snapshot/serializer.rb
|
|
240
|
+
- lib/browserctl/state.rb
|
|
241
|
+
- lib/browserctl/state/bundle.rb
|
|
242
|
+
- lib/browserctl/state/transport.rb
|
|
243
|
+
- lib/browserctl/state/transports/file.rb
|
|
244
|
+
- lib/browserctl/state/transports/one_password.rb
|
|
245
|
+
- lib/browserctl/state/transports/s3.rb
|
|
219
246
|
- lib/browserctl/version.rb
|
|
220
247
|
- lib/browserctl/workflow.rb
|
|
248
|
+
- lib/browserctl/workflow/flow_wrapper.rb
|
|
249
|
+
- lib/browserctl/workflow/promoter.rb
|
|
250
|
+
- lib/browserctl/workflow/promotion_ledger.rb
|
|
221
251
|
homepage: https://github.com/patrick204nqh/browserctl
|
|
222
252
|
licenses:
|
|
223
253
|
- MIT
|