Module: Tempest::Commands::Tui
- Defined in:
- lib/tempest/commands/tui.rb
Defined Under Namespace
Classes: RelineReader
Constant Summary collapse
- VALID_FEED_MODES =
%i[home self].freeze
Class Method Summary collapse
-
.announce_debug_logger(channel, stderr) ⇒ Object
Print a one-line note on stderr so the user knows where to look for the log files.
- .attach_store(session, store, identifier) ⇒ Object
- .avatar_cache_dir(env) ⇒ Object
-
.build_debug_logger(env, argv: []) ⇒ Object
Returns a Tempest::DebugLog::Channel.
-
.build_reauth(env, stdout, stdin, session_factory) ⇒ Object
Builds the proc REPL::Runner uses to honour ‘:relogin`.
-
.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.
- .call(argv:, env:, stdout:, stderr:, stdin:, session_factory: Tempest::Session.method(:create), store: nil, user: nil) ⇒ Object
- .create_with_2fa(config, env, stdout, stdin, session_factory) ⇒ Object
- .cursor_store(env, did: nil) ⇒ Object
- .debug_flag?(argv:, env:) ⇒ Boolean
- .feed_mode(argv:, env: {}) ⇒ Object
- .first_run_env_path(accounts:, env:, stdout:, stdin:, session_factory:) ⇒ Object
- .help_text ⇒ Object
- .nil_if_empty(value) ⇒ Object
- .opener_for(env:, system_proc: Kernel.method(:system)) ⇒ Object
- .resume_account(accounts:, env:, target:, stderr:) ⇒ Object
- .sign_in(env, stdout, stdin, session_factory, store:) ⇒ Object
-
.sign_in_with_accounts(accounts:, env:, user:, stdout:, stdin:, stderr:, session_factory:) ⇒ Object
Multi-account-aware sign-in for ‘tempest tui`.
- .stream_default_on?(argv, env) ⇒ Boolean
- .timeline_store(env, did: nil) ⇒ Object
-
.watchdog_options(env) ⇒ Object
Parses TEMPEST_WATCHDOG_THRESHOLD / TEMPEST_WATCHDOG_INTERVAL into the keyword-hash shape Watchdog expects.
Class Method Details
.announce_debug_logger(channel, stderr) ⇒ Object
Print a one-line note on stderr so the user knows where to look for the log files. Silent when logging is disabled.
316 317 318 319 320 321 322 323 |
# File 'lib/tempest/commands/tui.rb', line 316 def announce_debug_logger(channel, stderr) paths = channel.loggers.map { |l| dev = l.instance_variable_get(:@logdev) dev && dev.filename }.compact return if paths.empty? stderr.puts "[tempest] debug log: #{paths.join(', ')}" end |
.attach_store(session, store, identifier) ⇒ Object
400 401 402 403 |
# File 'lib/tempest/commands/tui.rb', line 400 def attach_store(session, store, identifier) session.identifier ||= identifier session.on_change = ->(s) { store.save(s, identifier: s.identifier || identifier) } end |
.avatar_cache_dir(env) ⇒ Object
347 348 349 350 351 352 353 |
# File 'lib/tempest/commands/tui.rb', line 347 def avatar_cache_dir(env) override = env["TEMPEST_AVATAR_CACHE_DIR"] return override if override && !override.empty? base = env["XDG_CACHE_HOME"] base = File.join(env["HOME"] || Dir.home, ".cache") if base.nil? || base.empty? File.join(base, "tempest", "avatars") end |
.build_debug_logger(env, argv: []) ⇒ Object
Returns a Tempest::DebugLog::Channel. info.log is always enabled (suppress with TEMPEST_NO_LOG=1). debug.log is enabled when –debug is passed on the command line or TEMPEST_DEBUG=1 is set in the environment. The legacy TEMPEST_DEBUG_LOG=<path> env var still routes everything to a single file regardless of the other settings.
305 306 307 |
# File 'lib/tempest/commands/tui.rb', line 305 def build_debug_logger(env, argv: []) Tempest::DebugLog.build(env: env, debug: debug_flag?(argv: argv, env: env)) end |
.build_reauth(env, stdout, stdin, session_factory) ⇒ Object
Builds the proc REPL::Runner uses to honour ‘:relogin`. The lambda re-reads credentials from `env` on each call (so a user can update env in-process if needed) and goes through the same 2FA prompt path as initial sign-in.
264 265 266 267 268 269 |
# File 'lib/tempest/commands/tui.rb', line 264 def build_reauth(env, stdout, stdin, session_factory) lambda do config = Tempest::Config.from_env(env) create_with_2fa(config, env, stdout, stdin, session_factory) end 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.
380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 |
# File 'lib/tempest/commands/tui.rb', line 380 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 |
.call(argv:, env:, stdout:, stderr:, stdin:, session_factory: Tempest::Session.method(:create), store: nil, user: nil) ⇒ Object
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 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/tempest/commands/tui.rb', line 29 def call(argv:, env:, stdout:, stderr:, stdin:, session_factory: Tempest::Session.method(:create), store: nil, user: nil) Tempest::REPL::Formatter.color = stdout.respond_to?(:tty?) && stdout.tty? && env["NO_COLOR"].to_s.empty? debug_logger = build_debug_logger(env, argv: argv) announce_debug_logger(debug_logger, stderr) Tempest::AccountsMigration.run(env: env, stderr: stderr, logger: debug_logger) if store # Test injection path: behave as the legacy single-account flow so # existing tests pass unchanged. session = sign_in(env, stdout, stdin, session_factory, store: store) target_did = session.did else accounts = Tempest::AccountsStore.new(env: env, logger: debug_logger) target_did, session = sign_in_with_accounts( accounts: accounts, env: env, user: user, stdout: stdout, stdin: stdin, stderr: stderr, session_factory: session_factory, ) return 3 if session.nil? store = Tempest::SessionStore.for(env, did: target_did) end client = Tempest::XRPCClient.new(session) input = RelineReader.new handle_resolver = Tempest::HandleResolver.new(client: client) handle_resolver.seed(session.did, session.handle) # NOTE: we intentionally don't pass `client` (the XRPCClient) here. # XRPCClient routes through Tempest::HTTP / Async, whose Fibers cannot # be resumed across threads; AvatarStore runs resolution in background # workers. DefaultProfileClient is a plain Net::HTTP client that hits # public.api.bsky.app unauthenticated, which is thread-safe. avatar_store = Tempest::AvatarStore.new( client: Tempest::AvatarStore::DefaultProfileClient.new, cache_dir: avatar_cache_dir(env), ) 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, did: target_did), 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, avatar_store: avatar_store, timeline_store: timeline_store(env, did: target_did), opener: opener_for(env: env), reauth: build_reauth(env, stdout, stdin, session_factory), ) 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 |
.create_with_2fa(config, env, stdout, stdin, session_factory) ⇒ Object
271 272 273 274 275 276 277 278 279 280 281 282 283 284 |
# File 'lib/tempest/commands/tui.rb', line 271 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, did: nil) ⇒ Object
292 293 294 295 296 297 298 |
# File 'lib/tempest/commands/tui.rb', line 292 def cursor_store(env, did: nil) if did Tempest::CursorStore.for(env, did: did) else Tempest::CursorStore.new(path: Tempest::CursorStore.default_path(env)) end end |
.debug_flag?(argv:, env:) ⇒ Boolean
309 310 311 312 |
# File 'lib/tempest/commands/tui.rb', line 309 def debug_flag?(argv:, env:) return true if argv.include?("--debug") env["TEMPEST_DEBUG"] == "1" end |
.feed_mode(argv:, env: {}) ⇒ Object
363 364 365 366 367 368 369 370 371 |
# File 'lib/tempest/commands/tui.rb', line 363 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 |
.first_run_env_path(accounts:, env:, stdout:, stdin:, session_factory:) ⇒ Object
223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 |
# File 'lib/tempest/commands/tui.rb', line 223 def first_run_env_path(accounts:, env:, stdout:, stdin:, session_factory:) # First-run env path intentionally pins pds_host to the default so that # the deprecated TEMPEST_PDS_HOST env var has no effect (spec §"Deprecated # env vars"). Alternate PDS hosts must go through `tempest login # --pds-host=...`. identifier = nil_if_empty(env["TEMPEST_IDENTIFIER"]) app_password = nil_if_empty(env["TEMPEST_APP_PASSWORD"]) raise Tempest::Config::MissingValue, "TEMPEST_IDENTIFIER is not set" if identifier.nil? raise Tempest::Config::MissingValue, "TEMPEST_APP_PASSWORD is not set" if app_password.nil? config = Tempest::Config.new( identifier: identifier, app_password: app_password, pds_host: Tempest::Config::DEFAULT_PDS_HOST, ) session = create_with_2fa(config, env, stdout, stdin, session_factory) store = Tempest::SessionStore.for(env, did: session.did) session.identifier ||= config.identifier session.on_change = ->(s) { store.save(s, identifier: s.identifier || config.identifier) accounts.update_handle(did: s.did, handle: s.handle) if s.did && s.handle } store.save(session, identifier: config.identifier) accounts.add_account( did: session.did, handle: session.handle, identifier: config.identifier, pds_host: config.pds_host, added_at: Time.now.utc, ) [session.did, session] end |
.help_text ⇒ Object
405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 |
# File 'lib/tempest/commands/tui.rb', line 405 def help_text <<~HELP Usage: tempest [--user <handle|did>] [subcommand] [options] Subcommands: tui (default) launch the interactive TUI post <text|-> create a post (use `-` to read text from stdin) feed me|timeline|author <handle> [opts] read posts; --format=line|json|raw, --since, --until, --limit whoami print the signed-in identity follow <handle> follow a Bluesky account login add a Bluesky account (interactive) accounts list show known accounts; * marks default. --format=json supported. accounts set-default <handle|did> pick which account `tempest` uses when --user is unset Global options: --user <handle|did> Pick which account to act as. Defaults to the entry marked default in accounts.json. Not accepted by `login` or `accounts`. TUI 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) --debug Also write a verbose debug.log alongside the always-on info.log. Both files live under $XDG_STATE_HOME/tempest (default ~/.local/state/tempest) and use size-based rotation (5 MiB x 5 files). Environment (required only on first run, when accounts.json is absent): TEMPEST_IDENTIFIER Your handle (e.g. asonas.bsky.social) TEMPEST_APP_PASSWORD An app password generated in Bluesky settings 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_FEED "home" (default) or "self"; equivalent to --feed. TEMPEST_DEBUG Set to 1 to behave as if --debug was passed. TEMPEST_LOG_DIR Override the directory holding info.log and debug.log. Default: $XDG_STATE_HOME/tempest or ~/.local/state/tempest. TEMPEST_NO_LOG Set to 1 to disable info.log/debug.log entirely. TEMPEST_DEBUG_LOG Legacy: path to a single combined log file. When set, every log line (DEBUG and above) is written to this path in addition to the regular info.log/debug.log. TEMPEST_DEBUG_LOG_LEVEL DEBUG (default) | INFO | WARN. Overrides the log verbosity for the legacy TEMPEST_DEBUG_LOG file. 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
256 257 258 |
# File 'lib/tempest/commands/tui.rb', line 256 def nil_if_empty(value) value.nil? || value.empty? ? nil : value end |
.opener_for(env:, system_proc: Kernel.method(:system)) ⇒ Object
355 356 357 358 359 |
# File 'lib/tempest/commands/tui.rb', line 355 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 |
.resume_account(accounts:, env:, target:, stderr:) ⇒ Object
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 |
# File 'lib/tempest/commands/tui.rb', line 201 def resume_account(accounts:, env:, target:, stderr:) store = Tempest::SessionStore.for(env, did: target.did) session = store.load(identifier: nil, pds_host: nil) if session.nil? stderr.puts "error: session for @#{target.handle} missing — run `tempest login` to re-authenticate" return [nil, nil] end session.identifier ||= target.identifier session.on_change = ->(s) { store.save(s, identifier: s.identifier || target.identifier) accounts.update_handle(did: s.did, handle: s.handle) if s.did && s.handle } begin session.refresh! rescue Tempest::Error => e stderr.puts "error: session for @#{target.handle} expired — run `tempest login` to re-authenticate (#{e.})" return [nil, nil] end [target.did, session] end |
.sign_in(env, stdout, stdin, session_factory, store:) ⇒ Object
143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 |
# File 'lib/tempest/commands/tui.rb', line 143 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 |
.sign_in_with_accounts(accounts:, env:, user:, stdout:, stdin:, stderr:, session_factory:) ⇒ Object
Multi-account-aware sign-in for ‘tempest tui`. Returns [did, session] on success, or [nil, nil] when stderr already received the error message.
Order of precedence:
1. `user` argument: resolve against accounts.json; refresh
`accounts/<did>/session.json`. Fail loudly on unknown user or
refresh failure.
2. Default account: same flow, target is `accounts.default`.
3. No accounts and `TEMPEST_IDENTIFIER`/`TEMPEST_APP_PASSWORD` set:
first-run env path. Create session via 2FA-aware factory, save it
under the per-DID layout, and register it as default.
4. No accounts and no env: stderr "no accounts configured" and bail.
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
# File 'lib/tempest/commands/tui.rb', line 178 def sign_in_with_accounts(accounts:, env:, user:, stdout:, stdin:, stderr:, session_factory:) if user target = accounts.resolve(user) if target.nil? stderr.puts "error: unknown user: #{user} (run `tempest accounts list` to see known accounts)" return [nil, nil] end return resume_account(accounts: accounts, env: env, target: target, stderr: stderr) end if accounts.default target = accounts.resolve(accounts.default) return resume_account(accounts: accounts, env: env, target: target, stderr: stderr) end if accounts.accounts.empty? && !nil_if_empty(env["TEMPEST_IDENTIFIER"]).nil? return first_run_env_path(accounts: accounts, env: env, stdout: stdout, stdin: stdin, session_factory: session_factory) end stderr.puts "error: no accounts configured — run `tempest login` to add one" [nil, nil] end |
.stream_default_on?(argv, env) ⇒ Boolean
286 287 288 289 290 |
# File 'lib/tempest/commands/tui.rb', line 286 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, did: nil) ⇒ Object
339 340 341 342 343 344 345 |
# File 'lib/tempest/commands/tui.rb', line 339 def timeline_store(env, did: nil) if did Tempest::TimelineStore.for(env, did: did) else Tempest::TimelineStore.new(path: Tempest::TimelineStore.default_path(env)) end 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.
328 329 330 331 332 333 334 335 336 337 |
# File 'lib/tempest/commands/tui.rb', line 328 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 |