browserctl 0.11.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +4 -3
- data/bin/browserctl +171 -115
- data/bin/browserd +8 -1
- data/lib/browserctl/callable_definition.rb +114 -0
- data/lib/browserctl/client.rb +3 -30
- data/lib/browserctl/commands/cli_output.rb +38 -4
- data/lib/browserctl/commands/daemon.rb +10 -6
- data/lib/browserctl/commands/flow.rb +7 -5
- data/lib/browserctl/commands/init.rb +20 -7
- data/lib/browserctl/commands/migrate.rb +142 -0
- data/lib/browserctl/commands/output_format.rb +144 -0
- data/lib/browserctl/commands/page.rb +9 -5
- data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
- data/lib/browserctl/commands/resume.rb +1 -1
- data/lib/browserctl/commands/screenshot.rb +2 -2
- data/lib/browserctl/commands/snapshot.rb +8 -3
- data/lib/browserctl/commands/state.rb +3 -2
- data/lib/browserctl/commands/trace.rb +216 -0
- data/lib/browserctl/commands/workflow.rb +9 -7
- data/lib/browserctl/constants.rb +3 -1
- data/lib/browserctl/contextual_persistence.rb +58 -0
- data/lib/browserctl/crash_report.rb +96 -0
- data/lib/browserctl/driver/cdp.rb +2 -3
- data/lib/browserctl/encryption_service.rb +84 -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 +44 -14
- data/lib/browserctl/flow.rb +35 -59
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
- 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/log_writer.rb +82 -0
- data/lib/browserctl/recording/redactor.rb +58 -0
- data/lib/browserctl/recording/state.rb +44 -0
- data/lib/browserctl/recording/workflow_renderer.rb +214 -0
- data/lib/browserctl/recording.rb +39 -268
- data/lib/browserctl/redactor.rb +58 -0
- data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
- data/lib/browserctl/runner.rb +12 -6
- data/lib/browserctl/secret_resolver_registry.rb +23 -4
- data/lib/browserctl/server/command_dispatcher.rb +28 -16
- 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 +19 -3
- data/lib/browserctl/server/handlers/state.rb +7 -5
- data/lib/browserctl/server.rb +2 -1
- data/lib/browserctl/state/bundle.rb +63 -49
- data/lib/browserctl/state.rb +46 -9
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +1 -1
- data/lib/browserctl/workflow/recovery_manager.rb +87 -0
- data/lib/browserctl/workflow.rb +117 -238
- metadata +25 -14
- data/examples/session_reuse.rb +0 -75
- data/lib/browserctl/commands/session.rb +0 -243
- data/lib/browserctl/driver/base.rb +0 -13
- data/lib/browserctl/driver.rb +0 -5
- data/lib/browserctl/server/handlers/session.rb +0 -94
- data/lib/browserctl/session.rb +0 -206
data/lib/browserctl/client.rb
CHANGED
|
@@ -182,7 +182,7 @@ module Browserctl
|
|
|
182
182
|
# @param path [String] file path to read cookies from
|
|
183
183
|
# @return [Hash] `{ ok: true, count: }` or `{ error: }`
|
|
184
184
|
def import_cookies(name, path)
|
|
185
|
-
raise "cookie file not found: #{path}" unless File.exist?(path)
|
|
185
|
+
raise Browserctl::Error, "cookie file not found: #{path}" unless File.exist?(path)
|
|
186
186
|
|
|
187
187
|
cookies = JSON.parse(File.read(path), symbolize_names: true)
|
|
188
188
|
call("import_cookies", name: name, cookies: cookies)
|
|
@@ -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)
|
|
@@ -371,10 +344,10 @@ module Browserctl
|
|
|
371
344
|
end
|
|
372
345
|
|
|
373
346
|
def read_response(sock)
|
|
374
|
-
raise "browserd response timeout after 60s" unless sock.wait_readable(60)
|
|
347
|
+
raise DaemonUnavailableError, "browserd response timeout after 60s" unless sock.wait_readable(60)
|
|
375
348
|
|
|
376
349
|
raw = sock.gets
|
|
377
|
-
raise "browserd closed connection" unless raw
|
|
350
|
+
raise DaemonUnavailableError, "browserd closed connection" unless raw
|
|
378
351
|
|
|
379
352
|
JSON.parse(raw.chomp, symbolize_names: true)
|
|
380
353
|
end
|
|
@@ -1,19 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require_relative "../errors"
|
|
5
|
+
require_relative "../error/suggested_actions"
|
|
6
|
+
require_relative "output_format"
|
|
4
7
|
|
|
5
8
|
module Browserctl
|
|
6
9
|
module Commands
|
|
7
10
|
module CliOutput
|
|
8
11
|
AUTH_REQUIRED_EXIT_CODE = Browserctl::AuthRequiredError::AUTH_REQUIRED_EXIT_CODE
|
|
9
12
|
|
|
10
|
-
|
|
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
|
+
|
|
11
26
|
if res.is_a?(Hash) && (res[:error] || res["error"])
|
|
12
|
-
|
|
13
|
-
|
|
27
|
+
message = res[:error] || res["error"]
|
|
28
|
+
warn "Error: #{message}"
|
|
29
|
+
warn structured_error_line(res, message)
|
|
30
|
+
puts res.to_json unless fmt.silent?
|
|
14
31
|
exit exit_code_for(res)
|
|
15
32
|
end
|
|
16
|
-
|
|
33
|
+
|
|
34
|
+
fmt.emit(res, text_block)
|
|
17
35
|
end
|
|
18
36
|
|
|
19
37
|
# Maps a daemon error response onto a process exit code. Defaults to 1;
|
|
@@ -23,6 +41,22 @@ module Browserctl
|
|
|
23
41
|
|
|
24
42
|
1
|
|
25
43
|
end
|
|
44
|
+
|
|
45
|
+
# Builds the single-line structured payload emitted to stderr after
|
|
46
|
+
# the human-readable line. Agents parse this JSON deterministically.
|
|
47
|
+
# Shape: { code, message, context, suggested_action }.
|
|
48
|
+
def structured_error_line(res, message)
|
|
49
|
+
code = (res[:code] || res["code"] || Browserctl::Error::Codes::GENERIC).to_s
|
|
50
|
+
context = res[:context] || res["context"] || {}
|
|
51
|
+
action = res[:suggested_action] || res["suggested_action"] ||
|
|
52
|
+
Browserctl::Error::SuggestedActions.for(code)
|
|
53
|
+
JSON.generate(
|
|
54
|
+
code: code,
|
|
55
|
+
message: message,
|
|
56
|
+
context: context,
|
|
57
|
+
suggested_action: action
|
|
58
|
+
)
|
|
59
|
+
end
|
|
26
60
|
end
|
|
27
61
|
end
|
|
28
62
|
end
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
18
|
-
|
|
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/
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../migrations"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "../error/codes"
|
|
6
|
+
require_relative "../error/exit_codes"
|
|
7
|
+
require_relative "output_format"
|
|
8
|
+
|
|
9
|
+
module Browserctl
|
|
10
|
+
module Commands
|
|
11
|
+
# `browserctl migrate <path> [--to-version N] [--dry-run]` — operator
|
|
12
|
+
# entry point for the {Browserctl::Migrations} registry. Detects the
|
|
13
|
+
# artifact's format and version, plans a chain of registered upgraders,
|
|
14
|
+
# and applies them in order (unless `--dry-run`).
|
|
15
|
+
#
|
|
16
|
+
# The registry ships empty in v0.12; this command exists so operators
|
|
17
|
+
# have a stable invocation the moment a real migration lands. On an
|
|
18
|
+
# already-current artifact the command is a no-op and exits 0.
|
|
19
|
+
module Migrate
|
|
20
|
+
USAGE = "Usage: browserctl migrate <path> [--to-version N] [--dry-run]"
|
|
21
|
+
|
|
22
|
+
def self.run(args, out: $stdout, err: $stderr)
|
|
23
|
+
abort USAGE if args.empty? || args.include?("-h") || args.include?("--help")
|
|
24
|
+
args = args.dup
|
|
25
|
+
|
|
26
|
+
dry_run = !args.delete("--dry-run").nil?
|
|
27
|
+
target_idx = args.index("--to-version")
|
|
28
|
+
target = if target_idx
|
|
29
|
+
args.delete_at(target_idx)
|
|
30
|
+
Integer(args.delete_at(target_idx))
|
|
31
|
+
end
|
|
32
|
+
path = args.shift
|
|
33
|
+
abort USAGE unless path
|
|
34
|
+
|
|
35
|
+
unless File.exist?(path)
|
|
36
|
+
err.puts "Error: file not found: #{path}"
|
|
37
|
+
exit Browserctl::Error::ExitCodes::GENERIC
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
execute(path, target_version: target, dry_run: dry_run, out: out, err: err)
|
|
41
|
+
rescue Browserctl::ProtocolMismatch => e
|
|
42
|
+
err.puts "Error: #{e.message}"
|
|
43
|
+
exit Browserctl::Error::ExitCodes.for(e.code)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.execute(path, target_version:, dry_run:, out:, err:)
|
|
47
|
+
format = detect_format!(path, err: err)
|
|
48
|
+
current = Browserctl::Migrations.detect_version(path, format)
|
|
49
|
+
emit_detected(format, current, path, out)
|
|
50
|
+
|
|
51
|
+
return plan_dry_run(format, current, target_version, out) if dry_run
|
|
52
|
+
|
|
53
|
+
result = Browserctl::Migrations.run(path, target_version: target_version)
|
|
54
|
+
emit_applied(format, current, result, out)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.detect_format!(path, err:)
|
|
58
|
+
format = Browserctl::Migrations.detect_format(path)
|
|
59
|
+
return format if format
|
|
60
|
+
|
|
61
|
+
err.puts "Error: could not detect format for #{path} (expected .bctl, .jsonl, or .rb)"
|
|
62
|
+
exit Browserctl::Error::ExitCodes::PROTOCOL_MISMATCH
|
|
63
|
+
end
|
|
64
|
+
private_class_method :detect_format!
|
|
65
|
+
|
|
66
|
+
def self.emit_detected(format, current, path, out)
|
|
67
|
+
return unless OutputFormat.current.text?
|
|
68
|
+
|
|
69
|
+
out.puts "Detected: format=#{format} version=#{current.inspect} path=#{path}"
|
|
70
|
+
end
|
|
71
|
+
private_class_method :emit_detected
|
|
72
|
+
|
|
73
|
+
def self.emit_applied(format, current, result, out)
|
|
74
|
+
fmt = OutputFormat.current
|
|
75
|
+
if fmt.json?
|
|
76
|
+
fmt.emit({ ok: true, format: format, from: result.from, to: result.to,
|
|
77
|
+
applied: result.applied.map { |m| { from: m.from_version, to: m.to_version } } },
|
|
78
|
+
io: out)
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
return if fmt.silent?
|
|
82
|
+
|
|
83
|
+
if result.applied.empty?
|
|
84
|
+
out.puts "No migrations registered for #{format} v#{current}; nothing to do."
|
|
85
|
+
else
|
|
86
|
+
out.puts "Applied #{result.applied.size} migration(s): #{result.from} -> #{result.to}"
|
|
87
|
+
result.applied.each { |m| out.puts " - #{format} v#{m.from_version} -> v#{m.to_version}" }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
private_class_method :emit_applied
|
|
91
|
+
|
|
92
|
+
def self.plan_dry_run(format, current, target_version, out)
|
|
93
|
+
target = target_version || latest_target(format, current)
|
|
94
|
+
chain = Browserctl::Migrations.find_path(format: format, from: current, to: target)
|
|
95
|
+
emit_plan(format, current, target, chain, out)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.emit_plan(format, current, target, chain, out)
|
|
99
|
+
fmt = OutputFormat.current
|
|
100
|
+
if fmt.json?
|
|
101
|
+
fmt.emit(plan_payload(format, current, target, chain), io: out)
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
return if fmt.silent?
|
|
105
|
+
|
|
106
|
+
emit_plan_text(format, current, target, chain, out)
|
|
107
|
+
end
|
|
108
|
+
private_class_method :emit_plan
|
|
109
|
+
|
|
110
|
+
def self.plan_payload(format, current, target, chain)
|
|
111
|
+
{
|
|
112
|
+
format: format, from: current, to: target, dry_run: true,
|
|
113
|
+
plan: chain&.map { |m| { from: m.from_version, to: m.to_version } },
|
|
114
|
+
registered: registered_for(format)
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
private_class_method :plan_payload
|
|
118
|
+
|
|
119
|
+
def self.emit_plan_text(format, current, target, chain, out)
|
|
120
|
+
if chain.nil?
|
|
121
|
+
out.puts "No migration path #{format} v#{current} -> v#{target} (registered: " \
|
|
122
|
+
"#{registered_for(format).inspect})"
|
|
123
|
+
elsif chain.empty?
|
|
124
|
+
out.puts "Already at v#{target}; no migrations would run."
|
|
125
|
+
else
|
|
126
|
+
out.puts "Plan (#{chain.size} step(s)):"
|
|
127
|
+
chain.each { |m| out.puts " - #{format} v#{m.from_version} -> v#{m.to_version}" }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
private_class_method :emit_plan_text
|
|
131
|
+
|
|
132
|
+
def self.latest_target(format, current)
|
|
133
|
+
targets = registered_for(format)
|
|
134
|
+
targets.empty? ? current : targets.max
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.registered_for(format)
|
|
138
|
+
Browserctl::Migrations.all.select { |m| m.format == format }.map(&:to_version)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Commands
|
|
7
|
+
# Resolves and applies the unified `--output {json,text,silent}` flag.
|
|
8
|
+
#
|
|
9
|
+
# Resolution order: explicit flag value -> `BROWSERCTL_OUTPUT` env var ->
|
|
10
|
+
# `text` (default).
|
|
11
|
+
#
|
|
12
|
+
# Usage from a command:
|
|
13
|
+
#
|
|
14
|
+
# fmt = OutputFormat.from(flag, ENV)
|
|
15
|
+
# fmt.emit(payload_hash) { "human readable text" }
|
|
16
|
+
#
|
|
17
|
+
# Per the v0.13 contract:
|
|
18
|
+
#
|
|
19
|
+
# text - prints the human-readable block; this is byte-identical to
|
|
20
|
+
# today's output. For most commands the human block already IS
|
|
21
|
+
# the JSON payload (legacy CLI shape) so the two collapse.
|
|
22
|
+
# json - prints the JSON payload via `to_json` (no pretty printing).
|
|
23
|
+
# silent - prints nothing on stdout. Exit codes still carry the result.
|
|
24
|
+
#
|
|
25
|
+
# The current format is also exposed as a process-wide default
|
|
26
|
+
# (`OutputFormat.current`) so that `CliOutput#print_result` and other
|
|
27
|
+
# legacy helpers can consult it without every callsite threading the
|
|
28
|
+
# value through.
|
|
29
|
+
module OutputFormat
|
|
30
|
+
VALID = %w[json text silent].freeze
|
|
31
|
+
DEFAULT = "text"
|
|
32
|
+
ENV_VAR = "BROWSERCTL_OUTPUT"
|
|
33
|
+
FLAG = "--output"
|
|
34
|
+
|
|
35
|
+
class InvalidFormat < ArgumentError; end
|
|
36
|
+
|
|
37
|
+
class Formatter
|
|
38
|
+
attr_reader :mode
|
|
39
|
+
|
|
40
|
+
def initialize(mode)
|
|
41
|
+
@mode = mode
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Print success output for a command.
|
|
45
|
+
# `payload` is a JSON-serialisable Hash (or Array). `text_block` is
|
|
46
|
+
# either a String or a block that returns a String — only evaluated
|
|
47
|
+
# when needed.
|
|
48
|
+
def emit(payload, text_block = nil, io: $stdout)
|
|
49
|
+
case @mode
|
|
50
|
+
when "silent"
|
|
51
|
+
nil
|
|
52
|
+
when "json"
|
|
53
|
+
io.puts payload.to_json
|
|
54
|
+
else # "text"
|
|
55
|
+
text = if block_given?
|
|
56
|
+
yield
|
|
57
|
+
elsif text_block.respond_to?(:call)
|
|
58
|
+
text_block.call
|
|
59
|
+
elsif text_block.nil?
|
|
60
|
+
payload.to_json
|
|
61
|
+
else
|
|
62
|
+
text_block
|
|
63
|
+
end
|
|
64
|
+
io.puts text
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def silent?
|
|
69
|
+
@mode == "silent"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def json?
|
|
73
|
+
@mode == "json"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def text?
|
|
77
|
+
@mode == "text"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
module_function
|
|
82
|
+
|
|
83
|
+
# Build a Formatter from an explicit flag value (or nil) and an env hash.
|
|
84
|
+
def from(flag, env = ENV)
|
|
85
|
+
raw = flag || env[ENV_VAR] || DEFAULT
|
|
86
|
+
mode = raw.to_s.strip.downcase
|
|
87
|
+
mode = DEFAULT if mode.empty?
|
|
88
|
+
unless VALID.include?(mode)
|
|
89
|
+
raise InvalidFormat,
|
|
90
|
+
"invalid --output value '#{raw}' (expected one of: #{VALID.join(', ')})"
|
|
91
|
+
end
|
|
92
|
+
Formatter.new(mode)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Strip `--output VALUE` (or `--output=VALUE`) from `args` in place and
|
|
96
|
+
# return the extracted value (or nil). Recognises the long form only —
|
|
97
|
+
# there is intentionally no short alias.
|
|
98
|
+
def extract!(args)
|
|
99
|
+
i = 0
|
|
100
|
+
while i < args.length
|
|
101
|
+
arg = args[i]
|
|
102
|
+
if arg == FLAG
|
|
103
|
+
args.delete_at(i)
|
|
104
|
+
value = args.delete_at(i) or
|
|
105
|
+
raise InvalidFormat, "missing value for #{FLAG}"
|
|
106
|
+
return value
|
|
107
|
+
elsif arg.is_a?(String) && arg.start_with?("#{FLAG}=")
|
|
108
|
+
value = arg.split("=", 2)[1]
|
|
109
|
+
args.delete_at(i)
|
|
110
|
+
return value
|
|
111
|
+
else
|
|
112
|
+
i += 1
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Process-wide current format. Set once by the CLI entry point after
|
|
119
|
+
# parsing the global flag; consulted by helpers that don't otherwise
|
|
120
|
+
# have a reference to a Formatter (notably `CliOutput#print_result`).
|
|
121
|
+
def current
|
|
122
|
+
@current ||= Formatter.new(DEFAULT)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def current=(formatter)
|
|
126
|
+
@current = formatter
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Convenience: parse the flag out of `args`, build a Formatter, set it
|
|
130
|
+
# as current, and return it.
|
|
131
|
+
def install!(args, env = ENV)
|
|
132
|
+
flag = extract!(args)
|
|
133
|
+
fmt = from(flag, env)
|
|
134
|
+
self.current = fmt
|
|
135
|
+
fmt
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Reset to default — for tests.
|
|
139
|
+
def reset!
|
|
140
|
+
@current = Formatter.new(DEFAULT)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -2,21 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
require "optimist"
|
|
4
4
|
require_relative "cli_output"
|
|
5
|
+
require_relative "snapshot"
|
|
6
|
+
require_relative "screenshot"
|
|
5
7
|
|
|
6
8
|
module Browserctl
|
|
7
9
|
module Commands
|
|
8
10
|
module Page
|
|
9
11
|
extend CliOutput
|
|
10
12
|
|
|
11
|
-
USAGE = "Usage: browserctl page <open|close|list|focus> [args]"
|
|
13
|
+
USAGE = "Usage: browserctl page <open|close|list|focus|snapshot|screenshot> [args]"
|
|
12
14
|
|
|
13
15
|
def self.run(client, args)
|
|
14
16
|
sub = args.shift or abort USAGE
|
|
15
17
|
case sub
|
|
16
|
-
when "open"
|
|
17
|
-
when "close"
|
|
18
|
-
when "list"
|
|
19
|
-
when "focus"
|
|
18
|
+
when "open" then run_open(client, args)
|
|
19
|
+
when "close" then run_close(client, args)
|
|
20
|
+
when "list" then run_list(client)
|
|
21
|
+
when "focus" then run_focus(client, args)
|
|
22
|
+
when "snapshot" then Browserctl::Commands::Snapshot.run(client, args)
|
|
23
|
+
when "screenshot" then Browserctl::Commands::Screenshot.run(client, args)
|
|
20
24
|
else abort "unknown page subcommand '#{sub}'\n#{USAGE}"
|
|
21
25
|
end
|
|
22
26
|
end
|
|
@@ -4,11 +4,12 @@ require "fileutils"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "optimist"
|
|
6
6
|
require "browserctl/recording"
|
|
7
|
+
require_relative "output_format"
|
|
7
8
|
|
|
8
9
|
module Browserctl
|
|
9
10
|
module Commands
|
|
10
|
-
class
|
|
11
|
-
USAGE = "Usage: browserctl
|
|
11
|
+
class Recording
|
|
12
|
+
USAGE = "Usage: browserctl recording start <name> | stop [--out PATH] | status"
|
|
12
13
|
|
|
13
14
|
def self.run(args)
|
|
14
15
|
subcmd = args.shift
|
|
@@ -17,7 +18,7 @@ module Browserctl
|
|
|
17
18
|
when "stop" then run_stop(args)
|
|
18
19
|
when "status" then run_status
|
|
19
20
|
else
|
|
20
|
-
abort "#{USAGE}\nRun 'browserctl
|
|
21
|
+
abort "#{USAGE}\nRun 'browserctl recording <subcommand> --help' for details."
|
|
21
22
|
end
|
|
22
23
|
end
|
|
23
24
|
|
|
@@ -25,29 +26,29 @@ module Browserctl
|
|
|
25
26
|
private
|
|
26
27
|
|
|
27
28
|
def run_start(args)
|
|
28
|
-
Optimist.options(args) { banner "Usage: browserctl
|
|
29
|
-
name = args.shift or abort "usage: browserctl
|
|
29
|
+
Optimist.options(args) { banner "Usage: browserctl recording start <name>" }
|
|
30
|
+
name = args.shift or abort "usage: browserctl recording start <name>"
|
|
30
31
|
abort "Invalid recording name #{name.inspect} — use only letters, digits, _ or -" \
|
|
31
32
|
unless name =~ /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
32
|
-
Recording.start(name)
|
|
33
|
-
|
|
33
|
+
Browserctl::Recording.start(name)
|
|
34
|
+
OutputFormat.current.emit({ ok: true, name: name })
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
def run_stop(args)
|
|
37
38
|
opts = Optimist.options(args) do
|
|
38
|
-
banner "Usage: browserctl
|
|
39
|
+
banner "Usage: browserctl recording stop [--out PATH]"
|
|
39
40
|
opt :out, "Output path for workflow file", type: :string, short: "-o"
|
|
40
41
|
end
|
|
41
|
-
name = Recording.stop
|
|
42
|
+
name = Browserctl::Recording.stop
|
|
42
43
|
out = opts[:out] || File.join(".browserctl/workflows", "#{name}.rb")
|
|
43
44
|
FileUtils.mkdir_p(File.dirname(out))
|
|
44
|
-
Recording.generate_workflow(name, output_path: out)
|
|
45
|
-
|
|
45
|
+
Browserctl::Recording.generate_workflow(name, output_path: out)
|
|
46
|
+
OutputFormat.current.emit({ ok: true, name: name, path: out })
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
def run_status
|
|
49
|
-
active = Recording.active
|
|
50
|
-
|
|
50
|
+
active = Browserctl::Recording.active
|
|
51
|
+
OutputFormat.current.emit({ active: active })
|
|
51
52
|
end
|
|
52
53
|
end
|
|
53
54
|
end
|
|
@@ -10,11 +10,11 @@ module Browserctl
|
|
|
10
10
|
|
|
11
11
|
def self.run(client, args)
|
|
12
12
|
opts = Optimist.options(args) do
|
|
13
|
-
banner "Usage: browserctl screenshot <
|
|
13
|
+
banner "Usage: browserctl page screenshot <name> [--out PATH] [--full]"
|
|
14
14
|
opt :out, "Output file path", type: :string, short: "-o"
|
|
15
15
|
opt :full, "Capture full page", default: false, short: "-f"
|
|
16
16
|
end
|
|
17
|
-
name = args.shift or abort "usage: browserctl screenshot <
|
|
17
|
+
name = args.shift or abort "usage: browserctl page screenshot <name> [--out PATH] [--full]"
|
|
18
18
|
print_result(client.screenshot(name, path: opts[:out], full: opts[:full]))
|
|
19
19
|
end
|
|
20
20
|
end
|