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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f20046bbdcf3ff57a52790137144c5f1197b6d3f8651f250bf6feb1cc9988f7
|
|
4
|
+
data.tar.gz: 1d928edc69e4691cc720b9bb7f32ea4001d1fd5df636a4af7fc59341f7714174
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 87be1521e16f71d8f77c699712fb03c7a339b2421102e6ad0d4bc9c1a0e860ff29882c81d0e5819a802b3364962922594bd9a08d80cdf5521e50ea09659dac98
|
|
7
|
+
data.tar.gz: 29256e3d109bdfa651e8bea5fb82eef18fcb2b299c648db937015cebbfddd0d947e3e042b848adf32b61006f99d69150aaf38acf2a1356946c1584e2dc7a00b6
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,51 @@ 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.11.0](https://github.com/patrick204nqh/browserctl/compare/v0.10.0...v0.11.0) (2026-05-10)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* .bctl bundle codec ([#94](https://github.com/patrick204nqh/browserctl/issues/94)) ([75a4370](https://github.com/patrick204nqh/browserctl/commit/75a43704abf9cf766435fff35409333514fd3c73))
|
|
19
|
+
* auth_required detector ([#99](https://github.com/patrick204nqh/browserctl/issues/99)) ([6d9554f](https://github.com/patrick204nqh/browserctl/commit/6d9554f7cbe9b8e93d22c42b03ff2ce59e594f28))
|
|
20
|
+
* AUTH_REQUIRED structured error code ([#100](https://github.com/patrick204nqh/browserctl/issues/100)) ([9928a4e](https://github.com/patrick204nqh/browserctl/commit/9928a4e7d456d0382b2244096fb38ebeee55013d))
|
|
21
|
+
* browserctl flow run/list/describe CLI ([#89](https://github.com/patrick204nqh/browserctl/issues/89)) ([5016fe5](https://github.com/patrick204nqh/browserctl/commit/5016fe54ee1414b967025f450ded79565ef9bcfb))
|
|
22
|
+
* drift telemetry (WS-2.4) ([#114](https://github.com/patrick204nqh/browserctl/issues/114)) ([712da07](https://github.com/patrick204nqh/browserctl/commit/712da075e623a2aa27a779958b94c0f6e92384c8))
|
|
23
|
+
* element fingerprint (WS-1.2) ([#107](https://github.com/patrick204nqh/browserctl/issues/107)) ([8a8b6b4](https://github.com/patrick204nqh/browserctl/commit/8a8b6b45121cb179919fae8c3f87a4584d4b4bc1))
|
|
24
|
+
* enriched recording log (WS-3.1) ([#115](https://github.com/patrick204nqh/browserctl/issues/115)) ([c00787d](https://github.com/patrick204nqh/browserctl/commit/c00787dc5f208cf69cd4a0326d4df19ed31e3bfc))
|
|
25
|
+
* fingerprint matcher (WS-2.1) ([#111](https://github.com/patrick204nqh/browserctl/issues/111)) ([1d99d20](https://github.com/patrick204nqh/browserctl/commit/1d99d208f5d3815c8ef1e59e3d549d15c3719712))
|
|
26
|
+
* flow registry ([#86](https://github.com/patrick204nqh/browserctl/issues/86)) ([31579dd](https://github.com/patrick204nqh/browserctl/commit/31579dd20156a700ace15137b34b120d16c86b6f))
|
|
27
|
+
* inferred waits (WS-3.3) ([#117](https://github.com/patrick204nqh/browserctl/issues/117)) ([9332754](https://github.com/patrick204nqh/browserctl/commit/93327545b1d21e00c16037c6730f019b574bc572))
|
|
28
|
+
* invoke flows from workflow DSL ([#88](https://github.com/patrick204nqh/browserctl/issues/88)) ([61fdc0a](https://github.com/patrick204nqh/browserctl/commit/61fdc0a6c98fdaf29c5034b80ccc54fdfb447e3a))
|
|
29
|
+
* load_state auto re-run + on_auth_required hook ([#101](https://github.com/patrick204nqh/browserctl/issues/101)) ([8328d13](https://github.com/patrick204nqh/browserctl/commit/8328d1348d2be8ec685ece9dec3cafb0eb26eb90))
|
|
30
|
+
* postcondition extraction (WS-3.4) ([#118](https://github.com/patrick204nqh/browserctl/issues/118)) ([56e76d5](https://github.com/patrick204nqh/browserctl/commit/56e76d50814d9c9a539ea56b20e6936d62509fcf))
|
|
31
|
+
* replay fallback in PageProxy (WS-2.2) ([#112](https://github.com/patrick204nqh/browserctl/issues/112)) ([50714fc](https://github.com/patrick204nqh/browserctl/commit/50714fcd2ff16614af0d18c8b390084e3f32511e))
|
|
32
|
+
* stable ref derivation (v0.11 WS-1.1) ([#106](https://github.com/patrick204nqh/browserctl/issues/106)) ([86df66a](https://github.com/patrick204nqh/browserctl/commit/86df66aaa9fc0b6c7a3a285c572ba2941b301e55))
|
|
33
|
+
* state export/import with file/s3/op transports ([#96](https://github.com/patrick204nqh/browserctl/issues/96)) ([f1dd97b](https://github.com/patrick204nqh/browserctl/commit/f1dd97b895503ee19e60f14bbb9df81dcb321bd1))
|
|
34
|
+
* state rotate ([#97](https://github.com/patrick204nqh/browserctl/issues/97)) ([78bb091](https://github.com/patrick204nqh/browserctl/commit/78bb091194e12e12867d7be0f566d426bdcb3b14))
|
|
35
|
+
* state save/load/list/info/delete ([#95](https://github.com/patrick204nqh/browserctl/issues/95)) ([4502cd1](https://github.com/patrick204nqh/browserctl/commit/4502cd1e8992f486b34ce2288a34a9ef78808617))
|
|
36
|
+
* stdlib flow — cloudflare_solve ([#93](https://github.com/patrick204nqh/browserctl/issues/93)) ([b4411b9](https://github.com/patrick204nqh/browserctl/commit/b4411b94af669d435346006c5f9304258456ba7b))
|
|
37
|
+
* stdlib flow — totp_2fa ([#90](https://github.com/patrick204nqh/browserctl/issues/90)) ([c23e574](https://github.com/patrick204nqh/browserctl/commit/c23e574887e1ef3324e41e988a8f8047079ac998))
|
|
38
|
+
* stdlib flows — basic_auth, magic_link_email ([#91](https://github.com/patrick204nqh/browserctl/issues/91)) ([e401d60](https://github.com/patrick204nqh/browserctl/commit/e401d60b4cf5b4b9f2b4138c45a9b19275a089e7))
|
|
39
|
+
* stdlib flows — oauth_google, oauth_github ([#92](https://github.com/patrick204nqh/browserctl/issues/92)) ([04c9586](https://github.com/patrick204nqh/browserctl/commit/04c9586ff83503b7d94a2c73fe9c3360f01dc4ca))
|
|
40
|
+
* workflow generate (WS-3.2) ([#116](https://github.com/patrick204nqh/browserctl/issues/116)) ([90dca3b](https://github.com/patrick204nqh/browserctl/commit/90dca3bada9b53f91e9be39e3d94d0208dcfa181))
|
|
41
|
+
* workflow promote --as-flow (WS-3.7) ([#121](https://github.com/patrick204nqh/browserctl/issues/121)) ([a5e4b30](https://github.com/patrick204nqh/browserctl/commit/a5e4b30a30559e8fa6b3f4ff3df7189dc3d6bd1e))
|
|
42
|
+
* workflow promote (WS-3.6) ([#120](https://github.com/patrick204nqh/browserctl/issues/120)) ([4355353](https://github.com/patrick204nqh/browserctl/commit/4355353213586dedf9f348f0fd211d1f0d3d72c6))
|
|
43
|
+
* workflow run --check + drift report (WS-2.3) ([#113](https://github.com/patrick204nqh/browserctl/issues/113)) ([7cb4bc7](https://github.com/patrick204nqh/browserctl/commit/7cb4bc7d967f4045e5709eacabb0408edf21f9f1))
|
|
44
|
+
* workflow run --check snapshot-diff (WS-3.5) ([#119](https://github.com/patrick204nqh/browserctl/issues/119)) ([1d131d8](https://github.com/patrick204nqh/browserctl/commit/1d131d85ea6e7948e9e734de6531c2d8dd194388))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
### Bug Fixes
|
|
48
|
+
|
|
49
|
+
* pass first open page to flow during load_state auto-rotate ([#105](https://github.com/patrick204nqh/browserctl/issues/105)) ([2c67733](https://github.com/patrick204nqh/browserctl/commit/2c67733fc4f879fc40833194e9e71e32cd3096a8))
|
|
50
|
+
|
|
51
|
+
## [0.10.0](https://github.com/patrick204nqh/browserctl/compare/v0.9.0...v0.10.0) (2026-05-09)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
### Features
|
|
55
|
+
|
|
56
|
+
* Browserctl::Flow class + DSL ([#82](https://github.com/patrick204nqh/browserctl/issues/82)) ([93282ed](https://github.com/patrick204nqh/browserctl/commit/93282edfc1358a371377c9ffbedf7cf1f6e7f567))
|
|
57
|
+
|
|
13
58
|
## [0.9.0](https://github.com/patrick204nqh/browserctl/compare/v0.8.4...v0.9.0) (2026-05-09)
|
|
14
59
|
|
|
15
60
|
|
data/README.md
CHANGED
|
@@ -196,7 +196,7 @@ The daemon shuts itself down after 30 minutes of inactivity.
|
|
|
196
196
|
|---|---|
|
|
197
197
|
| [Getting Started](docs/getting-started.md) | Install, first session, first snapshot |
|
|
198
198
|
| [Agent Integration](docs/guides/agent-integration.md) | Call browserctl from Python, shell, or Anthropic tool-use agents |
|
|
199
|
-
| [Concepts](docs/concepts/) | Sessions, snapshots, human-in-the-loop |
|
|
199
|
+
| [Concepts](docs/concepts/) | Sessions, snapshots, [state](docs/concepts/state.md), [flows](docs/concepts/flows.md), human-in-the-loop |
|
|
200
200
|
| [Guides](docs/guides/) | Writing workflows, handling challenges, smoke testing |
|
|
201
201
|
| [Examples](examples/) | Runnable scripts: session reuse, Cloudflare HITL, and more |
|
|
202
202
|
| [Command Reference](docs/reference/commands.md) | Every command and flag |
|
data/bin/browserctl
CHANGED
|
@@ -25,15 +25,18 @@ require "browserctl/commands/page"
|
|
|
25
25
|
require "browserctl/commands/cookie"
|
|
26
26
|
require "browserctl/commands/storage"
|
|
27
27
|
require "browserctl/commands/session"
|
|
28
|
+
require "browserctl/commands/state"
|
|
28
29
|
require "browserctl/commands/daemon"
|
|
29
30
|
require "browserctl/commands/workflow"
|
|
31
|
+
require "browserctl/commands/flow"
|
|
30
32
|
require "browserctl/commands/dialog"
|
|
31
33
|
require "browserctl/commands/ask"
|
|
32
34
|
|
|
33
35
|
def print_result(res)
|
|
34
|
-
if res.is_a?(Hash) && res[:error]
|
|
35
|
-
warn "Error: #{res[:error]}"
|
|
36
|
-
|
|
36
|
+
if res.is_a?(Hash) && (res[:error] || res["error"])
|
|
37
|
+
warn "Error: #{res[:error] || res['error']}"
|
|
38
|
+
puts res.to_json
|
|
39
|
+
exit((res[:code] || res["code"]) == "AUTH_REQUIRED" ? 7 : 1)
|
|
37
40
|
end
|
|
38
41
|
puts res.to_json
|
|
39
42
|
end
|
|
@@ -95,15 +98,36 @@ def usage
|
|
|
95
98
|
session export <name> <path>
|
|
96
99
|
session import <path>
|
|
97
100
|
|
|
101
|
+
Auth detection:
|
|
102
|
+
auth-check <page> [--cookies] [--state NAME] [--flow NAME]
|
|
103
|
+
# Exits 7 with code: AUTH_REQUIRED when the page needs login.
|
|
104
|
+
|
|
105
|
+
State (.bctl bundles — cookies + storage + flow binding):
|
|
106
|
+
state save <name> [--encrypt] [--origins a,b] [--flow NAME]
|
|
107
|
+
state load <name>
|
|
108
|
+
state list
|
|
109
|
+
state info <name>
|
|
110
|
+
state delete <name>
|
|
111
|
+
state rotate <name> [--page NAME] [--params FILE] [--key value ...]
|
|
112
|
+
state export <name> <destination> # file path, s3://..., op://Vault/Item
|
|
113
|
+
state import <source> [--name NAME]
|
|
114
|
+
|
|
98
115
|
Recording:
|
|
99
116
|
record start <name>
|
|
100
117
|
record stop [--out PATH]
|
|
101
118
|
record status
|
|
102
119
|
|
|
103
120
|
Workflow:
|
|
104
|
-
workflow run <name|file> [--params file] [--key value ...]
|
|
121
|
+
workflow run <name|file> [--check] [--params file] [--key value ...]
|
|
105
122
|
workflow list
|
|
106
123
|
workflow describe <name>
|
|
124
|
+
workflow generate <recording> [--out PATH]
|
|
125
|
+
workflow promote <name> [--force] [--threshold N] [--as-flow]
|
|
126
|
+
|
|
127
|
+
Flow:
|
|
128
|
+
flow run <name|file> [--page NAME] [--params file] [--key value ...]
|
|
129
|
+
flow list
|
|
130
|
+
flow describe <name>
|
|
107
131
|
|
|
108
132
|
Daemon:
|
|
109
133
|
daemon start [--headed] [--name NAME]
|
|
@@ -142,11 +166,28 @@ else
|
|
|
142
166
|
client = Browserctl::Client.new(Browserctl.socket_path(daemon_name))
|
|
143
167
|
|
|
144
168
|
case cmd
|
|
169
|
+
when "flow" then Browserctl::Commands::Flow.run(client, args)
|
|
145
170
|
when "page" then Browserctl::Commands::Page.run(client, args)
|
|
146
171
|
when "cookie" then Browserctl::Commands::Cookie.run(client, args)
|
|
147
172
|
when "storage" then Browserctl::Commands::Storage.run(client, args)
|
|
148
173
|
when "session" then Browserctl::Commands::Session.run(client, args)
|
|
174
|
+
when "state" then Browserctl::Commands::State.run(client, args)
|
|
149
175
|
when "daemon" then Browserctl::Commands::Daemon.run(client, args)
|
|
176
|
+
when "auth-check"
|
|
177
|
+
page_name = args.shift or abort "usage: browserctl auth-check <page> [--cookies] [--state NAME] [--flow NAME]"
|
|
178
|
+
include_cookies = args.delete("--cookies") ? true : false
|
|
179
|
+
state_idx = args.index("--state")
|
|
180
|
+
state_arg = if state_idx
|
|
181
|
+
(args.delete_at(state_idx)
|
|
182
|
+
args.delete_at(state_idx))
|
|
183
|
+
end
|
|
184
|
+
flow_idx = args.index("--flow")
|
|
185
|
+
flow_arg = if flow_idx
|
|
186
|
+
(args.delete_at(flow_idx)
|
|
187
|
+
args.delete_at(flow_idx))
|
|
188
|
+
end
|
|
189
|
+
print_result(client.auth_check(page_name, include_cookies: include_cookies,
|
|
190
|
+
state: state_arg, suggested_flow: flow_arg))
|
|
150
191
|
when "navigate" then print_result(client.navigate(args[0], args[1]))
|
|
151
192
|
when "fill" then Browserctl::Commands::Fill.run(client, args)
|
|
152
193
|
when "click" then Browserctl::Commands::Click.run(client, args)
|
data/lib/browserctl/client.rb
CHANGED
|
@@ -15,7 +15,7 @@ module Browserctl
|
|
|
15
15
|
|
|
16
16
|
def call(cmd, **params)
|
|
17
17
|
result = communicate(JSON.generate({ cmd: cmd }.merge(params)))
|
|
18
|
-
Recording.append(cmd, **params) if result[:ok]
|
|
18
|
+
Recording.append(cmd, response: result, **params) if result[:ok]
|
|
19
19
|
result
|
|
20
20
|
rescue Errno::ENOENT, Errno::ECONNREFUSED
|
|
21
21
|
raise DaemonUnavailableError, "browserd is not running — start it with: browserd"
|
|
@@ -50,7 +50,8 @@ module Browserctl
|
|
|
50
50
|
def click(name, selector = nil, ref: nil)
|
|
51
51
|
raise ArgumentError, "click: provide selector or ref:" unless selector || ref
|
|
52
52
|
|
|
53
|
-
call("click", name: name, selector: selector, ref: ref
|
|
53
|
+
call("click", name: name, selector: selector, ref: ref,
|
|
54
|
+
capture_post_snapshot: Recording.active ? true : nil)
|
|
54
55
|
end
|
|
55
56
|
|
|
56
57
|
# Fills an input element with a value.
|
|
@@ -62,7 +63,8 @@ module Browserctl
|
|
|
62
63
|
def fill(name, selector = nil, value = nil, ref: nil)
|
|
63
64
|
raise ArgumentError, "fill: provide selector or ref:" unless selector || ref
|
|
64
65
|
|
|
65
|
-
call("fill", name: name, selector: selector, ref: ref, value: value
|
|
66
|
+
call("fill", name: name, selector: selector, ref: ref, value: value,
|
|
67
|
+
capture_post_snapshot: Recording.active ? true : nil)
|
|
66
68
|
end
|
|
67
69
|
|
|
68
70
|
# Takes a screenshot of a named page.
|
|
@@ -308,6 +310,48 @@ module Browserctl
|
|
|
308
310
|
call("session_delete", session_name: session_name)
|
|
309
311
|
end
|
|
310
312
|
|
|
313
|
+
# Saves browser state (cookies + storage) into a single .bctl bundle.
|
|
314
|
+
# @return [Hash] `{ ok:, path:, origins:, cookies:, encrypted: }` or `{ error: }`
|
|
315
|
+
def state_save(name, origins: nil, flow: nil, flow_version: nil, passphrase: nil)
|
|
316
|
+
call("state_save",
|
|
317
|
+
name: name, origins: origins, flow: flow,
|
|
318
|
+
flow_version: flow_version, passphrase: passphrase)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Restores a .bctl bundle into the running daemon. The daemon runs the
|
|
322
|
+
# auth_required detector against the bundle's cookies before applying;
|
|
323
|
+
# callers that have already verified the bundle (e.g. workflow
|
|
324
|
+
# `load_state` after a successful rotate) can pass `skip_auth_check: true`
|
|
325
|
+
# to bypass it.
|
|
326
|
+
def state_load(name, passphrase: nil, skip_auth_check: false)
|
|
327
|
+
call("state_load", name: name, passphrase: passphrase, skip_auth_check: skip_auth_check)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Lists all stored state bundles (manifest only — no payload decryption).
|
|
331
|
+
def state_list = call("state_list")
|
|
332
|
+
|
|
333
|
+
# Reads a single bundle's manifest.
|
|
334
|
+
def state_info(name) = call("state_info", name: name)
|
|
335
|
+
|
|
336
|
+
# Permanently deletes a state bundle.
|
|
337
|
+
def state_delete(name) = call("state_delete", name: name)
|
|
338
|
+
|
|
339
|
+
# Runs the auth_required detector against a named page. Returns either
|
|
340
|
+
# `{ ok: true, auth_required: false }` or an AUTH_REQUIRED error response.
|
|
341
|
+
# @param name [String] page name
|
|
342
|
+
# @param include_cookies [Boolean] also feed the page's current cookies
|
|
343
|
+
# into the detector (catches expired-cookie auth)
|
|
344
|
+
# @param state [String, nil] bundle name the caller was working with;
|
|
345
|
+
# passed back verbatim so callers can recover without bookkeeping
|
|
346
|
+
# @param suggested_flow [String, nil] flow name to surface when triggered
|
|
347
|
+
def auth_check(name, include_cookies: false, state: nil, suggested_flow: nil)
|
|
348
|
+
call("auth_check",
|
|
349
|
+
name: name,
|
|
350
|
+
include_cookies: include_cookies,
|
|
351
|
+
state: state,
|
|
352
|
+
suggested_flow: suggested_flow)
|
|
353
|
+
end
|
|
354
|
+
|
|
311
355
|
private
|
|
312
356
|
|
|
313
357
|
def auto_discover_socket
|
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
3
5
|
module Browserctl
|
|
4
6
|
module Commands
|
|
5
7
|
module CliOutput
|
|
8
|
+
AUTH_REQUIRED_EXIT_CODE = Browserctl::AuthRequiredError::AUTH_REQUIRED_EXIT_CODE
|
|
9
|
+
|
|
6
10
|
def print_result(res)
|
|
7
|
-
if res.is_a?(Hash) && res[:error]
|
|
8
|
-
warn "Error: #{res[:error]}"
|
|
9
|
-
|
|
11
|
+
if res.is_a?(Hash) && (res[:error] || res["error"])
|
|
12
|
+
warn "Error: #{res[:error] || res['error']}"
|
|
13
|
+
puts res.to_json
|
|
14
|
+
exit exit_code_for(res)
|
|
10
15
|
end
|
|
11
16
|
puts res.to_json
|
|
12
17
|
end
|
|
18
|
+
|
|
19
|
+
# Maps a daemon error response onto a process exit code. Defaults to 1;
|
|
20
|
+
# special-cased only for codes that callers programmatically branch on.
|
|
21
|
+
def exit_code_for(res)
|
|
22
|
+
return AUTH_REQUIRED_EXIT_CODE if (res[:code] || res["code"]) == "AUTH_REQUIRED"
|
|
23
|
+
|
|
24
|
+
1
|
|
25
|
+
end
|
|
13
26
|
end
|
|
14
27
|
end
|
|
15
28
|
end
|
|
@@ -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,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
|