browserctl 0.10.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +1 -1
  4. data/bin/browserctl +45 -4
  5. data/lib/browserctl/client.rb +47 -3
  6. data/lib/browserctl/commands/cli_output.rb +16 -3
  7. data/lib/browserctl/commands/flow.rb +123 -0
  8. data/lib/browserctl/commands/state.rb +193 -0
  9. data/lib/browserctl/commands/workflow.rb +62 -4
  10. data/lib/browserctl/constants.rb +1 -1
  11. data/lib/browserctl/detectors/auth_required.rb +128 -0
  12. data/lib/browserctl/detectors.rb +2 -0
  13. data/lib/browserctl/errors.rb +30 -0
  14. data/lib/browserctl/flow.rb +22 -1
  15. data/lib/browserctl/flow_registry.rb +66 -0
  16. data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
  17. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
  18. data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
  19. data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
  20. data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
  21. data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
  22. data/lib/browserctl/recording.rb +212 -26
  23. data/lib/browserctl/replay/context.rb +40 -0
  24. data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
  25. data/lib/browserctl/replay/snapshot_diff.rb +51 -0
  26. data/lib/browserctl/replay/telemetry.rb +60 -0
  27. data/lib/browserctl/runner.rb +38 -4
  28. data/lib/browserctl/server/command_dispatcher.rb +10 -1
  29. data/lib/browserctl/server/handlers/interaction.rb +3 -3
  30. data/lib/browserctl/server/handlers/navigation.rb +33 -4
  31. data/lib/browserctl/server/handlers/observation.rb +43 -2
  32. data/lib/browserctl/server/handlers/state.rb +149 -0
  33. data/lib/browserctl/server/page_session.rb +9 -7
  34. data/lib/browserctl/server/snapshot_builder.rb +21 -45
  35. data/lib/browserctl/snapshot/annotator.rb +75 -0
  36. data/lib/browserctl/snapshot/extractor.rb +21 -0
  37. data/lib/browserctl/snapshot/fingerprint.rb +88 -0
  38. data/lib/browserctl/snapshot/ref.rb +70 -0
  39. data/lib/browserctl/snapshot/serializer.rb +17 -0
  40. data/lib/browserctl/state/bundle.rb +242 -0
  41. data/lib/browserctl/state/transport.rb +64 -0
  42. data/lib/browserctl/state/transports/file.rb +35 -0
  43. data/lib/browserctl/state/transports/one_password.rb +67 -0
  44. data/lib/browserctl/state/transports/s3.rb +42 -0
  45. data/lib/browserctl/state.rb +208 -0
  46. data/lib/browserctl/version.rb +1 -1
  47. data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
  48. data/lib/browserctl/workflow/promoter.rb +96 -0
  49. data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
  50. data/lib/browserctl/workflow.rb +180 -16
  51. metadata +31 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff8250901d49ad1038c686f51d303f2139fe673c708746804cdb59e1239890fe
4
- data.tar.gz: b47c1e8dbf9093ffe43001d8cacf02c490c887fb5e58336449f1b7aedebfda0b
3
+ metadata.gz: 0f20046bbdcf3ff57a52790137144c5f1197b6d3f8651f250bf6feb1cc9988f7
4
+ data.tar.gz: 1d928edc69e4691cc720b9bb7f32ea4001d1fd5df636a4af7fc59341f7714174
5
5
  SHA512:
6
- metadata.gz: e11c9a8300e7ec744e70ad5f33b645c9afc59e4e5212696045e899f4f2da017d1e4589f750db65b004d8e8208092d6a031724474a2a8feb48f249ed1daa83262
7
- data.tar.gz: 1a48cb05e7d92ab6dfd2de777abd0d8904a30b27c8d03fdaee2c00d1b5b734a9f8499be90ea30db686022a5c0d9ad7532ea1c1f148f0e1047a0583a954048dd8
6
+ metadata.gz: 87be1521e16f71d8f77c699712fb03c7a339b2421102e6ad0d4bc9c1a0e860ff29882c81d0e5819a802b3364962922594bd9a08d80cdf5521e50ea09659dac98
7
+ data.tar.gz: 29256e3d109bdfa651e8bea5fb82eef18fcb2b299c648db937015cebbfddd0d947e3e042b848adf32b61006f99d69150aaf38acf2a1356946c1584e2dc7a00b6
data/CHANGELOG.md CHANGED
@@ -10,6 +10,44 @@ 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
+
13
51
  ## [0.10.0](https://github.com/patrick204nqh/browserctl/compare/v0.9.0...v0.10.0) (2026-05-09)
14
52
 
15
53
 
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
- exit 1
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)
@@ -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
- exit 1
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