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) ⇒ Object
- .create_with_2fa(config, env, stdout, stdin, session_factory) ⇒ Object
- .cursor_store(env) ⇒ Object
- .debug_flag?(argv:, env:) ⇒ Boolean
- .feed_mode(argv:, env: {}) ⇒ Object
- .help_text ⇒ Object
- .nil_if_empty(value) ⇒ Object
- .opener_for(env:, system_proc: Kernel.method(:system)) ⇒ 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
.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.
204 205 206 207 208 209 210 211 |
# File 'lib/tempest/commands/tui.rb', line 204 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
284 285 286 287 |
# File 'lib/tempest/commands/tui.rb', line 284 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
231 232 233 234 235 236 237 |
# File 'lib/tempest/commands/tui.rb', line 231 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.
193 194 195 |
# File 'lib/tempest/commands/tui.rb', line 193 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.
156 157 158 159 160 161 |
# File 'lib/tempest/commands/tui.rb', line 156 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.
264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 |
# File 'lib/tempest/commands/tui.rb', line 264 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) ⇒ Object
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 118 119 120 121 122 123 |
# File 'lib/tempest/commands/tui.rb', line 27 def call(argv:, env:, stdout:, stderr:, stdin:, session_factory: Tempest::Session.method(:create), store: 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) 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) # 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), 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), 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
163 164 165 166 167 168 169 170 171 172 173 174 175 176 |
# File 'lib/tempest/commands/tui.rb', line 163 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
184 185 186 |
# File 'lib/tempest/commands/tui.rb', line 184 def cursor_store(env) Tempest::CursorStore.new(path: Tempest::CursorStore.default_path(env)) end |
.debug_flag?(argv:, env:) ⇒ Boolean
197 198 199 200 |
# File 'lib/tempest/commands/tui.rb', line 197 def debug_flag?(argv:, env:) return true if argv.include?("--debug") env["TEMPEST_DEBUG"] == "1" end |
.feed_mode(argv:, env: {}) ⇒ Object
247 248 249 250 251 252 253 254 255 |
# File 'lib/tempest/commands/tui.rb', line 247 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
289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 |
# File 'lib/tempest/commands/tui.rb', line 289 def help_text <<~HELP Usage: tempest [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 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 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 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
148 149 150 |
# File 'lib/tempest/commands/tui.rb', line 148 def nil_if_empty(value) value.nil? || value.empty? ? nil : value end |
.opener_for(env:, system_proc: Kernel.method(:system)) ⇒ Object
239 240 241 242 243 |
# File 'lib/tempest/commands/tui.rb', line 239 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 |
.sign_in(env, stdout, stdin, session_factory, store:) ⇒ Object
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 |
# File 'lib/tempest/commands/tui.rb', line 125 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
178 179 180 181 182 |
# File 'lib/tempest/commands/tui.rb', line 178 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
227 228 229 |
# File 'lib/tempest/commands/tui.rb', line 227 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.
216 217 218 219 220 221 222 223 224 225 |
# File 'lib/tempest/commands/tui.rb', line 216 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 |