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
- .avatar_cache_dir(env) ⇒ 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
262 263 264 265 |
# File 'lib/tempest/cli.rb', line 262 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
209 210 211 212 213 214 215 |
# File 'lib/tempest/cli.rb', line 209 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) ⇒ 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.
187 188 189 |
# File 'lib/tempest/cli.rb', line 187 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.
242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 |
# File 'lib/tempest/cli.rb', line 242 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
158 159 160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/tempest/cli.rb', line 158 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
179 180 181 |
# File 'lib/tempest/cli.rb', line 179 def cursor_store(env) Tempest::CursorStore.new(path: Tempest::CursorStore.default_path(env)) end |
.feed_mode(argv:, env: {}) ⇒ Object
225 226 227 228 229 230 231 232 233 |
# File 'lib/tempest/cli.rb', line 225 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
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 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 |
# File 'lib/tempest/cli.rb', line 267 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
154 155 156 |
# File 'lib/tempest/cli.rb', line 154 def nil_if_empty(value) value.nil? || value.empty? ? nil : value end |
.opener_for(env:, system_proc: Kernel.method(:system)) ⇒ Object
217 218 219 220 221 |
# File 'lib/tempest/cli.rb', line 217 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
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 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/tempest/cli.rb', line 25 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) # 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), ) 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
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/tempest/cli.rb', line 131 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
173 174 175 176 177 |
# File 'lib/tempest/cli.rb', line 173 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
205 206 207 |
# File 'lib/tempest/cli.rb', line 205 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.
194 195 196 197 198 199 200 201 202 203 |
# File 'lib/tempest/cli.rb', line 194 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 |