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

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 = (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,
    **watchdog_options(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.message}"
  stderr.puts "Set TEMPEST_IDENTIFIER and TEMPEST_APP_PASSWORD before launching."
  2
rescue Tempest::AuthenticationError => e
  stderr.puts "authentication failed: #{e.message}"
  3
rescue Tempest::Error => e
  stderr.puts "error: #{e.message}"
  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

Returns:

  • (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

Raises:

  • (ArgumentError)


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_textObject



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 (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.message}"
      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

Returns:

  • (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 watchdog_options(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