tempest-rb 0.1.1 → 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/cli.rb +69 -266
- 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/screen.rb +57 -0
- data/lib/tempest/version.rb +1 -1
- 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/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,307 +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
|
-
reauth: build_reauth(env, stdout, stdin, session_factory),
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
begin
|
|
107
|
-
runner.bootstrap_timeline
|
|
108
|
-
|
|
109
|
-
if stream_default_on?(argv, env)
|
|
110
|
-
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)
|
|
111
42
|
end
|
|
43
|
+
else
|
|
44
|
+
stderr.puts "unknown command: #{head.inspect}"
|
|
45
|
+
64
|
|
46
|
+
end
|
|
47
|
+
end
|
|
112
48
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
+
)
|
|
119
69
|
end
|
|
120
|
-
rescue Tempest::Config::MissingValue => e
|
|
121
|
-
stderr.puts "configuration error: #{e.message}"
|
|
122
|
-
stderr.puts "Set TEMPEST_IDENTIFIER and TEMPEST_APP_PASSWORD before launching."
|
|
123
|
-
2
|
|
124
|
-
rescue Tempest::AuthenticationError => e
|
|
125
|
-
stderr.puts "authentication failed: #{e.message}"
|
|
126
|
-
3
|
|
127
|
-
rescue Tempest::Error => e
|
|
128
|
-
stderr.puts "error: #{e.message}"
|
|
129
|
-
1
|
|
130
70
|
end
|
|
131
71
|
|
|
132
|
-
|
|
133
|
-
identifier_hint = nil_if_empty(env["TEMPEST_IDENTIFIER"])
|
|
134
|
-
pds_host_hint = nil_if_empty(env["TEMPEST_PDS_HOST"])
|
|
72
|
+
VALID_FEED_MODES = Tempest::Commands::Tui::VALID_FEED_MODES
|
|
135
73
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
begin
|
|
139
|
-
existing.refresh!
|
|
140
|
-
return existing
|
|
141
|
-
rescue Tempest::Error => e
|
|
142
|
-
existing.on_change = nil
|
|
143
|
-
stdout.puts "[tempest] cached session refresh failed: #{e.message}"
|
|
144
|
-
stdout.puts "[tempest] cache kept at #{store.path}; falling back to TEMPEST_IDENTIFIER/TEMPEST_APP_PASSWORD"
|
|
145
|
-
end
|
|
146
|
-
end
|
|
74
|
+
# Forwarding delegates — keep Tempest::CLI.* callable so existing tests
|
|
75
|
+
# do not need modification. All logic lives in Tempest::Commands::Tui.
|
|
147
76
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
attach_store(session, store, config.identifier)
|
|
151
|
-
store.save(session, identifier: config.identifier)
|
|
152
|
-
session
|
|
77
|
+
def sign_in(env, stdout, stdin, session_factory, store:)
|
|
78
|
+
Tempest::Commands::Tui.sign_in(env, stdout, stdin, session_factory, store: store)
|
|
153
79
|
end
|
|
154
80
|
|
|
155
81
|
def nil_if_empty(value)
|
|
156
|
-
|
|
82
|
+
Tempest::Commands::Tui.nil_if_empty(value)
|
|
157
83
|
end
|
|
158
84
|
|
|
159
|
-
# Builds the proc REPL::Runner uses to honour `:relogin`. The lambda
|
|
160
|
-
# re-reads credentials from `env` on each call (so a user can update env
|
|
161
|
-
# in-process if needed) and goes through the same 2FA prompt path as
|
|
162
|
-
# initial sign-in.
|
|
163
85
|
def build_reauth(env, stdout, stdin, session_factory)
|
|
164
|
-
|
|
165
|
-
config = Tempest::Config.from_env(env)
|
|
166
|
-
create_with_2fa(config, env, stdout, stdin, session_factory)
|
|
167
|
-
end
|
|
86
|
+
Tempest::Commands::Tui.build_reauth(env, stdout, stdin, session_factory)
|
|
168
87
|
end
|
|
169
88
|
|
|
170
89
|
def create_with_2fa(config, env, stdout, stdin, session_factory)
|
|
171
|
-
|
|
172
|
-
session_factory.call(config, auth_factor_token: token)
|
|
173
|
-
rescue Tempest::AuthenticationError => e
|
|
174
|
-
raise unless e.code == "AuthFactorTokenRequired" && token.nil?
|
|
175
|
-
|
|
176
|
-
stdout.puts "Bluesky sent a sign-in code to your email. Enter it below."
|
|
177
|
-
stdout.print "code: "
|
|
178
|
-
stdout.flush
|
|
179
|
-
code = stdin.gets&.strip
|
|
180
|
-
raise Tempest::AuthenticationError.new("sign-in cancelled (no code entered)", code: e.code) if code.nil? || code.empty?
|
|
181
|
-
|
|
182
|
-
session_factory.call(config, auth_factor_token: code)
|
|
90
|
+
Tempest::Commands::Tui.create_with_2fa(config, env, stdout, stdin, session_factory)
|
|
183
91
|
end
|
|
184
92
|
|
|
185
93
|
def stream_default_on?(argv, env)
|
|
186
|
-
|
|
187
|
-
return false if env["TEMPEST_NO_STREAM"] == "1"
|
|
188
|
-
true
|
|
94
|
+
Tempest::Commands::Tui.stream_default_on?(argv, env)
|
|
189
95
|
end
|
|
190
96
|
|
|
191
97
|
def cursor_store(env)
|
|
192
|
-
Tempest::
|
|
98
|
+
Tempest::Commands::Tui.cursor_store(env)
|
|
193
99
|
end
|
|
194
100
|
|
|
195
|
-
# Returns a Logger configured from TEMPEST_DEBUG_LOG (path) and
|
|
196
|
-
# TEMPEST_DEBUG_LOG_LEVEL. When TEMPEST_DEBUG_LOG is unset, the returned
|
|
197
|
-
# logger writes to IO::NULL at FATAL level so call sites can log
|
|
198
|
-
# unconditionally without producing files or output.
|
|
199
101
|
def build_debug_logger(env)
|
|
200
|
-
Tempest::
|
|
102
|
+
Tempest::Commands::Tui.build_debug_logger(env)
|
|
201
103
|
end
|
|
202
104
|
|
|
203
|
-
# Parses TEMPEST_WATCHDOG_THRESHOLD / TEMPEST_WATCHDOG_INTERVAL into the
|
|
204
|
-
# keyword-hash shape Watchdog expects. Raises ArgumentError on garbage so
|
|
205
|
-
# a typo in env config fails loudly rather than silently degrading.
|
|
206
105
|
def watchdog_options(env)
|
|
207
|
-
|
|
208
|
-
interval = env["TEMPEST_WATCHDOG_INTERVAL"]
|
|
209
|
-
{
|
|
210
|
-
threshold_seconds: threshold ? Integer(threshold) :
|
|
211
|
-
Tempest::Jetstream::Watchdog::DEFAULT_THRESHOLD_SECONDS,
|
|
212
|
-
interval_seconds: interval ? Integer(interval) :
|
|
213
|
-
Tempest::Jetstream::Watchdog::DEFAULT_INTERVAL_SECONDS,
|
|
214
|
-
}
|
|
106
|
+
Tempest::Commands::Tui.watchdog_options(env)
|
|
215
107
|
end
|
|
216
108
|
|
|
217
109
|
def timeline_store(env)
|
|
218
|
-
Tempest::
|
|
110
|
+
Tempest::Commands::Tui.timeline_store(env)
|
|
219
111
|
end
|
|
220
112
|
|
|
221
113
|
def avatar_cache_dir(env)
|
|
222
|
-
|
|
223
|
-
return override if override && !override.empty?
|
|
224
|
-
base = env["XDG_CACHE_HOME"]
|
|
225
|
-
base = File.join(env["HOME"] || Dir.home, ".cache") if base.nil? || base.empty?
|
|
226
|
-
File.join(base, "tempest", "avatars")
|
|
114
|
+
Tempest::Commands::Tui.avatar_cache_dir(env)
|
|
227
115
|
end
|
|
228
116
|
|
|
229
117
|
def opener_for(env:, system_proc: Kernel.method(:system))
|
|
230
|
-
|
|
231
|
-
return Tempest::REPL::Runner::DEFAULT_OPENER if cmd.nil? || cmd.empty?
|
|
232
|
-
->(url) { system_proc.call(cmd, url) }
|
|
118
|
+
Tempest::Commands::Tui.opener_for(env: env, system_proc: system_proc)
|
|
233
119
|
end
|
|
234
120
|
|
|
235
|
-
VALID_FEED_MODES = %i[home self].freeze
|
|
236
|
-
|
|
237
121
|
def feed_mode(argv:, env: {})
|
|
238
|
-
|
|
239
|
-
raw = flag || env["TEMPEST_FEED"] || "home"
|
|
240
|
-
|
|
241
|
-
mode = raw.to_sym
|
|
242
|
-
raise ArgumentError, "invalid --feed value: #{raw.inspect} (must be home|self)" \
|
|
243
|
-
unless VALID_FEED_MODES.include?(mode)
|
|
244
|
-
mode
|
|
122
|
+
Tempest::Commands::Tui.feed_mode(argv: argv, env: env)
|
|
245
123
|
end
|
|
246
124
|
|
|
247
|
-
# Decides what the Jetstream subscription should look like for a freshly
|
|
248
|
-
# signed-in session. In :self mode we only watch the user's own DID (the
|
|
249
|
-
# historical earthquake-style "echo my posts" UX). In :home mode we fetch
|
|
250
|
-
# the user's follows from AppView and let Subscription decide between
|
|
251
|
-
# server-side wantedDids filtering and a firehose+client-filter fallback.
|
|
252
|
-
# When a handle_resolver is provided, follow handles are seeded so the
|
|
253
|
-
# live feed can render @handle without an extra getProfile roundtrip.
|
|
254
125
|
def build_subscription(mode:, session:, client:, handle_resolver: nil, stdout: nil)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
stdout&.puts "[tempest] fetching follows..."
|
|
260
|
-
follows = Tempest::Follows.fetch(client, actor: session.did)
|
|
261
|
-
follows.each { |f| handle_resolver&.seed(f[:did], f[:handle]) }
|
|
262
|
-
plan = Tempest::Jetstream::Subscription.build(self_did: session.did, follows: follows)
|
|
263
|
-
if plan.filter
|
|
264
|
-
stdout&.puts "[tempest] following #{follows.length} accounts (exceeds 10000 cap; using firehose+client-filter)"
|
|
265
|
-
else
|
|
266
|
-
stdout&.puts "[tempest] following #{follows.length} accounts"
|
|
267
|
-
end
|
|
268
|
-
plan
|
|
269
|
-
else
|
|
270
|
-
raise ArgumentError, "unknown feed mode: #{mode.inspect}"
|
|
271
|
-
end
|
|
126
|
+
Tempest::Commands::Tui.build_subscription(
|
|
127
|
+
mode: mode, session: session, client: client,
|
|
128
|
+
handle_resolver: handle_resolver, stdout: stdout,
|
|
129
|
+
)
|
|
272
130
|
end
|
|
273
131
|
|
|
274
132
|
def attach_store(session, store, identifier)
|
|
275
|
-
session
|
|
276
|
-
session.on_change = ->(s) { store.save(s, identifier: s.identifier || identifier) }
|
|
133
|
+
Tempest::Commands::Tui.attach_store(session, store, identifier)
|
|
277
134
|
end
|
|
278
135
|
|
|
279
136
|
def help_text
|
|
280
|
-
|
|
281
|
-
Usage: tempest [options]
|
|
282
|
-
|
|
283
|
-
Options:
|
|
284
|
-
-h, --help Show this help
|
|
285
|
-
-v, --version Show version
|
|
286
|
-
--no-stream Disable the auto-started Jetstream feed
|
|
287
|
-
--feed=MODE Choose what the live feed subscribes to:
|
|
288
|
-
home (default) Your follows + your own posts
|
|
289
|
-
self Only your own posts (legacy echo mode)
|
|
290
|
-
|
|
291
|
-
Environment (required only when no cached session is available):
|
|
292
|
-
TEMPEST_IDENTIFIER Your handle (e.g. asonas.bsky.social)
|
|
293
|
-
TEMPEST_APP_PASSWORD An app password generated in Bluesky settings
|
|
294
|
-
TEMPEST_PDS_HOST Override PDS host (default: https://bsky.social)
|
|
295
|
-
TEMPEST_AUTH_FACTOR_TOKEN
|
|
296
|
-
Pre-supply an email sign-in code (rarely needed; the CLI will
|
|
297
|
-
prompt interactively when Bluesky asks for one)
|
|
298
|
-
TEMPEST_NO_STREAM Set to 1 to disable the auto-started Jetstream feed
|
|
299
|
-
TEMPEST_OPEN_CMD Command used to open URLs when :open $LX is invoked
|
|
300
|
-
(default: "open"). The URL is passed as the single
|
|
301
|
-
argument after the command.
|
|
302
|
-
TEMPEST_SESSION_PATH Override the session cache path (default:
|
|
303
|
-
$XDG_CONFIG_HOME/tempest/session.json or
|
|
304
|
-
~/.config/tempest/session.json). The cache holds refreshed
|
|
305
|
-
tokens so the email sign-in code is only requested once.
|
|
306
|
-
TEMPEST_CURSOR_PATH Override the Jetstream cursor cache path (default:
|
|
307
|
-
$XDG_CONFIG_HOME/tempest/cursor.json). Holds the last-seen
|
|
308
|
-
time_us so a restart can replay missed events.
|
|
309
|
-
TEMPEST_FEED "home" (default) or "self"; equivalent to --feed.
|
|
310
|
-
TEMPEST_DEBUG_LOG Path to a debug log file. When set, the live-stream
|
|
311
|
-
component writes timestamped state transitions to this
|
|
312
|
-
file (rotated daily). Unset by default — no file is
|
|
313
|
-
created and no output is produced.
|
|
314
|
-
TEMPEST_DEBUG_LOG_LEVEL
|
|
315
|
-
DEBUG | INFO (default) | WARN. Overrides the log
|
|
316
|
-
verbosity when TEMPEST_DEBUG_LOG is enabled.
|
|
317
|
-
TEMPEST_WATCHDOG_THRESHOLD
|
|
318
|
-
Seconds without a Jetstream event before the watchdog
|
|
319
|
-
forces a reconnect (default: 90).
|
|
320
|
-
TEMPEST_WATCHDOG_INTERVAL
|
|
321
|
-
Seconds between watchdog checks (default: 30).
|
|
322
|
-
HELP
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
# Wraps Reline to fit the input interface expected by REPL::Runner.
|
|
326
|
-
class RelineReader
|
|
327
|
-
def initialize
|
|
328
|
-
require "reline"
|
|
329
|
-
@reline = Reline
|
|
330
|
-
end
|
|
331
|
-
|
|
332
|
-
def readline(prompt)
|
|
333
|
-
@reline.readline(prompt, true)
|
|
334
|
-
end
|
|
137
|
+
Tempest::Commands::Tui.help_text
|
|
335
138
|
end
|
|
336
139
|
end
|
|
337
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
|