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



242
243
244
245
# File 'lib/tempest/cli.rb', line 242

def attach_store(session, store, identifier)
  session.identifier ||= identifier
  session.on_change = ->(s) { store.save(s, identifier: s.identifier || identifier) }
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.



175
176
177
# File 'lib/tempest/cli.rb', line 175

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.



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/tempest/cli.rb', line 222

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



146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/tempest/cli.rb', line 146

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



167
168
169
# File 'lib/tempest/cli.rb', line 167

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

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

Raises:

  • (ArgumentError)


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

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



247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
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
# File 'lib/tempest/cli.rb', line 247

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



142
143
144
# File 'lib/tempest/cli.rb', line 142

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

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



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

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



24
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
# File 'lib/tempest/cli.rb', line 24

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)

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



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/tempest/cli.rb', line 119

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)


161
162
163
164
165
# File 'lib/tempest/cli.rb', line 161

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



193
194
195
# File 'lib/tempest/cli.rb', line 193

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.



182
183
184
185
186
187
188
189
190
191
# File 'lib/tempest/cli.rb', line 182

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