browserctl 0.12.0 → 0.13.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +3 -3
  4. data/bin/browserctl +39 -32
  5. data/lib/browserctl/callable_definition.rb +114 -0
  6. data/lib/browserctl/client.rb +0 -27
  7. data/lib/browserctl/commands/cli_output.rb +17 -3
  8. data/lib/browserctl/commands/daemon.rb +10 -6
  9. data/lib/browserctl/commands/flow.rb +7 -5
  10. data/lib/browserctl/commands/init.rb +20 -7
  11. data/lib/browserctl/commands/migrate.rb +56 -8
  12. data/lib/browserctl/commands/output_format.rb +144 -0
  13. data/lib/browserctl/commands/page.rb +9 -5
  14. data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
  15. data/lib/browserctl/commands/resume.rb +1 -1
  16. data/lib/browserctl/commands/screenshot.rb +2 -2
  17. data/lib/browserctl/commands/snapshot.rb +8 -3
  18. data/lib/browserctl/commands/state.rb +3 -2
  19. data/lib/browserctl/commands/trace.rb +40 -11
  20. data/lib/browserctl/commands/workflow.rb +9 -7
  21. data/lib/browserctl/contextual_persistence.rb +58 -0
  22. data/lib/browserctl/driver/cdp.rb +2 -3
  23. data/lib/browserctl/encryption_service.rb +84 -0
  24. data/lib/browserctl/flow.rb +35 -59
  25. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
  26. data/lib/browserctl/recording/log_writer.rb +82 -0
  27. data/lib/browserctl/recording/redactor.rb +58 -0
  28. data/lib/browserctl/recording/state.rb +44 -0
  29. data/lib/browserctl/recording/workflow_renderer.rb +214 -0
  30. data/lib/browserctl/recording.rb +33 -294
  31. data/lib/browserctl/server/command_dispatcher.rb +25 -16
  32. data/lib/browserctl/server/handlers/state.rb +7 -5
  33. data/lib/browserctl/server.rb +2 -1
  34. data/lib/browserctl/state/bundle.rb +20 -47
  35. data/lib/browserctl/state.rb +46 -9
  36. data/lib/browserctl/version.rb +1 -1
  37. data/lib/browserctl/workflow/recovery_manager.rb +87 -0
  38. data/lib/browserctl/workflow.rb +61 -237
  39. metadata +11 -8
  40. data/examples/session_reuse.rb +0 -75
  41. data/lib/browserctl/commands/session.rb +0 -243
  42. data/lib/browserctl/driver/base.rb +0 -13
  43. data/lib/browserctl/driver.rb +0 -5
  44. data/lib/browserctl/server/handlers/session.rb +0 -94
  45. data/lib/browserctl/session.rb +0 -206
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a7eb052c2bbfc4e1f5afc24ba08c5b2b0d471a85d2acf6ea61a7f160cbe201b
4
- data.tar.gz: 2d4259da9b4a13a50ad80e68d404f11eda89b6249e5315c58247c8d80514d21f
3
+ metadata.gz: 41aa6c550f8a9a6403781639ebbd381c653aedb03cfa95f732b74de3ee0a931d
4
+ data.tar.gz: 3feea758e797eca81e1ab269ca5d9011f82778510dab1ee45e31b80b9aa38048
5
5
  SHA512:
