tempest-rb 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +19 -0
- data/lib/tempest/avatar_store.rb +9 -0
- data/lib/tempest/cli.rb +72 -257
- data/lib/tempest/commands/base.rb +69 -0
- data/lib/tempest/commands/feed.rb +137 -0
- data/lib/tempest/commands/post.rb +101 -0
- data/lib/tempest/commands/tui.rb +337 -0
- data/lib/tempest/commands/whoami.rb +31 -0
- data/lib/tempest/commands.rb +6 -0
- data/lib/tempest/date_filter.rb +34 -0
- data/lib/tempest/handle_lookup.rb +14 -0
- data/lib/tempest/output/json_writer.rb +25 -0
- data/lib/tempest/output/line_writer.rb +20 -0
- data/lib/tempest/post.rb +3 -1
- data/lib/tempest/post_view.rb +69 -0
- data/lib/tempest/repl/dispatcher.rb +1 -1
- data/lib/tempest/repl/formatter.rb +1 -1
- data/lib/tempest/repl/runner.rb +24 -1
- data/lib/tempest/repl/screen.rb +59 -0
- data/lib/tempest/session.rb +35 -3
- data/lib/tempest/version.rb +1 -1
- data/lib/tempest/xrpc_client.rb +12 -3
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b397a067ca8e4f637036351fd8d3f29f41dc7ef6a96d809c8f793993f34c940d
|
|
4
|
+
data.tar.gz: 83435fddf2631e5d16afdc8d185ae3b3ba5666c1a5bb6b838705e5ee4fc526f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c770fedb069f714b7a280535efe1c5984001bfcad6cce6a1ab4cc0e6ee8ee579fec9402659578b1573572c98d40fd089fe82f5469c0e040f0cc33b1dfeda39a0
|
|
7
|
+
data.tar.gz: 3cba24306fa9a90c00f44c77bd768753b1ab26f3420be6fb3706b275bdbaa426e869ce791248e3c0be7a8dfc27e673610f7286b60ffb416806c4b01b26ccf724
|
data/README.md
CHANGED
|
@@ -101,6 +101,25 @@ A built-in watchdog runs alongside the Jetstream consumer regardless of logging:
|
|
|
101
101
|
|
|
102
102
|
To inspect the log, grep by component tag: `grep '\[stream\]' ~/tempest-debug.log` shows connect, reconnect, gap, and disconnect events, while `grep '\[watchdog\]' ~/tempest-debug.log` shows forced reconnects.
|
|
103
103
|
|
|
104
|
+
## Non-interactive CLI
|
|
105
|
+
|
|
106
|
+
Once you have signed in once with `tempest tui`, you can call the CLI from scripts and tools:
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
tempest whoami --json
|
|
110
|
+
tempest post "今日もよろしくお願いします"
|
|
111
|
+
tempest feed me --since today --format json | jq '.text'
|
|
112
|
+
tempest feed author asonas.bsky.social --limit 20
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`--format=json` emits newline-delimited JSON; one post per line. The schema is documented in `lib/tempest/post_view.rb`.
|
|
116
|
+
|
|
117
|
+
`--format=raw` emits the underlying `getAuthorFeed`/`getTimeline` response pretty-printed; do not rely on its shape.
|
|
118
|
+
|
|
119
|
+
`--format=line` (default when stdout is a TTY) prints the same single-line representation as the TUI scroll buffer.
|
|
120
|
+
|
|
121
|
+
The non-interactive subcommands require a cached session on disk. If your cache is missing or expired, run `tempest tui` once to refresh it.
|
|
122
|
+
|
|
104
123
|
## Development
|
|
105
124
|
|
|
106
125
|
```sh
|
data/lib/tempest/avatar_store.rb
CHANGED
|
@@ -123,6 +123,11 @@ module Tempest
|
|
|
123
123
|
cached = @mutex.synchronize { @cache[did] }
|
|
124
124
|
return cached_value(cached) unless cached.nil?
|
|
125
125
|
|
|
126
|
+
if @async && (path = cached_file_for(did))
|
|
127
|
+
@mutex.synchronize { @cache[did] = path }
|
|
128
|
+
return path
|
|
129
|
+
end
|
|
130
|
+
|
|
126
131
|
if @async
|
|
127
132
|
enqueue_resolve(did)
|
|
128
133
|
nil
|
|
@@ -141,6 +146,10 @@ module Tempest
|
|
|
141
146
|
value.equal?(NOT_FOUND) ? nil : value
|
|
142
147
|
end
|
|
143
148
|
|
|
149
|
+
def cached_file_for(did)
|
|
150
|
+
Dir.glob(File.join(@cache_dir, "#{sanitize(did)}__*.png")).max_by { |path| File.mtime(path) }
|
|
151
|
+
end
|
|
152
|
+
|
|
144
153
|
def resolve_and_cache(did)
|
|
145
154
|
path = resolve_sync(did)
|
|
146
155
|
@mutex.synchronize { @cache[did] = path.nil? ? NOT_FOUND : path }
|
data/lib/tempest/cli.rb
CHANGED
|
@@ -1,25 +1,15 @@
|
|
|
1
1
|
require_relative "../tempest"
|
|
2
|
-
require_relative "
|
|
3
|
-
require_relative "
|
|
4
|
-
require_relative "
|
|
5
|
-
require_relative "
|
|
6
|
-
require_relative "
|
|
7
|
-
require_relative "timeline_store"
|
|
2
|
+
require_relative "commands/tui"
|
|
3
|
+
require_relative "commands/base"
|
|
4
|
+
require_relative "commands/whoami"
|
|
5
|
+
require_relative "commands/post"
|
|
6
|
+
require_relative "commands/feed"
|
|
8
7
|
require_relative "xrpc_client"
|
|
9
|
-
require_relative "handle_resolver"
|
|
10
|
-
require_relative "avatar_store"
|
|
11
|
-
require_relative "follows"
|
|
12
|
-
require_relative "jetstream/client"
|
|
13
|
-
require_relative "jetstream/stream_manager"
|
|
14
|
-
require_relative "jetstream/subscription"
|
|
15
|
-
require_relative "jetstream/watchdog"
|
|
16
|
-
require_relative "repl/runner"
|
|
17
|
-
require_relative "repl/formatter"
|
|
18
|
-
require_relative "repl/async_output"
|
|
19
|
-
require_relative "repl/screen"
|
|
20
8
|
|
|
21
9
|
module Tempest
|
|
22
10
|
module CLI
|
|
11
|
+
SUBCOMMANDS = %w[tui post feed whoami].freeze
|
|
12
|
+
|
|
23
13
|
module_function
|
|
24
14
|
|
|
25
15
|
def run(argv: ARGV, env: ENV, stdout: $stdout, stderr: $stderr, stdin: $stdin,
|
|
@@ -31,295 +21,120 @@ module Tempest
|
|
|
31
21
|
end
|
|
32
22
|
|
|
33
23
|
if argv.include?("--help") || argv.include?("-h")
|
|
34
|
-
stdout.puts help_text
|
|
24
|
+
stdout.puts Tempest::Commands::Tui.help_text
|
|
35
25
|
return 0
|
|
36
26
|
end
|
|
37
27
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
# be resumed across threads; AvatarStore runs resolution in background
|
|
53
|
-
# workers. DefaultProfileClient is a plain Net::HTTP client that hits
|
|
54
|
-
# public.api.bsky.app unauthenticated, which is thread-safe.
|
|
55
|
-
avatar_store = Tempest::AvatarStore.new(
|
|
56
|
-
client: Tempest::AvatarStore::DefaultProfileClient.new,
|
|
57
|
-
cache_dir: avatar_cache_dir(env),
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
mode = feed_mode(argv: argv, env: env)
|
|
61
|
-
plan = build_subscription(
|
|
62
|
-
mode: mode, session: session, client: client,
|
|
63
|
-
handle_resolver: handle_resolver, stdout: stdout,
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
jetstream_client = Tempest::Jetstream::Client.new(
|
|
67
|
-
wanted_collections: [
|
|
68
|
-
"app.bsky.feed.post",
|
|
69
|
-
"app.bsky.feed.like",
|
|
70
|
-
"app.bsky.feed.repost",
|
|
71
|
-
],
|
|
72
|
-
wanted_dids: plan.wanted_dids,
|
|
73
|
-
)
|
|
74
|
-
stream_manager = Tempest::Jetstream::StreamManager.new(
|
|
75
|
-
client: jetstream_client,
|
|
76
|
-
cursor_store: cursor_store(env),
|
|
77
|
-
filter: plan.filter,
|
|
78
|
-
logger: debug_logger,
|
|
79
|
-
)
|
|
80
|
-
watchdog = Tempest::Jetstream::Watchdog.new(
|
|
81
|
-
stream_manager: stream_manager,
|
|
82
|
-
logger: debug_logger,
|
|
83
|
-
**watchdog_options(env),
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
stdout.puts "tempest #{Tempest::VERSION} — signed in as @#{session.handle}"
|
|
87
|
-
stdout.puts "Type :help for commands, :quit to exit."
|
|
88
|
-
|
|
89
|
-
screen = Tempest::REPL::Screen.new(io: stdout)
|
|
90
|
-
screen.enable
|
|
91
|
-
|
|
92
|
-
runner = Tempest::REPL::Runner.new(
|
|
93
|
-
session: session,
|
|
94
|
-
client: client,
|
|
95
|
-
input: input,
|
|
96
|
-
output: screen.enabled? ? screen : stdout,
|
|
97
|
-
stream_output: screen.enabled? ? screen : Tempest::REPL::AsyncOutput.new(stdout),
|
|
98
|
-
stream_manager: stream_manager,
|
|
99
|
-
handle_resolver: handle_resolver,
|
|
100
|
-
avatar_store: avatar_store,
|
|
101
|
-
timeline_store: timeline_store(env),
|
|
102
|
-
opener: opener_for(env: env),
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
begin
|
|
106
|
-
runner.bootstrap_timeline
|
|
107
|
-
|
|
108
|
-
if stream_default_on?(argv, env)
|
|
109
|
-
runner.auto_start_stream
|
|
28
|
+
head = argv.first
|
|
29
|
+
case
|
|
30
|
+
when head.nil?, head.start_with?("-"), head == "tui"
|
|
31
|
+
rest = (head == "tui") ? argv.drop(1) : argv
|
|
32
|
+
Tempest::Commands::Tui.call(
|
|
33
|
+
argv: rest, env: env, stdout: stdout, stderr: stderr, stdin: stdin,
|
|
34
|
+
session_factory: session_factory, store: store,
|
|
35
|
+
)
|
|
36
|
+
when SUBCOMMANDS.include?(head)
|
|
37
|
+
begin
|
|
38
|
+
dispatch_subcommand(head, argv, env: env, stdout: stdout, stderr: stderr, stdin: stdin)
|
|
39
|
+
rescue Tempest::Error, ArgumentError => e
|
|
40
|
+
stderr.puts "error: #{e.message}"
|
|
41
|
+
Tempest::Commands::Base.exit_code_for(e)
|
|
110
42
|
end
|
|
43
|
+
else
|
|
44
|
+
stderr.puts "unknown command: #{head.inspect}"
|
|
45
|
+
64
|
|
46
|
+
end
|
|
47
|
+
end
|
|
111
48
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
49
|
+
def dispatch_subcommand(head, argv, env:, stdout:, stderr:, stdin:)
|
|
50
|
+
session = Tempest::Commands::Base.authenticate(env: env, stderr: stderr)
|
|
51
|
+
return 3 if session.nil?
|
|
52
|
+
client = Tempest::XRPCClient.new(session)
|
|
53
|
+
case head
|
|
54
|
+
when "whoami"
|
|
55
|
+
Tempest::Commands::Whoami.call(
|
|
56
|
+
argv: argv.drop(1), session: session,
|
|
57
|
+
stdout: stdout, stderr: stderr,
|
|
58
|
+
)
|
|
59
|
+
when "post"
|
|
60
|
+
Tempest::Commands::Post.call(
|
|
61
|
+
argv: argv.drop(1), session: session, client: client,
|
|
62
|
+
stdout: stdout, stderr: stderr, stdin: stdin,
|
|
63
|
+
)
|
|
64
|
+
when "feed"
|
|
65
|
+
Tempest::Commands::Feed.call(
|
|
66
|
+
argv: argv.drop(1), session: session, client: client,
|
|
67
|
+
stdout: stdout, stderr: stderr,
|
|
68
|
+
)
|
|
118
69
|
end
|
|
119
|
-
rescue Tempest::Config::MissingValue => e
|
|
120
|
-
stderr.puts "configuration error: #{e.message}"
|
|
121
|
-
stderr.puts "Set TEMPEST_IDENTIFIER and TEMPEST_APP_PASSWORD before launching."
|
|
122
|
-
2
|
|
123
|
-
rescue Tempest::AuthenticationError => e
|
|
124
|
-
stderr.puts "authentication failed: #{e.message}"
|
|
125
|
-
3
|
|
126
|
-
rescue Tempest::Error => e
|
|
127
|
-
stderr.puts "error: #{e.message}"
|
|
128
|
-
1
|
|
129
70
|
end
|
|
130
71
|
|
|
131
|
-
|
|
132
|
-
identifier_hint = nil_if_empty(env["TEMPEST_IDENTIFIER"])
|
|
133
|
-
pds_host_hint = nil_if_empty(env["TEMPEST_PDS_HOST"])
|
|
72
|
+
VALID_FEED_MODES = Tempest::Commands::Tui::VALID_FEED_MODES
|
|
134
73
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
begin
|
|
138
|
-
existing.refresh!
|
|
139
|
-
return existing
|
|
140
|
-
rescue Tempest::Error => e
|
|
141
|
-
existing.on_change = nil
|
|
142
|
-
stdout.puts "[tempest] cached session refresh failed: #{e.message}"
|
|
143
|
-
stdout.puts "[tempest] cache kept at #{store.path}; falling back to TEMPEST_IDENTIFIER/TEMPEST_APP_PASSWORD"
|
|
144
|
-
end
|
|
145
|
-
end
|
|
74
|
+
# Forwarding delegates — keep Tempest::CLI.* callable so existing tests
|
|
75
|
+
# do not need modification. All logic lives in Tempest::Commands::Tui.
|
|
146
76
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
attach_store(session, store, config.identifier)
|
|
150
|
-
store.save(session, identifier: config.identifier)
|
|
151
|
-
session
|
|
77
|
+
def sign_in(env, stdout, stdin, session_factory, store:)
|
|
78
|
+
Tempest::Commands::Tui.sign_in(env, stdout, stdin, session_factory, store: store)
|
|
152
79
|
end
|
|
153
80
|
|
|
154
81
|
def nil_if_empty(value)
|
|
155
|
-
|
|
82
|
+
Tempest::Commands::Tui.nil_if_empty(value)
|
|
156
83
|
end
|
|
157
84
|
|
|
158
|
-
def
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
rescue Tempest::AuthenticationError => e
|
|
162
|
-
raise unless e.code == "AuthFactorTokenRequired" && token.nil?
|
|
163
|
-
|
|
164
|
-
stdout.puts "Bluesky sent a sign-in code to your email. Enter it below."
|
|
165
|
-
stdout.print "code: "
|
|
166
|
-
stdout.flush
|
|
167
|
-
code = stdin.gets&.strip
|
|
168
|
-
raise Tempest::AuthenticationError.new("sign-in cancelled (no code entered)", code: e.code) if code.nil? || code.empty?
|
|
85
|
+
def build_reauth(env, stdout, stdin, session_factory)
|
|
86
|
+
Tempest::Commands::Tui.build_reauth(env, stdout, stdin, session_factory)
|
|
87
|
+
end
|
|
169
88
|
|
|
170
|
-
|
|
89
|
+
def create_with_2fa(config, env, stdout, stdin, session_factory)
|
|
90
|
+
Tempest::Commands::Tui.create_with_2fa(config, env, stdout, stdin, session_factory)
|
|
171
91
|
end
|
|
172
92
|
|
|
173
93
|
def stream_default_on?(argv, env)
|
|
174
|
-
|
|
175
|
-
return false if env["TEMPEST_NO_STREAM"] == "1"
|
|
176
|
-
true
|
|
94
|
+
Tempest::Commands::Tui.stream_default_on?(argv, env)
|
|
177
95
|
end
|
|
178
96
|
|
|
179
97
|
def cursor_store(env)
|
|
180
|
-
Tempest::
|
|
98
|
+
Tempest::Commands::Tui.cursor_store(env)
|
|
181
99
|
end
|
|
182
100
|
|
|
183
|
-
# Returns a Logger configured from TEMPEST_DEBUG_LOG (path) and
|
|
184
|
-
# TEMPEST_DEBUG_LOG_LEVEL. When TEMPEST_DEBUG_LOG is unset, the returned
|
|
185
|
-
# logger writes to IO::NULL at FATAL level so call sites can log
|
|
186
|
-
# unconditionally without producing files or output.
|
|
187
101
|
def build_debug_logger(env)
|
|
188
|
-
Tempest::
|
|
102
|
+
Tempest::Commands::Tui.build_debug_logger(env)
|
|
189
103
|
end
|
|
190
104
|
|
|
191
|
-
# Parses TEMPEST_WATCHDOG_THRESHOLD / TEMPEST_WATCHDOG_INTERVAL into the
|
|
192
|
-
# keyword-hash shape Watchdog expects. Raises ArgumentError on garbage so
|
|
193
|
-
# a typo in env config fails loudly rather than silently degrading.
|
|
194
105
|
def watchdog_options(env)
|
|
195
|
-
|
|
196
|
-
interval = env["TEMPEST_WATCHDOG_INTERVAL"]
|
|
197
|
-
{
|
|
198
|
-
threshold_seconds: threshold ? Integer(threshold) :
|
|
199
|
-
Tempest::Jetstream::Watchdog::DEFAULT_THRESHOLD_SECONDS,
|
|
200
|
-
interval_seconds: interval ? Integer(interval) :
|
|
201
|
-
Tempest::Jetstream::Watchdog::DEFAULT_INTERVAL_SECONDS,
|
|
202
|
-
}
|
|
106
|
+
Tempest::Commands::Tui.watchdog_options(env)
|
|
203
107
|
end
|
|
204
108
|
|
|
205
109
|
def timeline_store(env)
|
|
206
|
-
Tempest::
|
|
110
|
+
Tempest::Commands::Tui.timeline_store(env)
|
|
207
111
|
end
|
|
208
112
|
|
|
209
113
|
def avatar_cache_dir(env)
|
|
210
|
-
|
|
211
|
-
return override if override && !override.empty?
|
|
212
|
-
base = env["XDG_CACHE_HOME"]
|
|
213
|
-
base = File.join(env["HOME"] || Dir.home, ".cache") if base.nil? || base.empty?
|
|
214
|
-
File.join(base, "tempest", "avatars")
|
|
114
|
+
Tempest::Commands::Tui.avatar_cache_dir(env)
|
|
215
115
|
end
|
|
216
116
|
|
|
217
117
|
def opener_for(env:, system_proc: Kernel.method(:system))
|
|
218
|
-
|
|
219
|
-
return Tempest::REPL::Runner::DEFAULT_OPENER if cmd.nil? || cmd.empty?
|
|
220
|
-
->(url) { system_proc.call(cmd, url) }
|
|
118
|
+
Tempest::Commands::Tui.opener_for(env: env, system_proc: system_proc)
|
|
221
119
|
end
|
|
222
120
|
|
|
223
|
-
VALID_FEED_MODES = %i[home self].freeze
|
|
224
|
-
|
|
225
121
|
def feed_mode(argv:, env: {})
|
|
226
|
-
|
|
227
|
-
raw = flag || env["TEMPEST_FEED"] || "home"
|
|
228
|
-
|
|
229
|
-
mode = raw.to_sym
|
|
230
|
-
raise ArgumentError, "invalid --feed value: #{raw.inspect} (must be home|self)" \
|
|
231
|
-
unless VALID_FEED_MODES.include?(mode)
|
|
232
|
-
mode
|
|
122
|
+
Tempest::Commands::Tui.feed_mode(argv: argv, env: env)
|
|
233
123
|
end
|
|
234
124
|
|
|
235
|
-
# Decides what the Jetstream subscription should look like for a freshly
|
|
236
|
-
# signed-in session. In :self mode we only watch the user's own DID (the
|
|
237
|
-
# historical earthquake-style "echo my posts" UX). In :home mode we fetch
|
|
238
|
-
# the user's follows from AppView and let Subscription decide between
|
|
239
|
-
# server-side wantedDids filtering and a firehose+client-filter fallback.
|
|
240
|
-
# When a handle_resolver is provided, follow handles are seeded so the
|
|
241
|
-
# live feed can render @handle without an extra getProfile roundtrip.
|
|
242
125
|
def build_subscription(mode:, session:, client:, handle_resolver: nil, stdout: nil)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
stdout&.puts "[tempest] fetching follows..."
|
|
248
|
-
follows = Tempest::Follows.fetch(client, actor: session.did)
|
|
249
|
-
follows.each { |f| handle_resolver&.seed(f[:did], f[:handle]) }
|
|
250
|
-
plan = Tempest::Jetstream::Subscription.build(self_did: session.did, follows: follows)
|
|
251
|
-
if plan.filter
|
|
252
|
-
stdout&.puts "[tempest] following #{follows.length} accounts (exceeds 10000 cap; using firehose+client-filter)"
|
|
253
|
-
else
|
|
254
|
-
stdout&.puts "[tempest] following #{follows.length} accounts"
|
|
255
|
-
end
|
|
256
|
-
plan
|
|
257
|
-
else
|
|
258
|
-
raise ArgumentError, "unknown feed mode: #{mode.inspect}"
|
|
259
|
-
end
|
|
126
|
+
Tempest::Commands::Tui.build_subscription(
|
|
127
|
+
mode: mode, session: session, client: client,
|
|
128
|
+
handle_resolver: handle_resolver, stdout: stdout,
|
|
129
|
+
)
|
|
260
130
|
end
|
|
261
131
|
|
|
262
132
|
def attach_store(session, store, identifier)
|
|
263
|
-
session
|
|
264
|
-
session.on_change = ->(s) { store.save(s, identifier: s.identifier || identifier) }
|
|
133
|
+
Tempest::Commands::Tui.attach_store(session, store, identifier)
|
|
265
134
|
end
|
|
266
135
|
|
|
267
136
|
def help_text
|
|
268
|
-
|
|
269
|
-
Usage: tempest [options]
|
|
270
|
-
|
|
271
|
-
Options:
|
|
272
|
-
-h, --help Show this help
|
|
273
|
-
-v, --version Show version
|
|
274
|
-
--no-stream Disable the auto-started Jetstream feed
|
|
275
|
-
--feed=MODE Choose what the live feed subscribes to:
|
|
276
|
-
home (default) Your follows + your own posts
|
|
277
|
-
self Only your own posts (legacy echo mode)
|
|
278
|
-
|
|
279
|
-
Environment (required only when no cached session is available):
|
|
280
|
-
TEMPEST_IDENTIFIER Your handle (e.g. asonas.bsky.social)
|
|
281
|
-
TEMPEST_APP_PASSWORD An app password generated in Bluesky settings
|
|
282
|
-
TEMPEST_PDS_HOST Override PDS host (default: https://bsky.social)
|
|
283
|
-
TEMPEST_AUTH_FACTOR_TOKEN
|
|
284
|
-
Pre-supply an email sign-in code (rarely needed; the CLI will
|
|
285
|
-
prompt interactively when Bluesky asks for one)
|
|
286
|
-
TEMPEST_NO_STREAM Set to 1 to disable the auto-started Jetstream feed
|
|
287
|
-
TEMPEST_OPEN_CMD Command used to open URLs when :open $LX is invoked
|
|
288
|
-
(default: "open"). The URL is passed as the single
|
|
289
|
-
argument after the command.
|
|
290
|
-
TEMPEST_SESSION_PATH Override the session cache path (default:
|
|
291
|
-
$XDG_CONFIG_HOME/tempest/session.json or
|
|
292
|
-
~/.config/tempest/session.json). The cache holds refreshed
|
|
293
|
-
tokens so the email sign-in code is only requested once.
|
|
294
|
-
TEMPEST_CURSOR_PATH Override the Jetstream cursor cache path (default:
|
|
295
|
-
$XDG_CONFIG_HOME/tempest/cursor.json). Holds the last-seen
|
|
296
|
-
time_us so a restart can replay missed events.
|
|
297
|
-
TEMPEST_FEED "home" (default) or "self"; equivalent to --feed.
|
|
298
|
-
TEMPEST_DEBUG_LOG Path to a debug log file. When set, the live-stream
|
|
299
|
-
component writes timestamped state transitions to this
|
|
300
|
-
file (rotated daily). Unset by default — no file is
|
|
301
|
-
created and no output is produced.
|
|
302
|
-
TEMPEST_DEBUG_LOG_LEVEL
|
|
303
|
-
DEBUG | INFO (default) | WARN. Overrides the log
|
|
304
|
-
verbosity when TEMPEST_DEBUG_LOG is enabled.
|
|
305
|
-
TEMPEST_WATCHDOG_THRESHOLD
|
|
306
|
-
Seconds without a Jetstream event before the watchdog
|
|
307
|
-
forces a reconnect (default: 90).
|
|
308
|
-
TEMPEST_WATCHDOG_INTERVAL
|
|
309
|
-
Seconds between watchdog checks (default: 30).
|
|
310
|
-
HELP
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
# Wraps Reline to fit the input interface expected by REPL::Runner.
|
|
314
|
-
class RelineReader
|
|
315
|
-
def initialize
|
|
316
|
-
require "reline"
|
|
317
|
-
@reline = Reline
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
def readline(prompt)
|
|
321
|
-
@reline.readline(prompt, true)
|
|
322
|
-
end
|
|
137
|
+
Tempest::Commands::Tui.help_text
|
|
323
138
|
end
|
|
324
139
|
end
|
|
325
140
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require_relative "../commands"
|
|
2
|
+
require_relative "../session_store"
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
require_relative "../repl/formatter"
|
|
5
|
+
|
|
6
|
+
module Tempest
|
|
7
|
+
module Commands
|
|
8
|
+
module Base
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
VALID_FORMATS = %i[line json raw].freeze
|
|
12
|
+
|
|
13
|
+
# Loads the cached session and refreshes it. Returns the session on
|
|
14
|
+
# success. On failure (no cache, refresh rejected) writes a single
|
|
15
|
+
# human-readable line to stderr and returns nil; callers translate the
|
|
16
|
+
# nil into exit code 3.
|
|
17
|
+
def authenticate(env:, stderr:, store: nil)
|
|
18
|
+
store ||= Tempest::SessionStore.new(path: Tempest::SessionStore.default_path(env))
|
|
19
|
+
session = store.load(identifier: env["TEMPEST_IDENTIFIER"], pds_host: env["TEMPEST_PDS_HOST"])
|
|
20
|
+
if session.nil?
|
|
21
|
+
stderr.puts "error: no cached session — run `tempest tui` once to sign in"
|
|
22
|
+
return nil
|
|
23
|
+
end
|
|
24
|
+
session.on_change = ->(s) { store.save(s, identifier: s.identifier) }
|
|
25
|
+
begin
|
|
26
|
+
session.refresh!
|
|
27
|
+
rescue Tempest::Error => e
|
|
28
|
+
stderr.puts "error: cached session refresh failed: #{e.message}"
|
|
29
|
+
return nil
|
|
30
|
+
end
|
|
31
|
+
session
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns one of :line, :json, :raw. Callers may override with --format.
|
|
35
|
+
def default_format(stdout:, env:)
|
|
36
|
+
stdout.respond_to?(:tty?) && stdout.tty? ? :line : :json
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Parses --format=NAME from argv (destructive: returns [format, argv_without_flag]).
|
|
40
|
+
# Raises ArgumentError on unknown format names.
|
|
41
|
+
def take_format(argv, default:)
|
|
42
|
+
out = []
|
|
43
|
+
chosen = default
|
|
44
|
+
argv.each do |arg|
|
|
45
|
+
if (m = arg.match(/\A--format=(\S+)\z/))
|
|
46
|
+
sym = m[1].to_sym
|
|
47
|
+
raise ArgumentError, "invalid --format: #{m[1].inspect}" unless VALID_FORMATS.include?(sym)
|
|
48
|
+
chosen = sym
|
|
49
|
+
elsif arg == "--no-color"
|
|
50
|
+
Tempest::REPL::Formatter.color = false if defined?(Tempest::REPL::Formatter)
|
|
51
|
+
else
|
|
52
|
+
out << arg
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
[chosen, out]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def exit_code_for(error)
|
|
59
|
+
case error
|
|
60
|
+
when Tempest::Config::MissingValue then 2
|
|
61
|
+
when Tempest::AuthenticationError then 3
|
|
62
|
+
when Tempest::APIError then 4
|
|
63
|
+
when ArgumentError then 64
|
|
64
|
+
else 1
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
require_relative "../commands"
|
|
2
|
+
require_relative "../commands/base"
|
|
3
|
+
require_relative "../post"
|
|
4
|
+
require_relative "../post_view"
|
|
5
|
+
require_relative "../date_filter"
|
|
6
|
+
require_relative "../handle_lookup"
|
|
7
|
+
require_relative "../output/json_writer"
|
|
8
|
+
require_relative "../output/line_writer"
|
|
9
|
+
|
|
10
|
+
module Tempest
|
|
11
|
+
module Commands
|
|
12
|
+
module Feed
|
|
13
|
+
DEFAULT_LIMIT = 50
|
|
14
|
+
MAX_LIMIT = 100
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def call(argv:, session:, client:, stdout:, stderr:)
|
|
19
|
+
subcommand, rest = argv.first, argv.drop(1)
|
|
20
|
+
unless %w[me timeline author].include?(subcommand)
|
|
21
|
+
stderr.puts "usage: tempest feed me|timeline|author <handle> [opts]"
|
|
22
|
+
return 64
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
opts, positional = parse(rest, stderr: stderr)
|
|
26
|
+
return 64 if opts.nil?
|
|
27
|
+
|
|
28
|
+
nsid, base_query = endpoint_for(subcommand, session: session, positional: positional, client: client)
|
|
29
|
+
if nsid.nil?
|
|
30
|
+
stderr.puts "error: feed author requires a handle or DID"
|
|
31
|
+
return 64
|
|
32
|
+
end
|
|
33
|
+
if opts[:limit] > MAX_LIMIT
|
|
34
|
+
stderr.puts "error: --limit must be <= #{MAX_LIMIT}"
|
|
35
|
+
return 64
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
items = []
|
|
39
|
+
cursor = nil
|
|
40
|
+
max_pages = 5
|
|
41
|
+
pages = 0
|
|
42
|
+
loop do
|
|
43
|
+
query = base_query.merge("limit" => opts[:limit])
|
|
44
|
+
query["cursor"] = cursor if cursor
|
|
45
|
+
response = client.get(nsid, query: query)
|
|
46
|
+
page_items = Array(response["feed"]).map { |entry| entry["post"] }
|
|
47
|
+
items.concat(page_items)
|
|
48
|
+
pages += 1
|
|
49
|
+
cursor = response["cursor"]
|
|
50
|
+
break if cursor.nil? || cursor.empty?
|
|
51
|
+
break if pages >= max_pages
|
|
52
|
+
break unless opts[:since]
|
|
53
|
+
oldest = page_items.last && page_items.last.dig("record", "createdAt")
|
|
54
|
+
break if oldest.nil?
|
|
55
|
+
break if Time.iso8601(oldest) < opts[:since]
|
|
56
|
+
end
|
|
57
|
+
stderr.puts "warning: pagination cap of #{max_pages} pages reached; result truncated" if pages >= max_pages && !cursor.nil? && !cursor.empty?
|
|
58
|
+
|
|
59
|
+
items = filter_by_date(items, opts)
|
|
60
|
+
emit(items, format: opts[:format], stdout: stdout)
|
|
61
|
+
0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parse(argv, stderr:)
|
|
65
|
+
opts = { limit: DEFAULT_LIMIT, since: nil, until_at: nil, format: nil }
|
|
66
|
+
positional = []
|
|
67
|
+
i = 0
|
|
68
|
+
while i < argv.length
|
|
69
|
+
case argv[i]
|
|
70
|
+
when /\A--limit=(\d+)\z/ then opts[:limit] = Regexp.last_match(1).to_i; i += 1
|
|
71
|
+
when "--limit" then opts[:limit] = argv[i + 1].to_i; i += 2
|
|
72
|
+
when /\A--since=(.+)\z/ then opts[:since] = Tempest::DateFilter.parse(Regexp.last_match(1)); i += 1
|
|
73
|
+
when "--since" then opts[:since] = Tempest::DateFilter.parse(argv[i + 1]); i += 2
|
|
74
|
+
when /\A--until=(.+)\z/ then opts[:until_at] = Tempest::DateFilter.parse(Regexp.last_match(1)); i += 1
|
|
75
|
+
when "--until" then opts[:until_at] = Tempest::DateFilter.parse(argv[i + 1]); i += 2
|
|
76
|
+
when /\A--format=(\S+)\z/
|
|
77
|
+
sym = Regexp.last_match(1).to_sym
|
|
78
|
+
unless %i[line json raw].include?(sym)
|
|
79
|
+
stderr.puts "error: invalid --format: #{Regexp.last_match(1).inspect}"
|
|
80
|
+
return [nil, nil]
|
|
81
|
+
end
|
|
82
|
+
opts[:format] = sym
|
|
83
|
+
i += 1
|
|
84
|
+
when "--no-color"
|
|
85
|
+
Tempest::REPL::Formatter.color = false if defined?(Tempest::REPL::Formatter)
|
|
86
|
+
i += 1
|
|
87
|
+
else
|
|
88
|
+
positional << argv[i]; i += 1
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
[opts, positional]
|
|
92
|
+
rescue ArgumentError => e
|
|
93
|
+
stderr.puts "error: #{e.message}"
|
|
94
|
+
[nil, nil]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def endpoint_for(subcommand, session:, positional:, client:)
|
|
98
|
+
case subcommand
|
|
99
|
+
when "me"
|
|
100
|
+
["app.bsky.feed.getAuthorFeed", { "actor" => session.did }]
|
|
101
|
+
when "timeline"
|
|
102
|
+
["app.bsky.feed.getTimeline", {}]
|
|
103
|
+
when "author"
|
|
104
|
+
actor = positional.first
|
|
105
|
+
if actor.nil? || actor.empty?
|
|
106
|
+
return [nil, nil]
|
|
107
|
+
end
|
|
108
|
+
did = Tempest::HandleLookup.resolve(actor, client: client)
|
|
109
|
+
["app.bsky.feed.getAuthorFeed", { "actor" => did }]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def filter_by_date(items, opts)
|
|
114
|
+
return items if opts[:since].nil? && opts[:until_at].nil?
|
|
115
|
+
items.select do |it|
|
|
116
|
+
ts = it.dig("record", "createdAt")
|
|
117
|
+
t = Time.iso8601(ts)
|
|
118
|
+
(opts[:since].nil? || t >= opts[:since]) && (opts[:until_at].nil? || t < opts[:until_at])
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def emit(items, format:, stdout:)
|
|
123
|
+
format ||= stdout.respond_to?(:tty?) && stdout.tty? ? :line : :json
|
|
124
|
+
case format
|
|
125
|
+
when :json
|
|
126
|
+
views = items.map { |i| Tempest::PostView.from_feed_view(i) }
|
|
127
|
+
Tempest::Output::JsonWriter.new(stdout).write_posts(views)
|
|
128
|
+
when :line
|
|
129
|
+
posts = items.map { |i| Tempest::Post.from_feed_view(i) }
|
|
130
|
+
Tempest::Output::LineWriter.new(stdout).write_posts(posts)
|
|
131
|
+
when :raw
|
|
132
|
+
Tempest::Output::JsonWriter.new(stdout).write_raw({ "feed" => items.map { |i| { "post" => i } } })
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|