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



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

Raises:

  • (ArgumentError)


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_textObject



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 = (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),
  )

  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



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


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