6
- metadata.gz: ca34ca3125de0686417b06919a8cb0eaf3311eaedd7ebe6538c1f159941d737ca549f3d0c3d8a10da73146bd23602cb145ed010539a48de97c3d8552d4f317c9
7
- data.tar.gz: 61c89f8a4b2e61076ddd5fa603710e226ebfe1e176914ffb37f47a79ee35a2161b3700406a35c878d6fdc6cb129f051f00943d080b636076f6d1452b6ee290b3
6
+ metadata.gz: e100a2bce4a66b7ccd1cfaebfd5e5acb3b0f9c32aafec8427efe0f8d2b613ae954d9043a8d4da536284b7739508d4b318dab5e2abe16a83113938a23fb000796
7
+ data.tar.gz: b2171d7d6043c5c89084a21f50a5c53bbaa0ce1e891babeb30b4beb66ede7e41e8f87e7382980bb9f9229e453121cc99bd252aa4d54c48598d01bdbc7fbdadab
data/CHANGELOG.md CHANGED
@@ -10,6 +10,23 @@ 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.13.0](https://github.com/patrick204nqh/browserctl/compare/v0.12.0...v0.13.0) (2026-05-10)
14
+
15
+
16
+ ### ⚠ BREAKING CHANGES
17
+
18
+ * `--output` is now a reserved global flag on every browserctl command. Existing invocations are unaffected (default mode `text` preserves prior byte-for-byte output), but commands that previously consumed `--output` as a positional or sub-flag would now collide. None ship in this gem.
19
+ * The CLI subcommands `snapshot`, `screenshot`, and `record` are removed with no aliases. Use `page snapshot`, `page screenshot`, and `recording start|stop|status` respectively.
20
+ * The `session` CLI commands and the `session_*` JSON-RPC wire commands are removed. Users on v0.12 sessions must regenerate state via `state save` before upgrading. The workflow DSL methods `save_session`, `load_session`, and `list_sessions` are also removed; use `save_state` / `load_state` instead.
21
+ * `store` and `fetch` wire commands are no longer part of the Fixed zone. They move to Extension and may change between minor releases with a changelog entry. The wire shape is unchanged in v0.13; the change is to the stability promise.
22
+
23
+ ### Features
24
+
25
+ * narrow Fixed zone — store/fetch and known overlaps ([#158](https://github.com/patrick204nqh/browserctl/issues/158)) ([067cb42](https://github.com/patrick204nqh/browserctl/commit/067cb423b5966784abc8be2395808b5e4fa92ac0))
26
+ * noun-verb CLI consistency ([#162](https://github.com/patrick204nqh/browserctl/issues/162)) ([8ceb729](https://github.com/patrick204nqh/browserctl/commit/8ceb72998008a8e6983cb4bad13ac5f286ad9ee8))
27
+ * remove session commands and code path ([#161](https://github.com/patrick204nqh/browserctl/issues/161)) ([12d58d9](https://github.com/patrick204nqh/browserctl/commit/12d58d9aa26b2609c98e4b34ce072a6dbb8baafe))
28
+ * unified --output {json,text,silent} on every CLI command ([#164](https://github.com/patrick204nqh/browserctl/issues/164)) ([f8947c6](https://github.com/patrick204nqh/browserctl/commit/f8947c655bc0b1f21bbf6067a059d6ab0b7d8b9e))
29
+
13
30
  ## [0.12.0](https://github.com/patrick204nqh/browserctl/compare/v0.11.0...v0.12.0) (2026-05-10)
14
31
 
15
32
 
data/README.md CHANGED
@@ -21,7 +21,7 @@ Every browser automation tool restarts the browser when your script ends. That m
21
21
  ```bash
22
22
  browserd & # start the daemon (headless)
23
23
  browserctl page open main --url https://example.com/login
24
- browserctl snapshot main # AI-friendly JSON snapshot with ref IDs
24
+ browserctl page snapshot main # AI-friendly JSON snapshot with ref IDs
25
25
  browserctl fill main --ref e1 --value me@example.com # interact by ref, no selectors needed
26
26
  browserctl click main --ref e2
27
27
  browserctl daemon stop
@@ -42,7 +42,7 @@ browserd &
42
42
  browserctl page open main --url https://moatazeldebsy.github.io/test-automation-practices/#/auth
43
43
 
44
44
  # 4. Snapshot — returns JSON with a ref ID per interactable element
45
- browserctl snapshot main
45
+ browserctl page snapshot main
46
46
  # → [{"ref":"e1","tag":"input","attrs":{"data-test":"username-input"}}, {"ref":"e2",...}, {"ref":"e3","tag":"button","text":"Login",...}]
47
47
 
48
48
  # 5. Interact using the ref IDs from the snapshot
@@ -52,7 +52,7 @@ browserctl click main --ref e3
52
52
 
53
53
  # 6. Observe
54
54
  browserctl url main
55
- browserctl snapshot main --diff # only what changed
55
+ browserctl page snapshot main --diff # only what changed
56
56
 
57
57
  # Session persistence: save now, pick up later
58
58
  browserctl session save my-session
data/bin/browserctl CHANGED
@@ -14,17 +14,17 @@ require "json"
14
14
  require "optimist"
15
15
  require "browserctl"
16
16
  require "browserctl/commands/cli_output"
17
+ require "browserctl/commands/output_format"
17
18
  require "browserctl/commands/fill"
18
19
  require "browserctl/commands/click"
19
20
  require "browserctl/commands/snapshot"
20
21
  require "browserctl/commands/screenshot"
21
- require "browserctl/commands/record"
22
+ require "browserctl/commands/recording"
22
23
  require "browserctl/commands/resume"
23
24
  require "browserctl/commands/init"
24
25
  require "browserctl/commands/page"
25
26
  require "browserctl/commands/cookie"
26
27
  require "browserctl/commands/storage"
27
- require "browserctl/commands/session"
28
28
  require "browserctl/commands/state"
29
29
  require "browserctl/commands/daemon"
30
30
  require "browserctl/commands/workflow"
@@ -42,9 +42,11 @@ def structured_stderr_payload(res, message)
42
42
  [code, JSON.generate(code: code, message: message, context: context, suggested_action: action)]
43
43
  end
44
44
 
45
- def print_result(res)
45
+ def print_result(res, text_block = nil)
46
+ fmt = Browserctl::Commands::OutputFormat.current
47
+
46
48
  unless res.is_a?(Hash) && (res[:error] || res["error"])
47
- puts res.to_json
49
+ fmt.emit(res, text_block)
48
50
  return
49
51
  end
50
52
 
@@ -52,7 +54,7 @@ def print_result(res)
52
54
  code, payload = structured_stderr_payload(res, message)
53
55
  warn "Error: #{message}"
54
56
  warn payload
55
- puts res.to_json
57
+ puts res.to_json unless fmt.silent?
56
58
  exit Browserctl::Error::ExitCodes.for(code)
57
59
  end
58
60
 
@@ -64,10 +66,12 @@ def usage
64
66
  init
65
67
 
66
68
  Page:
67
- page open <name> [--url URL]
68
- page close <name>
69
+ page open <name> [--url URL]
70
+ page close <name>
69
71
  page list
70
- page focus <name>
72
+ page focus <name>
73
+ page snapshot <name> [--format elements|html] [--diff]
74
+ page screenshot <name> [--out PATH] [--full]
71
75
 
72
76
  Interaction (page is always first arg after verb):
73
77
  navigate <page> <url>
@@ -75,8 +79,6 @@ def usage
75
79
  <page> --ref <ref> --value <value>
76
80
  click <page> <selector>
77
81
  <page> --ref <ref>
78
- snapshot <page> [--format elements|html] [--diff]
79
- screenshot <page> [--out PATH] [--full]
80
82
  evaluate <page> <expression>
81
83
  url <page>
82
84
  wait <page> <selector> [--timeout N]
@@ -105,14 +107,6 @@ def usage
105
107
  storage import <page> <path>
106
108
  storage delete <page> [--store local|session|all]
107
109
 
108
- Session:
109
- session save <name>
110
- session load <name>
111
- session list
112
- session delete <name>
113
- session export <name> <path>
114
- session import <path>
115
-
116
110
  Auth detection:
117
111
  auth-check <page> [--cookies] [--state NAME] [--flow NAME]
118
112
  # Exits 7 with code: AUTH_REQUIRED when the page needs login.
@@ -128,9 +122,9 @@ def usage
128
122
  state import <source> [--name NAME]
129
123
 
130
124
  Recording:
131
- record start <name>
132
- record stop [--out PATH]
133
- record status
125
+ recording start <name>
126
+ recording stop [--out PATH]
127
+ recording status
134
128
 
135
129
  Workflow:
136
130
  workflow run <name|file> [--check] [--params file] [--key value ...]
@@ -162,11 +156,23 @@ def usage
162
156
 
163
157
  Global options:
164
158
  --daemon <name> Connect to named or auto-indexed daemon (d1, d2, work, ...)
159
+ --output <fmt> Output format: json | text | silent (default: text;
160
+ override with BROWSERCTL_OUTPUT)
165
161
  --version, -v
166
162
  USAGE
167
163
  exit 0
168
164
  end
169
165
 
166
+ # Resolve --output {json,text,silent} (or BROWSERCTL_OUTPUT) once, globally,
167
+ # before per-command parsers run. Strips the flag from ARGV in place so
168
+ # downstream Optimist/positional parsers don't see it.
169
+ begin
170
+ Browserctl::Commands::OutputFormat.install!(ARGV)
171
+ rescue Browserctl::Commands::OutputFormat::InvalidFormat => e
172
+ warn "Error: #{e.message}"
173
+ exit 2
174
+ end
175
+
170
176
  daemon_idx = ARGV.index("--daemon")
171
177
  daemon_name = if daemon_idx
172
178
  ARGV.delete_at(daemon_idx)
@@ -193,9 +199,9 @@ runner = Browserctl::Runner.new
193
199
 
194
200
  begin
195
201
  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)
202
+ when "workflow" then Browserctl::Commands::Workflow.run(runner, args)
203
+ when "recording" then Browserctl::Commands::Recording.run(args)
204
+ when "init" then Browserctl::Commands::Init.run(args)
199
205
  when "ask" then Browserctl::Commands::Ask.run(args)
200
206
  when "trace" then Browserctl::Commands::Trace.run(args)
201
207
  when "migrate" then Browserctl::Commands::Migrate.run(args)
@@ -208,7 +214,6 @@ begin
208
214
  when "page" then Browserctl::Commands::Page.run(client, args)
209
215
  when "cookie" then Browserctl::Commands::Cookie.run(client, args)
210
216
  when "storage" then Browserctl::Commands::Storage.run(client, args)
211
- when "session" then Browserctl::Commands::Session.run(client, args)
212
217
  when "state" then Browserctl::Commands::State.run(client, args)
213
218
  when "daemon" then Browserctl::Commands::Daemon.run(client, args)
214
219
  when "auth-check"
@@ -229,8 +234,6 @@ begin
229
234
  when "navigate" then print_result(client.navigate(args[0], args[1]))
230
235
  when "fill" then Browserctl::Commands::Fill.run(client, args)
231
236
  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
237
  when "evaluate" then print_result(client.evaluate(args[0], args[1]))
235
238
  when "url" then print_result(client.url(args[0]))
236
239
  when "wait"
@@ -251,9 +254,12 @@ begin
251
254
  warn "Error: #{res[:error]}"
252
255
  exit 1
253
256
  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
+ print_result(res.merge(paused: name, message: opts[:message])) do
258
+ lines = ["Page '#{name}' paused. Browser is live — interact freely."]
259
+ lines << "(#{opts[:message]})" if opts[:message]
260
+ lines << "When done: browserctl resume #{name}"
261
+ lines.join("\n")
262
+ end
257
263
  when "resume" then Browserctl::Commands::Resume.run(client, args)
258
264
  when "press"
259
265
  name = args.shift or abort "usage: browserctl press <page> <key>"
@@ -282,8 +288,9 @@ begin
282
288
  exit 1
283
289
  end
284
290
  url = res[:devtools_url]
285
- puts "Opening DevTools for '#{name}':"
286
- puts " #{url}"
291
+ print_result(res) do
292
+ "Opening DevTools for '#{name}':\n #{url}"
293
+ end
287
294
  opener = RUBY_PLATFORM =~ /darwin/ ? "open" : "xdg-open"
288
295
  system(opener, url)
289
296
  else
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require_relative "errors"
5
+ require_relative "secret_resolvers"
6
+
7
+ module Browserctl
8
+ # Shared base for {Flow} and {WorkflowDefinition}. Holds the duplicated
9
+ # DSL surface (`desc`, `param`, `step`) and the shared execution helpers
10
+ # for resolving params and running step blocks with retry + timeout.
11
+ #
12
+ # Subclasses provide:
13
+ # - their own `call`/`run` entry point and context object;
14
+ # - {#callable_kind} so cross-type composition can be rejected at
15
+ # definition time (e.g. a flow trying to `compose` a workflow);
16
+ # - {#step_failure_message} for the typed error wording.
17
+ #
18
+ # The persistence DSL (`store`/`fetch`/`save_state`/`load_state`) is
19
+ # mixed into `WorkflowContext` via {ContextualPersistence} and is
20
+ # deliberately absent from `FlowContext` — flows return state, workflows
21
+ # share state.
22
+ class CallableDefinition
23
+ ParamDef = Struct.new(:name, :required, :secret, :default, :secret_ref, keyword_init: true)
24
+ StepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
25
+
26
+ attr_reader :name, :description, :param_defs, :steps
27
+
28
+ def initialize(name)
29
+ @name = name.to_s
30
+ @description = nil
31
+ @param_defs = {}
32
+ @steps = []
33
+ end
34
+
35
+ def desc(text)
36
+ @description = text.to_s
37
+ end
38
+
39
+ def param(name, required: false, secret: false, default: nil, secret_ref: nil)
40
+ secret = true if secret_ref
41
+ @param_defs[name] = ParamDef.new(
42
+ name: name,
43
+ required: required,
44
+ secret: secret,
45
+ default: default,
46
+ secret_ref: secret_ref
47
+ )
48
+ end
49
+
50
+ def step(label, retry_count: 0, timeout: nil, &block)
51
+ raise ArgumentError, "#{callable_kind} step '#{label}' requires a block" unless block
52
+
53
+ @steps << StepDef.new(label: label, block: block, retry_count: retry_count, timeout: timeout)
54
+ end
55
+
56
+ # Subclasses override these to specialise the base behaviour.
57
+
58
+ # @return [Symbol] :flow or :workflow — used for cross-type composition checks.
59
+ def callable_kind
60
+ raise NotImplementedError
61
+ end
62
+
63
+ private
64
+
65
+ def resolve_params(provided)
66
+ @param_defs.each_with_object({}) do |(name, defn), out|
67
+ val = if defn.secret_ref
68
+ SecretResolverRegistry.resolve(defn.secret_ref)
69
+ elsif provided.key?(name)
70
+ provided[name]
71
+ else
72
+ defn.default
73
+ end
74
+
75
+ raise missing_param_error(name) if defn.required && val.nil?
76
+
77
+ out[name] = val
78
+ end
79
+ end
80
+
81
+ # Override for typed error wording.
82
+ def missing_param_error(_name)
83
+ raise NotImplementedError
84
+ end
85
+
86
+ # Runs a step block with retry + timeout. Yields the recoverable error
87
+ # to the caller (via the returned exception in `:error`) so subclasses
88
+ # can decide how to surface a failure (raise vs StepResult).
89
+ def execute_step_block(ctx, defn)
90
+ if defn.timeout
91
+ ::Timeout.timeout(defn.timeout) { ctx.instance_exec(&defn.block) }
92
+ else
93
+ ctx.instance_exec(&defn.block)
94
+ end
95
+ rescue ::Timeout::Error
96
+ raise step_timeout_error(defn)
97
+ end
98
+
99
+ def with_retries(defn)
100
+ last_error = nil
101
+ (defn.retry_count + 1).times do
102
+ return yield
103
+ rescue StandardError => e
104
+ last_error = e
105
+ end
106
+ [:error, last_error]
107
+ end
108
+
109
+ # Subclasses override to raise their typed timeout error.
110
+ def step_timeout_error(_defn)
111
+ raise NotImplementedError
112
+ end
113
+ end
114
+ end
@@ -283,33 +283,6 @@ module Browserctl
283
283
  # @return [Hash] `{ ok: true }` or `{ error: }`
284
284
  def dialog_dismiss(name) = call("dialog_dismiss", name: name)
285
285
 
286
- # Saves the current browser state (cookies, localStorage, open pages) to a named session.
287
- # @param session_name [String] name for the saved session
288
- # @return [Hash] `{ ok: true, path:, pages: N, cookies: N }` or `{ error: }`
289
- def session_save(session_name, encrypt: false)
290
- call("session_save", session_name: session_name, encrypt: encrypt)
291
- end
292
-
293
- # Restores a previously saved session into the running daemon.
294
- # @param session_name [String] name of the session to load
295
- # @return [Hash] `{ ok: true, cookies: N, pages: N, local_storage_keys: N }` or `{ error: }`
296
- def session_load(session_name)
297
- call("session_load", session_name: session_name)
298
- end
299
-
300
- # Lists all saved sessions.
301
- # @return [Hash] `{ ok: true, sessions: [Hash] }` or `{ error: }`
302
- def session_list
303
- call("session_list")
304
- end
305
-
306
- # Permanently deletes a named session.
307
- # @param session_name [String] name of the session to delete
308
- # @return [Hash] `{ ok: true }` or `{ error: }`
309
- def session_delete(session_name)
310
- call("session_delete", session_name: session_name)
311
- end
312
-
313
286
  # Saves browser state (cookies + storage) into a single .bctl bundle.
314
287
  # @return [Hash] `{ ok:, path:, origins:, cookies:, encrypted: }` or `{ error: }`
315
288
  def state_save(name, origins: nil, flow: nil, flow_version: nil, passphrase: nil)
@@ -3,21 +3,35 @@
3
3
  require "json"
4
4
  require_relative "../errors"
5
5
  require_relative "../error/suggested_actions"
6
+ require_relative "output_format"
6
7
 
7
8
  module Browserctl
8
9
  module Commands
9
10
  module CliOutput
10
11
  AUTH_REQUIRED_EXIT_CODE = Browserctl::AuthRequiredError::AUTH_REQUIRED_EXIT_CODE
11
12
 
12
- def print_result(res)
13
+ # Print the JSON-RPC daemon response, routed through the active
14
+ # `OutputFormat`. The historical default behaviour was `puts res.to_json`
15
+ # — keeping that as the `text` branch preserves byte-identical output
16
+ # for existing callers and golden files. `json` mode emits the same
17
+ # JSON explicitly. `silent` suppresses stdout entirely; errors still
18
+ # write the structured payload to stderr because errors are the
19
+ # result, not cosmetic output.
20
+ #
21
+ # `text_block` (optional) overrides the JSON dump in `text` mode for
22
+ # commands that have a distinct human-readable form (e.g. `init`).
23
+ def print_result(res, text_block = nil)
24
+ fmt = OutputFormat.current
25
+
13
26
  if res.is_a?(Hash) && (res[:error] || res["error"])
14
27
  message = res[:error] || res["error"]
15
28
  warn "Error: #{message}"
16
29
  warn structured_error_line(res, message)
17
- puts res.to_json
30
+ puts res.to_json unless fmt.silent?
18
31
  exit exit_code_for(res)
19
32
  end
20
- puts res.to_json
33
+
34
+ fmt.emit(res, text_block)
21
35
  end
22
36
 
23
37
  # Maps a daemon error response onto a process exit code. Defaults to 1;
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "optimist"
5
5
  require_relative "cli_output"
6
+ require_relative "output_format"
6
7
 
7
8
  module Browserctl
8
9
  module Commands
@@ -18,7 +19,8 @@ module Browserctl
18
19
  begin
19
20
  print_result(client.ping)
20
21
  rescue Browserctl::DaemonUnavailableError => e
21
- puts JSON.generate({ ok: false, daemon: "offline", error: e.message })
22
+ payload = { ok: false, daemon: "offline", error: e.message }
23
+ OutputFormat.current.emit(payload)
22
24
  exit 1
23
25
  end
24
26
  when "status" then run_status(client)
@@ -36,14 +38,16 @@ module Browserctl
36
38
  url_res = client.url(name)
37
39
  { name: name, url: url_res[:url] || url_res[:error] }
38
40
  end
39
- puts JSON.pretty_generate(
41
+ payload = {
40
42
  daemon: "online",
41
43
  pid: ping[:pid],
42
44
  protocol_version: ping[:protocol_version],
43
45
  pages: page_info
44
- )
46
+ }
47
+ OutputFormat.current.emit(payload, JSON.pretty_generate(payload))
45
48
  rescue Browserctl::DaemonUnavailableError => e
46
- puts JSON.pretty_generate(daemon: "offline", error: e.message)
49
+ payload = { daemon: "offline", error: e.message }
50
+ OutputFormat.current.emit(payload, JSON.pretty_generate(payload))
47
51
  exit 1
48
52
  end
49
53
 
@@ -57,7 +61,7 @@ module Browserctl
57
61
  flags += ["--name", opts[:name]] if opts[:name]
58
62
  pid = Process.spawn("browserd", *flags, out: File::NULL, err: File::NULL)
59
63
  Process.detach(pid)
60
- puts "browserd started (pid #{pid})"
64
+ OutputFormat.current.emit({ ok: true, pid: pid }) { "browserd started (pid #{pid})" }
61
65
  end
62
66
 
63
67
  def self.run_list
@@ -74,7 +78,7 @@ module Browserctl
74
78
  rescue Browserctl::DaemonUnavailableError, RuntimeError
75
79
  nil
76
80
  end.compact
77
- puts({ daemons: rows }.to_json)
81
+ OutputFormat.current.emit({ daemons: rows })
78
82
  end
79
83
  end
80
84
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "cli_output"
5
+ require_relative "output_format"
5
6
  require_relative "../flow_registry"
6
7
  require_relative "../runner"
7
8
 
@@ -31,22 +32,22 @@ module Browserctl
31
32
  page_proxy = page_name ? Browserctl::PageProxy.new(page_name, client) : nil
32
33
 
33
34
  result = flow.run(page: page_proxy, client: client, **params)
34
- puts JSON.generate(ok: true, flow: flow.name, result: serialisable(result))
35
+ OutputFormat.current.emit({ ok: true, flow: flow.name, result: serialisable(result) })
35
36
  rescue Browserctl::FlowError => e
36
37
  warn "Error: #{e.message}"
37
- puts JSON.generate(ok: false, code: e.code, error: e.message)
38
+ OutputFormat.current.emit({ ok: false, code: e.code, error: e.message }) unless OutputFormat.current.silent?
38
39
  exit 1
39
40
  end
40
41
 
41
42
  def self.run_list
42
43
  entries = Browserctl::FlowRegistry.list
43
- puts JSON.generate(flows: entries)
44
+ OutputFormat.current.emit({ flows: entries })
44
45
  end
45
46
 
46
47
  def self.run_describe(args)
47
48
  name = args.shift or abort "usage: browserctl flow describe <name>"
48
49
  flow = resolve(name)
49
- puts JSON.pretty_generate(
50
+ payload = {
50
51
  name: flow.name,
51
52
  desc: flow.description,
52
53
  version: flow.version_string,
@@ -56,7 +57,8 @@ module Browserctl
56
57
  steps: flow.steps.map(&:label),
57
58
  postconditions: flow.postconditions.map(&:label),
58
59
  produces_state: !flow.produces_state_block.nil?
59
- )
60
+ }
61
+ OutputFormat.current.emit(payload, JSON.pretty_generate(payload))
60
62
  end
61
63
 
62
64
  def self.resolve(name_or_path)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "output_format"
4
5
 
5
6
  module Browserctl
6
7
  module Commands
@@ -14,15 +15,15 @@ module Browserctl
14
15
  YAML
15
16
 
16
17
  GITIGNORE_CONTENT = <<~GITIGNORE
17
- # Cookie session exports — contain credentials, never commit
18
- sessions/
18
+ # State bundles — contain credentials, never commit
19
+ state/
19
20
  GITIGNORE
20
21
 
21
22
  def self.run(_args)
22
23
  FileUtils.mkdir_p(".browserctl/workflows")
23
24
  FileUtils.touch(".browserctl/workflows/.keep")
24
25
 
25
- FileUtils.mkdir_p(".browserctl/sessions")
26
+ FileUtils.mkdir_p(".browserctl/state")
26
27
 
27
28
  gitignore_path = ".browserctl/.gitignore"
28
29
  File.write(gitignore_path, GITIGNORE_CONTENT) unless File.exist?(gitignore_path)
@@ -30,10 +31,22 @@ module Browserctl
30
31
  config_path = ".browserctl/config.yml"
31
32
  File.write(config_path, CONFIG_TEMPLATE) unless File.exist?(config_path)
32
33
 
33
- puts "Initialised browserctl project:"
34
- puts " .browserctl/workflows/ (place workflow .rb files here)"
35
- puts " .browserctl/sessions/ (cookie exports — git-ignored)"
36
- puts " .browserctl/config.yml (project settings)"
34
+ payload = {
35
+ ok: true,
36
+ paths: {
37
+ workflows: ".browserctl/workflows",
38
+ state: ".browserctl/state",
39
+ config: ".browserctl/config.yml"
40
+ }
41
+ }
42
+ OutputFormat.current.emit(payload) do
43
+ <<~TEXT.chomp
44
+ Initialised browserctl project:
45
+ .browserctl/workflows/ (place workflow .rb files here)
46
+ .browserctl/state/ (state bundles — git-ignored)
47
+ .browserctl/config.yml (project settings)
48
+ TEXT
49
+ end
37
50
  end
38
51
  end
39
52
  end