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

Class Method Details

.attach_store(session, store, identifier) ⇒ Object



274
275
276
277
# File 'lib/tempest/cli.rb', line 274

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



221
222
223
224
225
226
227
# File 'lib/tempest/cli.rb', line 221

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.



199
200
201
# File 'lib/tempest/cli.rb', line 199

def build_debug_logger(env)
  Tempest::DebugLog.from_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.



163
164
165
166
167
168
# File 'lib/tempest/cli.rb', line 163

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.



254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/tempest/cli.rb', line 254

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



170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/tempest/cli.rb', line 170

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



191
192
193
# File 'lib/tempest/cli.rb', line 191

def cursor_store(env)
  Tempest::CursorStore.new(path: Tempest::CursorStore.default_path(env))
end

.feed_mode(argv:, env: {}) ⇒ Object

Raises:

  • (ArgumentError)


237
238
239
240
241
242
243
244
245
# File 'lib/tempest/cli.rb', line 237

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



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
312
313
314
315
316
317
318
319
320
321
322
323
# File 'lib/tempest/cli.rb', line 279

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



155
156
157
# File 'lib/tempest/cli.rb', line 155

def nil_if_empty(value)
  value.nil? || value.empty? ? nil : value
end

.opener_for(env:, system_proc: Kernel.method(:system)) ⇒ Object



229
230
231
232
233
# File 'lib/tempest/cli.rb', line 229

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
130
# 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 = (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

.sign_in(env, stdout, stdin, session_factory, store:) ⇒ Object



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/tempest/cli.rb', line 132

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)


185
186
187
188
189
# File 'lib/tempest/cli.rb', line 185

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



217
218
219
# File 'lib/tempest/cli.rb', line 217

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.



206
207
208
209
210
211
212
213
214
215
# File 'lib/tempest/cli.rb', line 206

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