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.



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 = (env, stdout, stdin, session_factory, store: store)
    target_did = session.did
  else
    accounts = Tempest::AccountsStore.new(env: env, logger: debug_logger)
    target_did, session = (
      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,
    **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, 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.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



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

Returns:

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

Raises:

  • (ArgumentError)


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.(
    did: session.did,
    handle: session.handle,
    identifier: config.identifier,
    pds_host: config.pds_host,
    added_at: Time.now.utc,
  )
  [session.did, session]
end

.help_textObject



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

.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 (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 (accounts: accounts, env: env, target: target, stderr: stderr)
  end

  if accounts.default
    target = accounts.resolve(accounts.default)
    return (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

Returns:

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