Module: Tempest::CLI
- Defined in:
- lib/tempest/cli.rb
Defined Under Namespace
Classes: RelineReader
Constant Summary collapse
- VALID_FEED_MODES =
%i[home self].freeze
Class Method Summary collapse
- .attach_store(session, store, identifier) ⇒ Object
-
.build_debug_logger(env) ⇒ Object
Returns a Logger configured from TEMPEST_DEBUG_LOG (path) and TEMPEST_DEBUG_LOG_LEVEL.
-
.build_subscription(mode:, session:, client:, handle_resolver: nil, stdout: nil) ⇒ Object
Decides what the Jetstream subscription should look like for a freshly signed-in session.
- .create_with_2fa(config, env, stdout, stdin, session_factory) ⇒ Object
- .cursor_store(env) ⇒ Object
- .feed_mode(argv:, env: {}) ⇒ Object
- .help_text ⇒ Object
- .nil_if_empty(value) ⇒ Object
- .opener_for(env:, system_proc: Kernel.method(:system)) ⇒ Object
- .run(argv: ARGV, env: ENV, stdout: $stdout, stderr: $stderr, stdin: $stdin, session_factory: Tempest::Session.method(:create), store: nil) ⇒ Object
- .sign_in(env, stdout, stdin, session_factory, store:) ⇒ Object
- .stream_default_on?(argv, env) ⇒ Boolean
- .timeline_store(env) ⇒ Object
-
.watchdog_options(env) ⇒ Object
Parses TEMPEST_WATCHDOG_THRESHOLD / TEMPEST_WATCHDOG_INTERVAL into the keyword-hash shape Watchdog expects.
Class Method Details
.attach_store(session, store, identifier) ⇒ Object
242 243 244 245 |
# File 'lib/tempest/cli.rb', line 242 def attach_store(session, store, identifier) session.identifier ||= identifier session.on_change = ->(s) { store.save(s, identifier: s.identifier || identifier) } end |
.build_debug_logger(env) ⇒ Object
Returns a Logger configured from TEMPEST_DEBUG_LOG (path) and TEMPEST_DEBUG_LOG_LEVEL. When TEMPEST_DEBUG_LOG is unset, the returned logger writes to IO::NULL at FATAL level so call sites can log unconditionally without producing files or output.
175 176 177 |
# File 'lib/tempest/cli.rb', line 175 def build_debug_logger(env) Tempest::DebugLog.from_env(env) end |
.build_subscription(mode:, session:, client:, handle_resolver: nil, stdout: nil) ⇒ Object
Decides what the Jetstream subscription should look like for a freshly signed-in session. In :self mode we only watch the user’s own DID (the historical earthquake-style “echo my posts” UX). In :home mode we fetch the user’s follows from AppView and let Subscription decide between server-side wantedDids filtering and a firehose+client-filter fallback. When a handle_resolver is provided, follow handles are seeded so the live feed can render @handle without an extra getProfile roundtrip.
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 |
# File 'lib/tempest/cli.rb', line 222 def build_subscription(mode:, session:, client:, handle_resolver: nil, stdout: nil) case mode when :self Tempest::Jetstream::Plan.new(wanted_dids: [session.did], filter: nil) when :home stdout&.puts "[tempest] fetching follows..." follows = Tempest::Follows.fetch(client, actor: session.did) follows.each { |f| handle_resolver&.seed(f[:did], f[:handle]) } plan = Tempest::Jetstream::Subscription.build(self_did: session.did, follows: follows) if plan.filter stdout&.puts "[tempest] following #{follows.length} accounts (exceeds 10000 cap; using firehose+client-filter)" else stdout&.puts "[tempest] following #{follows.length} accounts" end plan else raise ArgumentError, "unknown feed mode: #{mode.inspect}" end end |
.create_with_2fa(config, env, stdout, stdin, session_factory) ⇒ Object
146 147 148 149 150 151 152 153 154 155 156 157 158 159 |
# File 'lib/tempest/cli.rb', line 146 def create_with_2fa(config, env, stdout, stdin, session_factory) token = env["TEMPEST_AUTH_FACTOR_TOKEN"] session_factory.call(config, auth_factor_token: token) rescue Tempest::AuthenticationError => e raise unless e.code == "AuthFactorTokenRequired" && token.nil? stdout.puts "Bluesky sent a sign-in code to your email. Enter it below." stdout.print "code: " stdout.flush code = stdin.gets&.strip raise Tempest::AuthenticationError.new("sign-in cancelled (no code entered)", code: e.code) if code.nil? || code.empty? session_factory.call(config, auth_factor_token: code) end |
.cursor_store(env) ⇒ Object
167 168 169 |
# File 'lib/tempest/cli.rb', line 167 def cursor_store(env) Tempest::CursorStore.new(path: Tempest::CursorStore.default_path(env)) end |
.feed_mode(argv:, env: {}) ⇒ Object
205 206 207 208 209 210 211 212 213 |
# File 'lib/tempest/cli.rb', line 205 def feed_mode(argv:, env: {}) flag = argv.find { |a| a.start_with?("--feed=") }&.split("=", 2)&.last raw = flag || env["TEMPEST_FEED"] || "home" mode = raw.to_sym raise ArgumentError, "invalid --feed value: #{raw.inspect} (must be home|self)" \ unless VALID_FEED_MODES.include?(mode) mode end |
.help_text ⇒ Object
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 |
# File 'lib/tempest/cli.rb', line 247 def help_text <<~HELP Usage: tempest [options] Options: -h, --help Show this help -v, --version Show version --no-stream Disable the auto-started Jetstream feed --feed=MODE Choose what the live feed subscribes to: home (default) Your follows + your own posts self Only your own posts (legacy echo mode) Environment (required only when no cached session is available): TEMPEST_IDENTIFIER Your handle (e.g. asonas.bsky.social) TEMPEST_APP_PASSWORD An app password generated in Bluesky settings TEMPEST_PDS_HOST Override PDS host (default: https://bsky.social) TEMPEST_AUTH_FACTOR_TOKEN Pre-supply an email sign-in code (rarely needed; the CLI will prompt interactively when Bluesky asks for one) TEMPEST_NO_STREAM Set to 1 to disable the auto-started Jetstream feed TEMPEST_OPEN_CMD Command used to open URLs when :open $LX is invoked (default: "open"). The URL is passed as the single argument after the command. TEMPEST_SESSION_PATH Override the session cache path (default: $XDG_CONFIG_HOME/tempest/session.json or ~/.config/tempest/session.json). The cache holds refreshed tokens so the email sign-in code is only requested once. TEMPEST_CURSOR_PATH Override the Jetstream cursor cache path (default: $XDG_CONFIG_HOME/tempest/cursor.json). Holds the last-seen time_us so a restart can replay missed events. TEMPEST_FEED "home" (default) or "self"; equivalent to --feed. TEMPEST_DEBUG_LOG Path to a debug log file. When set, the live-stream component writes timestamped state transitions to this file (rotated daily). Unset by default — no file is created and no output is produced. TEMPEST_DEBUG_LOG_LEVEL DEBUG | INFO (default) | WARN. Overrides the log verbosity when TEMPEST_DEBUG_LOG is enabled. TEMPEST_WATCHDOG_THRESHOLD Seconds without a Jetstream event before the watchdog forces a reconnect (default: 90). TEMPEST_WATCHDOG_INTERVAL Seconds between watchdog checks (default: 30). HELP end |
.nil_if_empty(value) ⇒ Object
142 143 144 |
# File 'lib/tempest/cli.rb', line 142 def nil_if_empty(value) value.nil? || value.empty? ? nil : value end |
.opener_for(env:, system_proc: Kernel.method(:system)) ⇒ Object
197 198 199 200 201 |
# File 'lib/tempest/cli.rb', line 197 def opener_for(env:, system_proc: Kernel.method(:system)) cmd = env["TEMPEST_OPEN_CMD"] return Tempest::REPL::Runner::DEFAULT_OPENER if cmd.nil? || cmd.empty? ->(url) { system_proc.call(cmd, url) } end |
.run(argv: ARGV, env: ENV, stdout: $stdout, stderr: $stderr, stdin: $stdin, session_factory: Tempest::Session.method(:create), store: nil) ⇒ Object
24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 |
# File 'lib/tempest/cli.rb', line 24 def run(argv: ARGV, env: ENV, stdout: $stdout, stderr: $stderr, stdin: $stdin, session_factory: Tempest::Session.method(:create), store: nil) if argv.include?("--version") || argv.include?("-v") stdout.puts "tempest #{Tempest::VERSION}" return 0 end if argv.include?("--help") || argv.include?("-h") stdout.puts help_text return 0 end Tempest::REPL::Formatter.color = stdout.respond_to?(:tty?) && stdout.tty? && env["NO_COLOR"].to_s.empty? debug_logger = build_debug_logger(env) store ||= Tempest::SessionStore.new(path: Tempest::SessionStore.default_path(env)) session = sign_in(env, stdout, stdin, session_factory, store: store) client = Tempest::XRPCClient.new(session) input = RelineReader.new handle_resolver = Tempest::HandleResolver.new(client: client) handle_resolver.seed(session.did, session.handle) mode = feed_mode(argv: argv, env: env) plan = build_subscription( mode: mode, session: session, client: client, handle_resolver: handle_resolver, stdout: stdout, ) jetstream_client = Tempest::Jetstream::Client.new( wanted_collections: [ "app.bsky.feed.post", "app.bsky.feed.like", "app.bsky.feed.repost", ], wanted_dids: plan.wanted_dids, ) stream_manager = Tempest::Jetstream::StreamManager.new( client: jetstream_client, cursor_store: cursor_store(env), filter: plan.filter, logger: debug_logger, ) watchdog = Tempest::Jetstream::Watchdog.new( stream_manager: stream_manager, logger: debug_logger, **(env), ) stdout.puts "tempest #{Tempest::VERSION} — signed in as @#{session.handle}" stdout.puts "Type :help for commands, :quit to exit." screen = Tempest::REPL::Screen.new(io: stdout) screen.enable runner = Tempest::REPL::Runner.new( session: session, client: client, input: input, output: screen.enabled? ? screen : stdout, stream_output: screen.enabled? ? screen : Tempest::REPL::AsyncOutput.new(stdout), stream_manager: stream_manager, handle_resolver: handle_resolver, timeline_store: timeline_store(env), opener: opener_for(env: env), ) begin runner.bootstrap_timeline if stream_default_on?(argv, env) runner.auto_start_stream end watchdog.start runner.run 0 ensure watchdog.stop screen.disable end rescue Tempest::Config::MissingValue => e stderr.puts "configuration error: #{e.}" stderr.puts "Set TEMPEST_IDENTIFIER and TEMPEST_APP_PASSWORD before launching." 2 rescue Tempest::AuthenticationError => e stderr.puts "authentication failed: #{e.}" 3 rescue Tempest::Error => e stderr.puts "error: #{e.}" 1 end |
.sign_in(env, stdout, stdin, session_factory, store:) ⇒ Object
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
# File 'lib/tempest/cli.rb', line 119 def sign_in(env, stdout, stdin, session_factory, store:) identifier_hint = nil_if_empty(env["TEMPEST_IDENTIFIER"]) pds_host_hint = nil_if_empty(env["TEMPEST_PDS_HOST"]) if (existing = store.load(identifier: identifier_hint, pds_host: pds_host_hint)) attach_store(existing, store, existing.identifier || identifier_hint) begin existing.refresh! return existing rescue Tempest::Error => e existing.on_change = nil stdout.puts "[tempest] cached session refresh failed: #{e.}" stdout.puts "[tempest] cache kept at #{store.path}; falling back to TEMPEST_IDENTIFIER/TEMPEST_APP_PASSWORD" end end config = Tempest::Config.from_env(env) session = create_with_2fa(config, env, stdout, stdin, session_factory) attach_store(session, store, config.identifier) store.save(session, identifier: config.identifier) session end |
.stream_default_on?(argv, env) ⇒ Boolean
161 162 163 164 165 |
# File 'lib/tempest/cli.rb', line 161 def stream_default_on?(argv, env) return false if argv.include?("--no-stream") return false if env["TEMPEST_NO_STREAM"] == "1" true end |
.timeline_store(env) ⇒ Object
193 194 195 |
# File 'lib/tempest/cli.rb', line 193 def timeline_store(env) Tempest::TimelineStore.new(path: Tempest::TimelineStore.default_path(env)) end |
.watchdog_options(env) ⇒ Object
Parses TEMPEST_WATCHDOG_THRESHOLD / TEMPEST_WATCHDOG_INTERVAL into the keyword-hash shape Watchdog expects. Raises ArgumentError on garbage so a typo in env config fails loudly rather than silently degrading.
182 183 184 185 186 187 188 189 190 191 |
# File 'lib/tempest/cli.rb', line 182 def (env) threshold = env["TEMPEST_WATCHDOG_THRESHOLD"] interval = env["TEMPEST_WATCHDOG_INTERVAL"] { threshold_seconds: threshold ? Integer(threshold) : Tempest::Jetstream::Watchdog::DEFAULT_THRESHOLD_SECONDS, interval_seconds: interval ? Integer(interval) : Tempest::Jetstream::Watchdog::DEFAULT_INTERVAL_SECONDS, } end |