browserctl 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +2 -1
- data/bin/browserctl +168 -78
- data/bin/browserd +8 -1
- data/lib/browserctl/client.rb +50 -6
- data/lib/browserctl/commands/cli_output.rb +36 -3
- data/lib/browserctl/commands/flow.rb +123 -0
- data/lib/browserctl/commands/migrate.rb +94 -0
- data/lib/browserctl/commands/state.rb +193 -0
- data/lib/browserctl/commands/trace.rb +187 -0
- data/lib/browserctl/commands/workflow.rb +62 -4
- data/lib/browserctl/constants.rb +4 -2
- data/lib/browserctl/crash_report.rb +96 -0
- data/lib/browserctl/detectors/auth_required.rb +128 -0
- data/lib/browserctl/detectors.rb +2 -0
- data/lib/browserctl/error/codes.rb +44 -0
- data/lib/browserctl/error/exit_codes.rb +54 -0
- data/lib/browserctl/error/suggested_actions.rb +41 -0
- data/lib/browserctl/errors.rb +72 -12
- data/lib/browserctl/flow.rb +22 -1
- data/lib/browserctl/flow_registry.rb +66 -0
- data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
- data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
- data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
- data/lib/browserctl/format_version.rb +37 -0
- data/lib/browserctl/logger.rb +102 -9
- data/lib/browserctl/migrations.rb +216 -0
- data/lib/browserctl/recording.rb +246 -28
- data/lib/browserctl/redactor.rb +58 -0
- data/lib/browserctl/replay/context.rb +40 -0
- data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
- data/lib/browserctl/replay/snapshot_diff.rb +51 -0
- data/lib/browserctl/replay/telemetry.rb +60 -0
- data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
- data/lib/browserctl/runner.rb +50 -10
- data/lib/browserctl/secret_resolver_registry.rb +23 -4
- data/lib/browserctl/server/command_dispatcher.rb +13 -1
- data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
- data/lib/browserctl/server/handlers/error_payload.rb +27 -0
- data/lib/browserctl/server/handlers/interaction.rb +21 -3
- data/lib/browserctl/server/handlers/navigation.rb +50 -5
- data/lib/browserctl/server/handlers/observation.rb +43 -2
- data/lib/browserctl/server/handlers/state.rb +149 -0
- data/lib/browserctl/server/page_session.rb +9 -7
- data/lib/browserctl/server/snapshot_builder.rb +21 -45
- data/lib/browserctl/session.rb +1 -1
- data/lib/browserctl/snapshot/annotator.rb +75 -0
- data/lib/browserctl/snapshot/extractor.rb +21 -0
- data/lib/browserctl/snapshot/fingerprint.rb +88 -0
- data/lib/browserctl/snapshot/ref.rb +70 -0
- data/lib/browserctl/snapshot/serializer.rb +17 -0
- data/lib/browserctl/state/bundle.rb +283 -0
- data/lib/browserctl/state/transport.rb +64 -0
- data/lib/browserctl/state/transports/file.rb +35 -0
- data/lib/browserctl/state/transports/one_password.rb +67 -0
- data/lib/browserctl/state/transports/s3.rb +42 -0
- data/lib/browserctl/state.rb +208 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
- data/lib/browserctl/workflow/promoter.rb +96 -0
- data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
- data/lib/browserctl/workflow.rb +235 -16
- metadata +44 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2a7eb052c2bbfc4e1f5afc24ba08c5b2b0d471a85d2acf6ea61a7f160cbe201b
|
|
4
|
+
data.tar.gz: 2d4259da9b4a13a50ad80e68d404f11eda89b6249e5315c58247c8d80514d21f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca34ca3125de0686417b06919a8cb0eaf3311eaedd7ebe6538c1f159941d737ca549f3d0c3d8a10da73146bd23602cb145ed010539a48de97c3d8552d4f317c9
|
|
7
|
+
data.tar.gz: 61c89f8a4b2e61076ddd5fa603710e226ebfe1e176914ffb37f47a79ee35a2161b3700406a35c878d6fdc6cb129f051f00943d080b636076f6d1452b6ee290b3
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,72 @@ 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.12.0](https://github.com/patrick204nqh/browserctl/compare/v0.11.0...v0.12.0) (2026-05-10)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### ⚠ BREAKING CHANGES
|
|
17
|
+
|
|
18
|
+
* Scripts that hard-coded `exit 7 = AUTH_REQUIRED` must update — AUTH_REQUIRED is now exit 3, and 7 is STATE_EXPIRED. Scripts treating any non-zero status as a generic failure are unaffected. Prior to this change the CLI exited 1 for everything except AUTH_REQUIRED (which already used 7); typed errors now surface their dedicated codes.
|
|
19
|
+
|
|
20
|
+
### Features
|
|
21
|
+
|
|
22
|
+
* benchmark harness + bundle_codec target ([#154](https://github.com/patrick204nqh/browserctl/issues/154)) ([f4b815a](https://github.com/patrick204nqh/browserctl/commit/f4b815a617d4a7d22473e6028ce2a0f90467fa87))
|
|
23
|
+
* bundle format version ([#132](https://github.com/patrick204nqh/browserctl/issues/132)) ([b52d325](https://github.com/patrick204nqh/browserctl/commit/b52d32546d1b9ec3915fac014fecd8f929161ddb))
|
|
24
|
+
* crash reports ([#135](https://github.com/patrick204nqh/browserctl/issues/135)) ([b02adf4](https://github.com/patrick204nqh/browserctl/commit/b02adf4d41295dbe547d2965d838168d48efb8bf))
|
|
25
|
+
* error code enum ([#128](https://github.com/patrick204nqh/browserctl/issues/128)) ([feb0522](https://github.com/patrick204nqh/browserctl/commit/feb0522887935737fd96ad8a8b47d1407a5f585e))
|
|
26
|
+
* exit code map ([#140](https://github.com/patrick204nqh/browserctl/issues/140)) ([1a1d2b2](https://github.com/patrick204nqh/browserctl/commit/1a1d2b2f50693863cb508b5354e5546f340a2771))
|
|
27
|
+
* format version header convention ([#129](https://github.com/patrick204nqh/browserctl/issues/129)) ([405774d](https://github.com/patrick204nqh/browserctl/commit/405774dd6c99cc96f62f0c092010b30e7e710d41))
|
|
28
|
+
* migration helpers ([#143](https://github.com/patrick204nqh/browserctl/issues/143)) ([1c1762e](https://github.com/patrick204nqh/browserctl/commit/1c1762e755f83445c7cdd0df75fc5324a820fa19))
|
|
29
|
+
* recording format version ([#136](https://github.com/patrick204nqh/browserctl/issues/136)) ([4f1bc46](https://github.com/patrick204nqh/browserctl/commit/4f1bc467e653ecc1f508a85b3c329adf048b783d))
|
|
30
|
+
* structured error payload ([#137](https://github.com/patrick204nqh/browserctl/issues/137)) ([f028410](https://github.com/patrick204nqh/browserctl/commit/f02841069f68de749a76ba9a98a38b0c20836d76))
|
|
31
|
+
* structured JSONL logs ([#131](https://github.com/patrick204nqh/browserctl/issues/131)) ([c6daff4](https://github.com/patrick204nqh/browserctl/commit/c6daff488460606659c0641dd8cee6c23561be05))
|
|
32
|
+
* trace --redact ([#139](https://github.com/patrick204nqh/browserctl/issues/139)) ([19b4cec](https://github.com/patrick204nqh/browserctl/commit/19b4cec37daf95a06b0513780d991d0522069593))
|
|
33
|
+
* trace command ([#133](https://github.com/patrick204nqh/browserctl/issues/133)) ([156f304](https://github.com/patrick204nqh/browserctl/commit/156f304246d24fa6c3e06f8d941ac971629ea77c))
|
|
34
|
+
* workflow format version ([#138](https://github.com/patrick204nqh/browserctl/issues/138)) ([963776b](https://github.com/patrick204nqh/browserctl/commit/963776be785583fc3a3b0c04c97510e16c73ca9a))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
### Miscellaneous Chores
|
|
38
|
+
|
|
39
|
+
* target next release as 0.12.0 ([#156](https://github.com/patrick204nqh/browserctl/issues/156)) ([9797fcc](https://github.com/patrick204nqh/browserctl/commit/9797fcc0bae1a47bb72eec9cec5fda105e698406))
|
|
40
|
+
|
|
41
|
+
## [0.11.0](https://github.com/patrick204nqh/browserctl/compare/v0.10.0...v0.11.0) (2026-05-10)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
### Features
|
|
45
|
+
|
|
46
|
+
* .bctl bundle codec ([#94](https://github.com/patrick204nqh/browserctl/issues/94)) ([75a4370](https://github.com/patrick204nqh/browserctl/commit/75a43704abf9cf766435fff35409333514fd3c73))
|
|
47
|
+
* auth_required detector ([#99](https://github.com/patrick204nqh/browserctl/issues/99)) ([6d9554f](https://github.com/patrick204nqh/browserctl/commit/6d9554f7cbe9b8e93d22c42b03ff2ce59e594f28))
|
|
48
|
+
* AUTH_REQUIRED structured error code ([#100](https://github.com/patrick204nqh/browserctl/issues/100)) ([9928a4e](https://github.com/patrick204nqh/browserctl/commit/9928a4e7d456d0382b2244096fb38ebeee55013d))
|
|
49
|
+
* browserctl flow run/list/describe CLI ([#89](https://github.com/patrick204nqh/browserctl/issues/89)) ([5016fe5](https://github.com/patrick204nqh/browserctl/commit/5016fe54ee1414b967025f450ded79565ef9bcfb))
|
|
50
|
+
* drift telemetry (WS-2.4) ([#114](https://github.com/patrick204nqh/browserctl/issues/114)) ([712da07](https://github.com/patrick204nqh/browserctl/commit/712da075e623a2aa27a779958b94c0f6e92384c8))
|
|
51
|
+
* element fingerprint (WS-1.2) ([#107](https://github.com/patrick204nqh/browserctl/issues/107)) ([8a8b6b4](https://github.com/patrick204nqh/browserctl/commit/8a8b6b45121cb179919fae8c3f87a4584d4b4bc1))
|
|
52
|
+
* enriched recording log (WS-3.1) ([#115](https://github.com/patrick204nqh/browserctl/issues/115)) ([c00787d](https://github.com/patrick204nqh/browserctl/commit/c00787dc5f208cf69cd4a0326d4df19ed31e3bfc))
|
|
53
|
+
* fingerprint matcher (WS-2.1) ([#111](https://github.com/patrick204nqh/browserctl/issues/111)) ([1d99d20](https://github.com/patrick204nqh/browserctl/commit/1d99d208f5d3815c8ef1e59e3d549d15c3719712))
|
|
54
|
+
* flow registry ([#86](https://github.com/patrick204nqh/browserctl/issues/86)) ([31579dd](https://github.com/patrick204nqh/browserctl/commit/31579dd20156a700ace15137b34b120d16c86b6f))
|
|
55
|
+
* inferred waits (WS-3.3) ([#117](https://github.com/patrick204nqh/browserctl/issues/117)) ([9332754](https://github.com/patrick204nqh/browserctl/commit/93327545b1d21e00c16037c6730f019b574bc572))
|
|
56
|
+
* invoke flows from workflow DSL ([#88](https://github.com/patrick204nqh/browserctl/issues/88)) ([61fdc0a](https://github.com/patrick204nqh/browserctl/commit/61fdc0a6c98fdaf29c5034b80ccc54fdfb447e3a))
|
|
57
|
+
* 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))
|
|
58
|
+
* postcondition extraction (WS-3.4) ([#118](https://github.com/patrick204nqh/browserctl/issues/118)) ([56e76d5](https://github.com/patrick204nqh/browserctl/commit/56e76d50814d9c9a539ea56b20e6936d62509fcf))
|
|
59
|
+
* replay fallback in PageProxy (WS-2.2) ([#112](https://github.com/patrick204nqh/browserctl/issues/112)) ([50714fc](https://github.com/patrick204nqh/browserctl/commit/50714fcd2ff16614af0d18c8b390084e3f32511e))
|
|
60
|
+
* stable ref derivation (v0.11 WS-1.1) ([#106](https://github.com/patrick204nqh/browserctl/issues/106)) ([86df66a](https://github.com/patrick204nqh/browserctl/commit/86df66aaa9fc0b6c7a3a285c572ba2941b301e55))
|
|
61
|
+
* state export/import with file/s3/op transports ([#96](https://github.com/patrick204nqh/browserctl/issues/96)) ([f1dd97b](https://github.com/patrick204nqh/browserctl/commit/f1dd97b895503ee19e60f14bbb9df81dcb321bd1))
|
|
62
|
+
* state rotate ([#97](https://github.com/patrick204nqh/browserctl/issues/97)) ([78bb091](https://github.com/patrick204nqh/browserctl/commit/78bb091194e12e12867d7be0f566d426bdcb3b14))
|
|
63
|
+
* state save/load/list/info/delete ([#95](https://github.com/patrick204nqh/browserctl/issues/95)) ([4502cd1](https://github.com/patrick204nqh/browserctl/commit/4502cd1e8992f486b34ce2288a34a9ef78808617))
|
|
64
|
+
* stdlib flow — cloudflare_solve ([#93](https://github.com/patrick204nqh/browserctl/issues/93)) ([b4411b9](https://github.com/patrick204nqh/browserctl/commit/b4411b94af669d435346006c5f9304258456ba7b))
|
|
65
|
+
* stdlib flow — totp_2fa ([#90](https://github.com/patrick204nqh/browserctl/issues/90)) ([c23e574](https://github.com/patrick204nqh/browserctl/commit/c23e574887e1ef3324e41e988a8f8047079ac998))
|
|
66
|
+
* stdlib flows — basic_auth, magic_link_email ([#91](https://github.com/patrick204nqh/browserctl/issues/91)) ([e401d60](https://github.com/patrick204nqh/browserctl/commit/e401d60b4cf5b4b9f2b4138c45a9b19275a089e7))
|
|
67
|
+
* stdlib flows — oauth_google, oauth_github ([#92](https://github.com/patrick204nqh/browserctl/issues/92)) ([04c9586](https://github.com/patrick204nqh/browserctl/commit/04c9586ff83503b7d94a2c73fe9c3360f01dc4ca))
|
|
68
|
+
* workflow generate (WS-3.2) ([#116](https://github.com/patrick204nqh/browserctl/issues/116)) ([90dca3b](https://github.com/patrick204nqh/browserctl/commit/90dca3bada9b53f91e9be39e3d94d0208dcfa181))
|
|
69
|
+
* workflow promote --as-flow (WS-3.7) ([#121](https://github.com/patrick204nqh/browserctl/issues/121)) ([a5e4b30](https://github.com/patrick204nqh/browserctl/commit/a5e4b30a30559e8fa6b3f4ff3df7189dc3d6bd1e))
|
|
70
|
+
* workflow promote (WS-3.6) ([#120](https://github.com/patrick204nqh/browserctl/issues/120)) ([4355353](https://github.com/patrick204nqh/browserctl/commit/4355353213586dedf9f348f0fd211d1f0d3d72c6))
|
|
71
|
+
* workflow run --check + drift report (WS-2.3) ([#113](https://github.com/patrick204nqh/browserctl/issues/113)) ([7cb4bc7](https://github.com/patrick204nqh/browserctl/commit/7cb4bc7d967f4045e5709eacabb0408edf21f9f1))
|
|
72
|
+
* workflow run --check snapshot-diff (WS-3.5) ([#119](https://github.com/patrick204nqh/browserctl/issues/119)) ([1d131d8](https://github.com/patrick204nqh/browserctl/commit/1d131d85ea6e7948e9e734de6531c2d8dd194388))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
### Bug Fixes
|
|
76
|
+
|
|
77
|
+
* 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))
|
|
78
|
+
|
|
13
79
|
## [0.10.0](https://github.com/patrick204nqh/browserctl/compare/v0.9.0...v0.10.0) (2026-05-09)
|
|
14
80
|
|
|
15
81
|
|
data/README.md
CHANGED
|
@@ -196,8 +196,9 @@ 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
|
+
| [Debugging](docs/guides/debugging.md) | Read traces, redaction, crash reports, filing a good issue |
|
|
201
202
|
| [Examples](examples/) | Runnable scripts: session reuse, Cloudflare HITL, and more |
|
|
202
203
|
| [Command Reference](docs/reference/commands.md) | Every command and flag |
|
|
203
204
|
| [API Stability](docs/reference/api-stability.md) | Wire protocol contract and stability zones |
|
data/bin/browserctl
CHANGED
|
@@ -25,17 +25,35 @@ 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"
|
|
34
|
+
require "browserctl/commands/trace"
|
|
35
|
+
require "browserctl/commands/migrate"
|
|
36
|
+
|
|
37
|
+
def structured_stderr_payload(res, message)
|
|
38
|
+
code = (res[:code] || res["code"] || Browserctl::Error::Codes::GENERIC).to_s
|
|
39
|
+
context = res[:context] || res["context"] || {}
|
|
40
|
+
action = res[:suggested_action] || res["suggested_action"] ||
|
|
41
|
+
Browserctl::Error::SuggestedActions.for(code)
|
|
42
|
+
[code, JSON.generate(code: code, message: message, context: context, suggested_action: action)]
|
|
43
|
+
end
|
|
32
44
|
|
|
33
45
|
def print_result(res)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
unless res.is_a?(Hash) && (res[:error] || res["error"])
|
|
47
|
+
puts res.to_json
|
|
48
|
+
return
|
|
37
49
|
end
|
|
50
|
+
|
|
51
|
+
message = res[:error] || res["error"]
|
|
52
|
+
code, payload = structured_stderr_payload(res, message)
|
|
53
|
+
warn "Error: #{message}"
|
|
54
|
+
warn payload
|
|
38
55
|
puts res.to_json
|
|
56
|
+
exit Browserctl::Error::ExitCodes.for(code)
|
|
39
57
|
end
|
|
40
58
|
|
|
41
59
|
def usage
|
|
@@ -95,15 +113,45 @@ def usage
|
|
|
95
113
|
session export <name> <path>
|
|
96
114
|
session import <path>
|
|
97
115
|
|
|
116
|
+
Auth detection:
|
|
117
|
+
auth-check <page> [--cookies] [--state NAME] [--flow NAME]
|
|
118
|
+
# Exits 7 with code: AUTH_REQUIRED when the page needs login.
|
|
119
|
+
|
|
120
|
+
State (.bctl bundles — cookies + storage + flow binding):
|
|
121
|
+
state save <name> [--encrypt] [--origins a,b] [--flow NAME]
|
|
122
|
+
state load <name>
|
|
123
|
+
state list
|
|
124
|
+
state info <name>
|
|
125
|
+
state delete <name>
|
|
126
|
+
state rotate <name> [--page NAME] [--params FILE] [--key value ...]
|
|
127
|
+
state export <name> <destination> # file path, s3://..., op://Vault/Item
|
|
128
|
+
state import <source> [--name NAME]
|
|
129
|
+
|
|
98
130
|
Recording:
|
|
99
131
|
record start <name>
|
|
100
132
|
record stop [--out PATH]
|
|
101
133
|
record status
|
|
102
134
|
|
|
103
135
|
Workflow:
|
|
104
|
-
workflow run <name|file> [--params file] [--key value ...]
|
|
136
|
+
workflow run <name|file> [--check] [--params file] [--key value ...]
|
|
105
137
|
workflow list
|
|
106
138
|
workflow describe <name>
|
|
139
|
+
workflow generate <recording> [--out PATH]
|
|
140
|
+
workflow promote <name> [--force] [--threshold N] [--as-flow]
|
|
141
|
+
|
|
142
|
+
Flow:
|
|
143
|
+
flow run <name|file> [--page NAME] [--params file] [--key value ...]
|
|
144
|
+
flow list
|
|
145
|
+
flow describe <name>
|
|
146
|
+
|
|
147
|
+
Trace:
|
|
148
|
+
trace [<session>] Pretty timeline of CLI + daemon log events.
|
|
149
|
+
|
|
150
|
+
Migrate:
|
|
151
|
+
migrate <path> [--to-version N] [--dry-run]
|
|
152
|
+
# Upgrades a persisted artifact (.bctl / .jsonl recording / workflow .rb)
|
|
153
|
+
# using registered migrations. Empty registry in v0.12 — first real
|
|
154
|
+
# migration ships post-1.0 when a format actually changes.
|
|
107
155
|
|
|
108
156
|
Daemon:
|
|
109
157
|
daemon start [--headed] [--name NAME]
|
|
@@ -125,6 +173,17 @@ daemon_name = if daemon_idx
|
|
|
125
173
|
ARGV.delete_at(daemon_idx)
|
|
126
174
|
end
|
|
127
175
|
|
|
176
|
+
log_level_idx = ARGV.index("--log-level") || ARGV.index("-l")
|
|
177
|
+
log_level = if log_level_idx
|
|
178
|
+
ARGV.delete_at(log_level_idx)
|
|
179
|
+
ARGV.delete_at(log_level_idx)
|
|
180
|
+
end || ENV["BROWSERCTL_LOG_LEVEL"] || "info"
|
|
181
|
+
|
|
182
|
+
# CLI invocations write structured JSON Lines to ~/.browserctl/logs/cli.log
|
|
183
|
+
# alongside the existing stderr behaviour. Stderr stays human-readable so
|
|
184
|
+
# scripted callers see no change.
|
|
185
|
+
Browserctl.logger = Browserctl.build_logger(log_level, component: "cli")
|
|
186
|
+
|
|
128
187
|
cmd = ARGV.shift
|
|
129
188
|
args = ARGV.dup
|
|
130
189
|
|
|
@@ -132,82 +191,113 @@ usage if cmd.nil? || %w[-h --help help].include?(cmd)
|
|
|
132
191
|
|
|
133
192
|
runner = Browserctl::Runner.new
|
|
134
193
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
when "
|
|
138
|
-
when "
|
|
139
|
-
when "
|
|
194
|
+
begin
|
|
195
|
+
case cmd
|
|
196
|
+
when "workflow" then Browserctl::Commands::Workflow.run(runner, args)
|
|
197
|
+
when "record" then Browserctl::Commands::Record.run(args)
|
|
198
|
+
when "init" then Browserctl::Commands::Init.run(args)
|
|
199
|
+
when "ask" then Browserctl::Commands::Ask.run(args)
|
|
200
|
+
when "trace" then Browserctl::Commands::Trace.run(args)
|
|
201
|
+
when "migrate" then Browserctl::Commands::Migrate.run(args)
|
|
140
202
|
|
|
141
|
-
else
|
|
142
|
-
|
|
203
|
+
else
|
|
204
|
+
client = Browserctl::Client.new(Browserctl.socket_path(daemon_name))
|
|
143
205
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
206
|
+
case cmd
|
|
207
|
+
when "flow" then Browserctl::Commands::Flow.run(client, args)
|
|
208
|
+
when "page" then Browserctl::Commands::Page.run(client, args)
|
|
209
|
+
when "cookie" then Browserctl::Commands::Cookie.run(client, args)
|
|
210
|
+
when "storage" then Browserctl::Commands::Storage.run(client, args)
|
|
211
|
+
when "session" then Browserctl::Commands::Session.run(client, args)
|
|
212
|
+
when "state" then Browserctl::Commands::State.run(client, args)
|
|
213
|
+
when "daemon" then Browserctl::Commands::Daemon.run(client, args)
|
|
214
|
+
when "auth-check"
|
|
215
|
+
page_name = args.shift or abort "usage: browserctl auth-check <page> [--cookies] [--state NAME] [--flow NAME]"
|
|
216
|
+
include_cookies = args.delete("--cookies") ? true : false
|
|
217
|
+
state_idx = args.index("--state")
|
|
218
|
+
state_arg = if state_idx
|
|
219
|
+
(args.delete_at(state_idx)
|
|
220
|
+
args.delete_at(state_idx))
|
|
221
|
+
end
|
|
222
|
+
flow_idx = args.index("--flow")
|
|
223
|
+
flow_arg = if flow_idx
|
|
224
|
+
(args.delete_at(flow_idx)
|
|
225
|
+
args.delete_at(flow_idx))
|
|
226
|
+
end
|
|
227
|
+
print_result(client.auth_check(page_name, include_cookies: include_cookies,
|
|
228
|
+
state: state_arg, suggested_flow: flow_arg))
|
|
229
|
+
when "navigate" then print_result(client.navigate(args[0], args[1]))
|
|
230
|
+
when "fill" then Browserctl::Commands::Fill.run(client, args)
|
|
231
|
+
when "click" then Browserctl::Commands::Click.run(client, args)
|
|
232
|
+
when "snapshot" then Browserctl::Commands::Snapshot.run(client, args)
|
|
233
|
+
when "screenshot" then Browserctl::Commands::Screenshot.run(client, args)
|
|
234
|
+
when "evaluate" then print_result(client.evaluate(args[0], args[1]))
|
|
235
|
+
when "url" then print_result(client.url(args[0]))
|
|
236
|
+
when "wait"
|
|
237
|
+
opts = Optimist.options(args) do
|
|
238
|
+
opt :timeout, "Seconds to wait (default: 30)", default: 30.0, short: "-t"
|
|
239
|
+
end
|
|
240
|
+
name = args.shift
|
|
241
|
+
selector = args.shift
|
|
242
|
+
abort "usage: browserctl wait <page> <selector> [--timeout N]" unless name && selector
|
|
243
|
+
print_result(client.wait(name, selector, timeout: opts[:timeout]))
|
|
244
|
+
when "pause"
|
|
245
|
+
opts = Optimist.options(args) do
|
|
246
|
+
opt :message, "Message shown to human", type: :string, short: "-m"
|
|
247
|
+
end
|
|
248
|
+
name = args.shift or abort "usage: browserctl pause <page> [--message MSG]"
|
|
249
|
+
res = client.pause(name, message: opts[:message])
|
|
250
|
+
if res[:error]
|
|
251
|
+
warn "Error: #{res[:error]}"
|
|
252
|
+
exit 1
|
|
253
|
+
end
|
|
254
|
+
puts "Page '#{name}' paused. Browser is live — interact freely."
|
|
255
|
+
puts "(#{opts[:message]})" if opts[:message]
|
|
256
|
+
puts "When done: browserctl resume #{name}"
|
|
257
|
+
when "resume" then Browserctl::Commands::Resume.run(client, args)
|
|
258
|
+
when "press"
|
|
259
|
+
name = args.shift or abort "usage: browserctl press <page> <key>"
|
|
260
|
+
key = args.shift or abort "usage: browserctl press <page> <key>"
|
|
261
|
+
print_result(client.press(name, key))
|
|
262
|
+
when "hover"
|
|
263
|
+
name = args.shift or abort "usage: browserctl hover <page> <selector>"
|
|
264
|
+
selector = args.shift or abort "usage: browserctl hover <page> <selector>"
|
|
265
|
+
print_result(client.hover(name, selector))
|
|
266
|
+
when "upload"
|
|
267
|
+
name = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
|
|
268
|
+
selector = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
|
|
269
|
+
path = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
|
|
270
|
+
print_result(client.upload(name, selector, path))
|
|
271
|
+
when "select"
|
|
272
|
+
name = args.shift or abort "usage: browserctl select <page> <selector> <value>"
|
|
273
|
+
selector = args.shift or abort "usage: browserctl select <page> <selector> <value>"
|
|
274
|
+
value = args.shift or abort "usage: browserctl select <page> <selector> <value>"
|
|
275
|
+
print_result(client.select(name, selector, value))
|
|
276
|
+
when "dialog" then Browserctl::Commands::Dialog.run(client, args)
|
|
277
|
+
when "devtools"
|
|
278
|
+
name = args.shift or abort "usage: browserctl devtools <page>"
|
|
279
|
+
res = client.devtools(name)
|
|
280
|
+
if res[:error]
|
|
281
|
+
warn "Error: #{res[:error]}"
|
|
282
|
+
exit 1
|
|
283
|
+
end
|
|
284
|
+
url = res[:devtools_url]
|
|
285
|
+
puts "Opening DevTools for '#{name}':"
|
|
286
|
+
puts " #{url}"
|
|
287
|
+
opener = RUBY_PLATFORM =~ /darwin/ ? "open" : "xdg-open"
|
|
288
|
+
system(opener, url)
|
|
289
|
+
else
|
|
290
|
+
abort "unknown command: #{cmd}\nRun 'browserctl --help' for usage."
|
|
160
291
|
end
|
|
161
|
-
name = args.shift
|
|
162
|
-
selector = args.shift
|
|
163
|
-
abort "usage: browserctl wait <page> <selector> [--timeout N]" unless name && selector
|
|
164
|
-
print_result(client.wait(name, selector, timeout: opts[:timeout]))
|
|
165
|
-
when "pause"
|
|
166
|
-
opts = Optimist.options(args) do
|
|
167
|
-
opt :message, "Message shown to human", type: :string, short: "-m"
|
|
168
|
-
end
|
|
169
|
-
name = args.shift or abort "usage: browserctl pause <page> [--message MSG]"
|
|
170
|
-
res = client.pause(name, message: opts[:message])
|
|
171
|
-
if res[:error]
|
|
172
|
-
warn "Error: #{res[:error]}"
|
|
173
|
-
exit 1
|
|
174
|
-
end
|
|
175
|
-
puts "Page '#{name}' paused. Browser is live — interact freely."
|
|
176
|
-
puts "(#{opts[:message]})" if opts[:message]
|
|
177
|
-
puts "When done: browserctl resume #{name}"
|
|
178
|
-
when "resume" then Browserctl::Commands::Resume.run(client, args)
|
|
179
|
-
when "press"
|
|
180
|
-
name = args.shift or abort "usage: browserctl press <page> <key>"
|
|
181
|
-
key = args.shift or abort "usage: browserctl press <page> <key>"
|
|
182
|
-
print_result(client.press(name, key))
|
|
183
|
-
when "hover"
|
|
184
|
-
name = args.shift or abort "usage: browserctl hover <page> <selector>"
|
|
185
|
-
selector = args.shift or abort "usage: browserctl hover <page> <selector>"
|
|
186
|
-
print_result(client.hover(name, selector))
|
|
187
|
-
when "upload"
|
|
188
|
-
name = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
|
|
189
|
-
selector = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
|
|
190
|
-
path = args.shift or abort "usage: browserctl upload <page> <selector> <file>"
|
|
191
|
-
print_result(client.upload(name, selector, path))
|
|
192
|
-
when "select"
|
|
193
|
-
name = args.shift or abort "usage: browserctl select <page> <selector> <value>"
|
|
194
|
-
selector = args.shift or abort "usage: browserctl select <page> <selector> <value>"
|
|
195
|
-
value = args.shift or abort "usage: browserctl select <page> <selector> <value>"
|
|
196
|
-
print_result(client.select(name, selector, value))
|
|
197
|
-
when "dialog" then Browserctl::Commands::Dialog.run(client, args)
|
|
198
|
-
when "devtools"
|
|
199
|
-
name = args.shift or abort "usage: browserctl devtools <page>"
|
|
200
|
-
res = client.devtools(name)
|
|
201
|
-
if res[:error]
|
|
202
|
-
warn "Error: #{res[:error]}"
|
|
203
|
-
exit 1
|
|
204
|
-
end
|
|
205
|
-
url = res[:devtools_url]
|
|
206
|
-
puts "Opening DevTools for '#{name}':"
|
|
207
|
-
puts " #{url}"
|
|
208
|
-
opener = RUBY_PLATFORM =~ /darwin/ ? "open" : "xdg-open"
|
|
209
|
-
system(opener, url)
|
|
210
|
-
else
|
|
211
|
-
abort "unknown command: #{cmd}\nRun 'browserctl --help' for usage."
|
|
212
292
|
end
|
|
293
|
+
rescue Browserctl::Error => e
|
|
294
|
+
payload = {
|
|
295
|
+
code: e.code,
|
|
296
|
+
message: e.message,
|
|
297
|
+
context: e.respond_to?(:context) ? (e.context || {}) : {},
|
|
298
|
+
suggested_action: Browserctl::Error::SuggestedActions.for(e.code)
|
|
299
|
+
}
|
|
300
|
+
warn "Error: #{e.message}"
|
|
301
|
+
warn JSON.generate(payload)
|
|
302
|
+
exit Browserctl::Error::ExitCodes.for(e.code)
|
|
213
303
|
end
|
data/bin/browserd
CHANGED
|
@@ -5,6 +5,7 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
|
5
5
|
|
|
6
6
|
require "optimist"
|
|
7
7
|
require "nokogiri"
|
|
8
|
+
require "browserctl/crash_report"
|
|
8
9
|
require "browserctl/logger"
|
|
9
10
|
require "browserctl/server"
|
|
10
11
|
require "browserctl/version"
|
|
@@ -35,7 +36,9 @@ end
|
|
|
35
36
|
|
|
36
37
|
log_path = Browserctl.log_path(assigned_name)
|
|
37
38
|
warn "browserd starting — log: #{log_path}"
|
|
38
|
-
|
|
39
|
+
warn " if browserd crashes, attach the crash report from ~/.browserctl/logs/crash-*.json"
|
|
40
|
+
Browserctl.logger = Browserctl.build_logger(opts[:log_level], log_path: log_path, component: "daemon")
|
|
41
|
+
daemon_jsonl_log = File.join(Browserctl.log_dir, "daemon.log")
|
|
39
42
|
begin
|
|
40
43
|
Browserctl::Server.new(
|
|
41
44
|
headless: !opts[:headed],
|
|
@@ -45,4 +48,8 @@ begin
|
|
|
45
48
|
).run
|
|
46
49
|
rescue Browserctl::BrowserNotFound => e
|
|
47
50
|
abort e.message
|
|
51
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
52
|
+
crash_path = Browserctl::CrashReport.write(error: e, log_path: daemon_jsonl_log)
|
|
53
|
+
warn "browserd crashed — crash report: #{crash_path}" if crash_path
|
|
54
|
+
raise
|
|
48
55
|
end
|
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.
|
|
@@ -180,7 +182,7 @@ module Browserctl
|
|
|
180
182
|
# @param path [String] file path to read cookies from
|
|
181
183
|
# @return [Hash] `{ ok: true, count: }` or `{ error: }`
|
|
182
184
|
def import_cookies(name, path)
|
|
183
|
-
raise "cookie file not found: #{path}" unless File.exist?(path)
|
|
185
|
+
raise Browserctl::Error, "cookie file not found: #{path}" unless File.exist?(path)
|
|
184
186
|
|
|
185
187
|
cookies = JSON.parse(File.read(path), symbolize_names: true)
|
|
186
188
|
call("import_cookies", name: name, cookies: cookies)
|
|
@@ -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
|
|
@@ -327,10 +371,10 @@ module Browserctl
|
|
|
327
371
|
end
|
|
328
372
|
|
|
329
373
|
def read_response(sock)
|
|
330
|
-
raise "browserd response timeout after 60s" unless sock.wait_readable(60)
|
|
374
|
+
raise DaemonUnavailableError, "browserd response timeout after 60s" unless sock.wait_readable(60)
|
|
331
375
|
|
|
332
376
|
raw = sock.gets
|
|
333
|
-
raise "browserd closed connection" unless raw
|
|
377
|
+
raise DaemonUnavailableError, "browserd closed connection" unless raw
|
|
334
378
|
|
|
335
379
|
JSON.parse(raw.chomp, symbolize_names: true)
|
|
336
380
|
end
|
|
@@ -1,15 +1,48 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "../error/suggested_actions"
|
|
6
|
+
|
|
3
7
|
module Browserctl
|
|
4
8
|
module Commands
|
|
5
9
|
module CliOutput
|
|
10
|
+
AUTH_REQUIRED_EXIT_CODE = Browserctl::AuthRequiredError::AUTH_REQUIRED_EXIT_CODE
|
|
11
|
+
|
|
6
12
|
def print_result(res)
|
|
7
|
-
if res.is_a?(Hash) && res[:error]
|
|
8
|
-
|
|
9
|
-
|
|
13
|
+
if res.is_a?(Hash) && (res[:error] || res["error"])
|
|
14
|
+
message = res[:error] || res["error"]
|
|
15
|
+
warn "Error: #{message}"
|
|
16
|
+
warn structured_error_line(res, message)
|
|
17
|
+
puts res.to_json
|
|
18
|
+
exit exit_code_for(res)
|
|
10
19
|
end
|
|
11
20
|
puts res.to_json
|
|
12
21
|
end
|
|
22
|
+
|
|
23
|
+
# Maps a daemon error response onto a process exit code. Defaults to 1;
|
|
24
|
+
# special-cased only for codes that callers programmatically branch on.
|
|
25
|
+
def exit_code_for(res)
|
|
26
|
+
return AUTH_REQUIRED_EXIT_CODE if (res[:code] || res["code"]) == "AUTH_REQUIRED"
|
|
27
|
+
|
|
28
|
+
1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Builds the single-line structured payload emitted to stderr after
|
|
32
|
+
# the human-readable line. Agents parse this JSON deterministically.
|
|
33
|
+
# Shape: { code, message, context, suggested_action }.
|
|
34
|
+
def structured_error_line(res, message)
|
|
35
|
+
code = (res[:code] || res["code"] || Browserctl::Error::Codes::GENERIC).to_s
|
|
36
|
+
context = res[:context] || res["context"] || {}
|
|
37
|
+
action = res[:suggested_action] || res["suggested_action"] ||
|
|
38
|
+
Browserctl::Error::SuggestedActions.for(code)
|
|
39
|
+
JSON.generate(
|
|
40
|
+
code: code,
|
|
41
|
+
message: message,
|
|
42
|
+
context: context,
|
|
43
|
+
suggested_action: action
|
|
44
|
+
)
|
|
45
|
+
end
|
|
13
46
|
end
|
|
14
47
|
end
|
|
15
48
|
end
|