tempest-rb 0.2.1 → 0.3.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98586ffebeb3d2fecb23743e6fd174b33852fc948f87d89a854d7d966b6876dc
4
- data.tar.gz: 4860e84c81350e30f25f440f95288dc6b9848356d5d74ed3ed8c0747d79bcfa2
3
+ metadata.gz: 828dcb07d7565737bbe9b37ef7ca876fe6136dd7aaf308df43ec8c8fac9be825
4
+ data.tar.gz: 3a32fde6e7693ba99acea0482875cc9919e8e4969dd201404edeeb9e01d89193
5
5
  SHA512:
6
- metadata.gz: 1d8958487876f35934dd7c1e5d960fa287cba393c7aa8cac0af5a468a14dfebf5fc9fe91fc735ce6ae90ea3ee5b10e8c5338c96100d6a0dd5e87262e0ec8d682
7
- data.tar.gz: f095b78d7226913e275834cce66012414d493e91bd4515188de12d20a03aa447eab1569d3cb23f8456d394548b3f40f5aa507585e4940e180ca2b8d5b7e18bd5
6
+ metadata.gz: 8eb5b81c5d05843c461fcf2352b8be5351e95dbb4eebd1359ba0c2ee2380400de36bd627fab9d96db685f44bb80cf39c3c914c1183ee895481f33d7d9b3a4b84
7
+ data.tar.gz: 1b16bd98ef48212760133e3b87bb877a9f3a7c0e1044a86f541219cf32871fad921d88074ac7ca52e1c42ed69b5f8d1fedbd5f7e762d4d3c21d22c45be88ad3c
data/README.md CHANGED
@@ -65,7 +65,47 @@ export TEMPEST_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx"
65
65
  tempest
66
66
  ```
67
67
 
68
- The first sign-in may prompt for the email code Bluesky sends as a second factor. After a successful sign-in the session is cached at `$XDG_CONFIG_HOME/tempest/session.json` (defaults to `~/.config/tempest/session.json`), and subsequent launches refresh tokens silently.
68
+ The first sign-in may prompt for the email code Bluesky sends as a second factor. After a successful sign-in the session is cached at `$XDG_CONFIG_HOME/tempest/accounts/<did>/session.json` (defaults to `~/.config/tempest/accounts/<did>/session.json`), and subsequent launches refresh tokens silently.
69
+
70
+ ### Multiple accounts
71
+
72
+ `tempest` keeps each account's session, Jetstream cursor, and timeline snapshot under `~/.config/tempest/accounts/<did>/`. A top-level `accounts.json` index records which account is the default and which alternate accounts are known to the client.
73
+
74
+ To add a second account, run `tempest login` and supply the handle and app password interactively:
75
+
76
+ ```sh
77
+ tempest login
78
+ # identifier: tempest-dev.bsky.social
79
+ # app password: ****
80
+ # signing in...
81
+ # logged in as @tempest-dev.bsky.social (did:plc:...)
82
+ ```
83
+
84
+ List the accounts known to `tempest`. The default is marked with `*`.
85
+
86
+ ```sh
87
+ tempest accounts list
88
+ # * @asonas.bsky.social (did:plc:abcdef) https://bsky.social added 2026-05-18
89
+ # @tempest-dev.bsky.social (did:plc:ghijkl) https://bsky.social added 2026-05-18
90
+ ```
91
+
92
+ Switch the default account:
93
+
94
+ ```sh
95
+ tempest accounts set-default tempest-dev.bsky.social
96
+ ```
97
+
98
+ Pick the account just for one invocation with `--user`. The flag accepts a handle or a DID and works on every subcommand:
99
+
100
+ ```sh
101
+ tempest --user tempest-dev.bsky.social
102
+ tempest --user did:plc:ghijkl feed me --format=json
103
+ tempest post "from the dev account" --user tempest-dev.bsky.social
104
+ ```
105
+
106
+ For bots and scripts where stability matters, pass the DID directly. Handles can be changed by their owner; DIDs do not.
107
+
108
+ If you have been using a single-account installation of an earlier `tempest` release, the legacy `~/.config/tempest/session.json` is migrated to the new per-DID layout on the next start. The migration is one-shot, idempotent, and prints a one-line notice to stderr when it runs.
69
109
 
70
110
  ### REPL commands
71
111
 
@@ -88,18 +128,20 @@ Each post in the timeline is prefixed with a short `$XX` id, and URLs found insi
88
128
  |-------------------|--------------------------------------------------------------|
89
129
  | `-h`, `--help` | Show CLI help |
90
130
  | `-v`, `--version` | Show version |
131
+ | `--user <h\|did>` | Pick which account to act as (default: the entry marked default in `accounts.json`). Works on every subcommand except `login` / `accounts` |
91
132
  | `--no-stream` | Disable the auto-started Jetstream feed |
92
133
  | `--feed=MODE` | `home` (default, your follows + your own posts) or `self` (only your own posts) |
93
134
 
94
135
  ### Non-interactive CLI
95
136
 
96
- Once you have signed in once with `tempest tui`, you can call the CLI from scripts and tools:
137
+ Once you have signed in once with `tempest tui` or `tempest login`, you can call the CLI from scripts and tools:
97
138
 
98
139
  ```sh
99
140
  tempest whoami --json
100
141
  tempest post "今日もよろしくお願いします"
101
142
  tempest feed me --since today --format json | jq '.text'
102
143
  tempest feed author asonas.bsky.social --limit 20
144
+ tempest --user tempest-dev.bsky.social post "from the dev account"
103
145
  ```
104
146
 
105
147
  `--format=json` emits newline-delimited JSON; one post per line. The schema is documented in `lib/tempest/post_view.rb`.
@@ -108,28 +150,26 @@ tempest feed author asonas.bsky.social --limit 20
108
150
 
109
151
  `--format=line` (default when stdout is a TTY) prints the same single-line representation as the TUI scroll buffer.
110
152
 
111
- The non-interactive subcommands require a cached session on disk. If your cache is missing or expired, run `tempest tui` once to refresh it.
153
+ The non-interactive subcommands require a cached session on disk for the resolved account. If a session is missing or expired, run `tempest login` (or `tempest tui` for the default account) to refresh it.
112
154
 
113
155
  ### Environment variables
114
156
 
115
157
  | Variable | Purpose |
116
158
  |-----------------------------|-------------------------------------------------------------------------|
117
- | `TEMPEST_IDENTIFIER` | Your handle, e.g. `asonas.bsky.social` |
118
- | `TEMPEST_APP_PASSWORD` | An app password generated in Bluesky settings |
119
- | `TEMPEST_PDS_HOST` | Override PDS host (default `https://bsky.social`) |
159
+ | `TEMPEST_IDENTIFIER` | Your handle, e.g. `asonas.bsky.social`. Only consulted on first run, when `accounts.json` is absent. Use `tempest login` to add accounts thereafter. |
160
+ | `TEMPEST_APP_PASSWORD` | Same precondition as `TEMPEST_IDENTIFIER` |
120
161
  | `TEMPEST_AUTH_FACTOR_TOKEN` | Pre-supply an email sign-in code; usually unnecessary |
121
162
  | `TEMPEST_NO_STREAM` | Set to `1` to disable the auto-started Jetstream feed |
122
163
  | `TEMPEST_FEED` | `home` (default) or `self`; equivalent to `--feed` |
123
164
  | `TEMPEST_OPEN_CMD` | Command used by `:open $LX` to open URLs (default `open`); URL is passed as the single argument |
124
- | `TEMPEST_SESSION_PATH` | Override the session cache path |
125
- | `TEMPEST_CURSOR_PATH` | Override the Jetstream cursor cache path |
126
- | `TEMPEST_TIMELINE_PATH` | Override the timeline snapshot cache path |
127
165
  | `TEMPEST_DEBUG_LOG` | Path to a debug log file (unset by default; see Diagnostics) |
128
166
  | `TEMPEST_DEBUG_LOG_LEVEL` | `DEBUG`, `INFO` (default), or `WARN` |
129
167
  | `TEMPEST_WATCHDOG_THRESHOLD`| Seconds without a Jetstream event before a forced reconnect (default 90) |
130
168
  | `TEMPEST_WATCHDOG_INTERVAL` | Seconds between watchdog checks (default 30) |
131
169
  | `NO_COLOR` | Disable ANSI colors when set to any non-empty value |
132
170
 
171
+ The `TEMPEST_SESSION_PATH`, `TEMPEST_CURSOR_PATH`, `TEMPEST_TIMELINE_PATH`, and `TEMPEST_PDS_HOST` variables are no longer honored after 0.3.0. The legacy `TEMPEST_SESSION_PATH` is read once at migration time so that an existing override still maps cleanly into the new per-DID layout.
172
+
133
173
  ## Diagnostics
134
174
 
135
175
  Set `TEMPEST_DEBUG_LOG` to a writable path and `tempest` will append timestamped notes about every Jetstream state transition to that file (rotated daily). When the variable is unset no file is created and the runtime behaves exactly as before. Example: `TEMPEST_DEBUG_LOG=~/tempest-debug.log tempest`.
@@ -0,0 +1,60 @@
1
+ require_relative "../tempest"
2
+
3
+ module Tempest
4
+ # Centralizes filesystem layout for tempest's per-account storage.
5
+ #
6
+ # Legacy paths (`legacy_*`) honor `TEMPEST_SESSION_PATH` /
7
+ # `TEMPEST_CURSOR_PATH` / `TEMPEST_TIMELINE_PATH` for migration purposes only;
8
+ # the new per-DID paths cannot be overridden via env.
9
+ module AccountPaths
10
+ module_function
11
+
12
+ def config_base(env = ENV)
13
+ base = env["XDG_CONFIG_HOME"]
14
+ base = File.join(env["HOME"].to_s, ".config") if base.nil? || base.empty?
15
+ File.join(base, "tempest")
16
+ end
17
+
18
+ def legacy_session_path(env = ENV)
19
+ explicit = env["TEMPEST_SESSION_PATH"]
20
+ return explicit if explicit && !explicit.empty?
21
+ File.join(config_base(env), "session.json")
22
+ end
23
+
24
+ def legacy_cursor_path(env = ENV)
25
+ explicit = env["TEMPEST_CURSOR_PATH"]
26
+ return explicit if explicit && !explicit.empty?
27
+ File.join(config_base(env), "cursor.json")
28
+ end
29
+
30
+ def legacy_timeline_path(env = ENV)
31
+ explicit = env["TEMPEST_TIMELINE_PATH"]
32
+ return explicit if explicit && !explicit.empty?
33
+ File.join(config_base(env), "timeline.json")
34
+ end
35
+
36
+ def accounts_dir(env = ENV)
37
+ File.join(config_base(env), "accounts")
38
+ end
39
+
40
+ def account_dir(env = ENV, did:)
41
+ File.join(accounts_dir(env), did)
42
+ end
43
+
44
+ def session_path(env = ENV, did:)
45
+ File.join(account_dir(env, did: did), "session.json")
46
+ end
47
+
48
+ def cursor_path(env = ENV, did:)
49
+ File.join(account_dir(env, did: did), "cursor.json")
50
+ end
51
+
52
+ def timeline_path(env = ENV, did:)
53
+ File.join(account_dir(env, did: did), "timeline.json")
54
+ end
55
+
56
+ def accounts_json_path(env = ENV)
57
+ File.join(config_base(env), "accounts.json")
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,65 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "time"
4
+
5
+ require_relative "../tempest"
6
+ require_relative "account_paths"
7
+ require_relative "accounts_store"
8
+
9
+ module Tempest
10
+ # One-shot, idempotent migration that converts the legacy single-account
11
+ # layout (<base>/session.json etc.) into the per-DID layout introduced in
12
+ # 0.3.0 (<base>/accounts/<did>/session.json + <base>/accounts.json).
13
+ #
14
+ # Runs at the top of every command entry point. The presence of accounts.json
15
+ # is the completion marker; partial failures recover automatically thanks to
16
+ # `File.rename`'s atomicity and AccountsStore's orphan self-heal.
17
+ module AccountsMigration
18
+ module_function
19
+
20
+ # Returns one of :migrated, :skipped, :noop. :migrated is the only state
21
+ # that produces stderr output.
22
+ def run(env: ENV, stderr: $stderr, logger: nil)
23
+ accounts_json = Tempest::AccountPaths.accounts_json_path(env)
24
+ return :skipped if File.exist?(accounts_json)
25
+
26
+ legacy_session = Tempest::AccountPaths.legacy_session_path(env)
27
+ return :noop unless File.exist?(legacy_session)
28
+
29
+ data = JSON.parse(File.read(legacy_session))
30
+ did = data.fetch("did")
31
+ handle = data.fetch("handle")
32
+ identifier = data["identifier"] || handle
33
+ pds_host = data["pds_host"] || Tempest::Config::DEFAULT_PDS_HOST
34
+
35
+ account_dir = Tempest::AccountPaths.account_dir(env, did: did)
36
+ FileUtils.mkdir_p(Tempest::AccountPaths.accounts_dir(env), mode: 0o700)
37
+ FileUtils.mkdir_p(account_dir, mode: 0o700)
38
+
39
+ File.rename(legacy_session, Tempest::AccountPaths.session_path(env, did: did))
40
+
41
+ legacy_cursor = Tempest::AccountPaths.legacy_cursor_path(env)
42
+ if File.exist?(legacy_cursor)
43
+ File.rename(legacy_cursor, Tempest::AccountPaths.cursor_path(env, did: did))
44
+ end
45
+
46
+ legacy_timeline = Tempest::AccountPaths.legacy_timeline_path(env)
47
+ if File.exist?(legacy_timeline)
48
+ File.rename(legacy_timeline, Tempest::AccountPaths.timeline_path(env, did: did))
49
+ end
50
+
51
+ store = Tempest::AccountsStore.new(env: env, logger: logger)
52
+ store.add_account(
53
+ did: did,
54
+ handle: handle,
55
+ identifier: identifier,
56
+ pds_host: pds_host,
57
+ added_at: Time.now.utc,
58
+ )
59
+
60
+ logger&.info("accounts", event: "migrated", did: did, account_dir: account_dir)
61
+ stderr.puts "[tempest] migrated session to #{account_dir}/"
62
+ :migrated
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,205 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "set"
4
+ require "time"
5
+
6
+ require_relative "../tempest"
7
+ require_relative "account_paths"
8
+ require_relative "config"
9
+ require_relative "session_store"
10
+
11
+ module Tempest
12
+ # Tracks which Bluesky accounts tempest knows about and which one is currently
13
+ # the default. The on-disk representation is `<config_base>/accounts.json`,
14
+ # always rewritten via tmp + rename for atomicity (see `write_atomic`).
15
+ #
16
+ # On construction, also performs an orphan-recovery sweep so that any
17
+ # `accounts/<did>/session.json` left behind by a partial login or migration
18
+ # appears in accounts.json the next time tempest starts.
19
+ class AccountsStore
20
+ SCHEMA_VERSION = 1
21
+
22
+ Account = Data.define(:did, :handle, :identifier, :pds_host, :added_at)
23
+
24
+ def initialize(env: ENV, path: nil, logger: nil)
25
+ @env = env
26
+ @path = path || Tempest::AccountPaths.accounts_json_path(env)
27
+ @logger = logger
28
+ @default = nil
29
+ @accounts = []
30
+ load
31
+ self_heal
32
+ end
33
+
34
+ attr_reader :default, :accounts
35
+
36
+ def resolve(value)
37
+ return nil if value.nil? || value.empty?
38
+ by_did = @accounts.find { |a| a.did == value }
39
+ return by_did if by_did
40
+ @accounts.find { |a| a.handle == value }
41
+ end
42
+
43
+ def set_default(value)
44
+ account = resolve(value)
45
+ raise ArgumentError, "unknown user: #{value}" if account.nil?
46
+ return account.did if @default == account.did
47
+ @default = account.did
48
+ persist
49
+ @logger&.info("accounts", event: "set_default", handle: account.handle, did: account.did)
50
+ account.did
51
+ end
52
+
53
+ def add_account(did:, handle:, identifier:, pds_host:, added_at: Time.now.utc)
54
+ existing = @accounts.find { |a| a.did == did }
55
+ effective_added_at = existing ? existing.added_at : added_at
56
+ replacement = Account.new(
57
+ did: did,
58
+ handle: handle,
59
+ identifier: identifier,
60
+ pds_host: pds_host,
61
+ added_at: effective_added_at,
62
+ )
63
+
64
+ list = @accounts.reject { |a| a.did == did } + [replacement]
65
+ @accounts = list.sort_by(&:added_at).freeze
66
+ @default ||= did
67
+ persist
68
+ replacement
69
+ end
70
+
71
+ def update_handle(did:, handle:)
72
+ target = @accounts.find { |a| a.did == did }
73
+ return if target.nil?
74
+ return if target.handle == handle
75
+
76
+ old_handle = target.handle
77
+ replacement = Account.new(
78
+ did: target.did,
79
+ handle: handle,
80
+ identifier: target.identifier,
81
+ pds_host: target.pds_host,
82
+ added_at: target.added_at,
83
+ )
84
+ list = @accounts.reject { |a| a.did == did } + [replacement]
85
+ @accounts = list.sort_by(&:added_at).freeze
86
+ persist
87
+ @logger&.info("accounts", event: "handle_changed", did: did, old_handle: old_handle, new_handle: handle)
88
+ end
89
+
90
+ private
91
+
92
+ def load
93
+ return unless File.exist?(@path)
94
+
95
+ raw = File.read(@path)
96
+ data = JSON.parse(raw)
97
+ unless data.is_a?(Hash) && data["version"] == SCHEMA_VERSION
98
+ @default = nil
99
+ @accounts = []
100
+ return
101
+ end
102
+
103
+ @default = data["default"]
104
+ @accounts = Array(data["accounts"]).filter_map { |hash| build_account(hash) }
105
+ .sort_by { |a| a.added_at }
106
+ @accounts.freeze
107
+ rescue JSON::ParserError
108
+ @default = nil
109
+ @accounts = []
110
+ end
111
+
112
+ def build_account(hash)
113
+ return nil unless hash.is_a?(Hash)
114
+ did = hash["did"]
115
+ handle = hash["handle"]
116
+ return nil if did.nil? || handle.nil?
117
+
118
+ Account.new(
119
+ did: did,
120
+ handle: handle,
121
+ identifier: hash["identifier"] || handle,
122
+ pds_host: hash["pds_host"] || Tempest::Config::DEFAULT_PDS_HOST,
123
+ added_at: parse_time(hash["added_at"]),
124
+ )
125
+ end
126
+
127
+ def parse_time(value)
128
+ return Time.now.utc if value.nil? || value.to_s.empty?
129
+ Time.iso8601(value)
130
+ rescue ArgumentError
131
+ Time.now.utc
132
+ end
133
+
134
+ def self_heal
135
+ dir = Tempest::AccountPaths.accounts_dir(@env)
136
+ return unless File.directory?(dir)
137
+
138
+ known = @accounts.map(&:did).to_set
139
+ changed = false
140
+ Dir.children(dir).each do |entry|
141
+ did = entry
142
+ next if known.include?(did)
143
+ session_path = Tempest::AccountPaths.session_path(@env, did: did)
144
+ next unless File.exist?(session_path)
145
+
146
+ adopted = adopt_orphan_session(did, session_path)
147
+ if adopted
148
+ @accounts = (@accounts.reject { |a| a.did == did } + [adopted]).sort_by(&:added_at).freeze
149
+ @default ||= did
150
+ changed = true
151
+ @logger&.info("accounts", event: "orphan_recovered", did: did, handle: adopted.handle)
152
+ end
153
+ end
154
+ persist if changed
155
+ end
156
+
157
+ def adopt_orphan_session(did, session_path)
158
+ raw = File.read(session_path)
159
+ data = JSON.parse(raw)
160
+ return nil unless data.is_a?(Hash)
161
+ handle = data["handle"] || data["identifier"]
162
+ return nil if handle.nil?
163
+
164
+ Account.new(
165
+ did: did,
166
+ handle: handle,
167
+ identifier: data["identifier"] || handle,
168
+ pds_host: data["pds_host"] || Tempest::Config::DEFAULT_PDS_HOST,
169
+ added_at: File.mtime(session_path).utc,
170
+ )
171
+ rescue JSON::ParserError
172
+ @logger&.warn("accounts", event: "orphan_malformed", did: did)
173
+ nil
174
+ end
175
+
176
+ def persist
177
+ payload = {
178
+ "version" => SCHEMA_VERSION,
179
+ "default" => @default,
180
+ "accounts" => @accounts.map { |a|
181
+ {
182
+ "did" => a.did,
183
+ "handle" => a.handle,
184
+ "identifier" => a.identifier,
185
+ "pds_host" => a.pds_host,
186
+ "added_at" => a.added_at.utc.iso8601(6),
187
+ }
188
+ },
189
+ }
190
+ write_atomic(JSON.generate(payload))
191
+ end
192
+
193
+ def write_atomic(content)
194
+ FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
195
+ tmp = "#{@path}.tmp.#{Process.pid}"
196
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |io|
197
+ io.write(content)
198
+ end
199
+ File.chmod(0o600, tmp)
200
+ File.rename(tmp, @path)
201
+ ensure
202
+ File.delete(tmp) if tmp && File.exist?(tmp)
203
+ end
204
+ end
205
+ end
data/lib/tempest/cli.rb CHANGED
@@ -5,17 +5,58 @@ require_relative "commands/whoami"
5
5
  require_relative "commands/post"
6
6
  require_relative "commands/feed"
7
7
  require_relative "commands/follow"
8
+ require_relative "commands/login"
9
+ require_relative "commands/accounts"
10
+ require_relative "debug_log"
11
+ require_relative "deprecated_envs"
8
12
  require_relative "xrpc_client"
9
13
 
10
14
  module Tempest
11
15
  module CLI
12
- SUBCOMMANDS = %w[tui post feed whoami follow].freeze
16
+ SUBCOMMANDS = %w[tui post feed whoami follow accounts login].freeze
13
17
 
14
18
  module_function
15
19
 
20
+ # Pulls `--user <name>` / `--user=<name>` out of argv and returns
21
+ # `[user_or_nil, remaining_argv]`. Raises ArgumentError when the flag is
22
+ # present but the value is missing or empty. Multiple occurrences: last one
23
+ # wins.
24
+ def extract_user(argv)
25
+ user = nil
26
+ remaining = []
27
+ i = 0
28
+ while i < argv.length
29
+ arg = argv[i]
30
+ if arg == "--user"
31
+ nxt = argv[i + 1]
32
+ if nxt.nil? || nxt.empty? || nxt.start_with?("-")
33
+ raise ArgumentError, "--user requires a value"
34
+ end
35
+ user = nxt
36
+ i += 2
37
+ elsif arg.start_with?("--user=")
38
+ value = arg["--user=".length..]
39
+ raise ArgumentError, "--user requires a value" if value.nil? || value.empty?
40
+ user = value
41
+ i += 1
42
+ else
43
+ remaining << arg
44
+ i += 1
45
+ end
46
+ end
47
+ [user, remaining]
48
+ end
49
+
16
50
  def run(argv: ARGV, env: ENV, stdout: $stdout, stderr: $stderr, stdin: $stdin,
17
51
  session_factory: Tempest::Session.method(:create),
18
52
  store: nil)
53
+ begin
54
+ user, argv = extract_user(argv)
55
+ rescue ArgumentError => e
56
+ stderr.puts "error: #{e.message}"
57
+ return 64
58
+ end
59
+
19
60
  if argv.include?("--version") || argv.include?("-v")
20
61
  stdout.puts "tempest #{Tempest::VERSION}"
21
62
  return 0
@@ -29,14 +70,46 @@ module Tempest
29
70
  head = argv.first
30
71
  case
31
72
  when head.nil?, head.start_with?("-"), head == "tui"
73
+ Tempest::DeprecatedEnvs.warn_if_set(env: env, stderr: stderr)
32
74
  rest = (head == "tui") ? argv.drop(1) : argv
33
75
  Tempest::Commands::Tui.call(
34
76
  argv: rest, env: env, stdout: stdout, stderr: stderr, stdin: stdin,
35
- session_factory: session_factory, store: store,
77
+ session_factory: session_factory, store: store, user: user,
36
78
  )
79
+ when head == "login"
80
+ if user
81
+ stderr.puts "error: --user is not supported for `login`"
82
+ return 64
83
+ end
84
+ logger = build_subcommand_logger(env)
85
+ begin
86
+ Tempest::Commands::Login.call(
87
+ argv: argv.drop(1), env: env, stdout: stdout, stderr: stderr, stdin: stdin,
88
+ session_factory: session_factory, logger: logger,
89
+ )
90
+ rescue Tempest::Error, ArgumentError => e
91
+ stderr.puts "error: #{e.message}"
92
+ Tempest::Commands::Base.exit_code_for(e)
93
+ end
94
+ when head == "accounts"
95
+ if user
96
+ stderr.puts "error: --user is not supported for `accounts`"
97
+ return 64
98
+ end
99
+ logger = build_subcommand_logger(env)
100
+ begin
101
+ Tempest::Commands::Accounts.call(
102
+ argv: argv.drop(1), env: env, stdout: stdout, stderr: stderr, logger: logger,
103
+ )
104
+ rescue Tempest::Error, ArgumentError => e
105
+ stderr.puts "error: #{e.message}"
106
+ Tempest::Commands::Base.exit_code_for(e)
107
+ end
37
108
  when SUBCOMMANDS.include?(head)
109
+ Tempest::DeprecatedEnvs.warn_if_set(env: env, stderr: stderr)
110
+ logger = build_subcommand_logger(env)
38
111
  begin
39
- dispatch_subcommand(head, argv, env: env, stdout: stdout, stderr: stderr, stdin: stdin)
112
+ dispatch_subcommand(head, argv, env: env, stdout: stdout, stderr: stderr, stdin: stdin, user: user, logger: logger)
40
113
  rescue Tempest::Error, ArgumentError => e
41
114
  stderr.puts "error: #{e.message}"
42
115
  Tempest::Commands::Base.exit_code_for(e)
@@ -47,9 +120,18 @@ module Tempest
47
120
  end
48
121
  end
49
122
 
50
- def dispatch_subcommand(head, argv, env:, stdout:, stderr:, stdin:)
51
- session = Tempest::Commands::Base.authenticate(env: env, stderr: stderr)
52
- return 3 if session.nil?
123
+ # Build the info-level logger used by non-TUI subcommands so that
124
+ # account/login/migration events still reach info.log. Always non-verbose
125
+ # (no `debug:` flag), distinct from the TUI's `--debug` channel.
126
+ def build_subcommand_logger(env)
127
+ Tempest::DebugLog.build(env: env, debug: false)
128
+ end
129
+
130
+ def dispatch_subcommand(head, argv, env:, stdout:, stderr:, stdin:, user: nil, logger: nil)
131
+ session, code = Tempest::Commands::Base.authenticate_with_code(
132
+ env: env, stderr: stderr, user: user, logger: logger,
133
+ )
134
+ return code if session.nil?
53
135
  client = Tempest::XRPCClient.new(session)
54
136
  case head
55
137
  when "whoami"
@@ -0,0 +1,98 @@
1
+ require "json"
2
+
3
+ require_relative "../commands"
4
+ require_relative "../accounts_store"
5
+ require_relative "../accounts_migration"
6
+
7
+ module Tempest
8
+ module Commands
9
+ # `tempest accounts list` / `tempest accounts set-default <handle|did>`.
10
+ module Accounts
11
+ module_function
12
+
13
+ def call(argv:, env:, stdout:, stderr:, logger: nil)
14
+ Tempest::AccountsMigration.run(env: env, stderr: stderr, logger: logger)
15
+ sub = argv.first
16
+
17
+ case sub
18
+ when "list"
19
+ list(argv.drop(1), env: env, stdout: stdout, stderr: stderr, logger: logger)
20
+ when "set-default"
21
+ set_default(argv.drop(1), env: env, stdout: stdout, stderr: stderr, logger: logger)
22
+ when nil
23
+ stdout.puts "usage: tempest accounts list|set-default ..."
24
+ 64
25
+ else
26
+ stderr.puts "error: unknown accounts subcommand: #{sub}"
27
+ 64
28
+ end
29
+ end
30
+
31
+ def list(argv, env:, stdout:, stderr:, logger: nil)
32
+ format = "line"
33
+ argv.each do |arg|
34
+ if (m = arg.match(/\A--format=(\S+)\z/))
35
+ format = m[1]
36
+ end
37
+ end
38
+ unless %w[line json].include?(format)
39
+ stderr.puts "error: invalid --format: #{format.inspect}"
40
+ return 64
41
+ end
42
+
43
+ store = Tempest::AccountsStore.new(env: env, logger: logger)
44
+
45
+ if store.accounts.empty?
46
+ if format == "json"
47
+ stdout.puts JSON.generate("default" => nil, "accounts" => [])
48
+ else
49
+ stdout.puts "no accounts — run `tempest login` to add one"
50
+ end
51
+ return 0
52
+ end
53
+
54
+ case format
55
+ when "json"
56
+ payload = {
57
+ "default" => store.default,
58
+ "accounts" => store.accounts.map { |a|
59
+ {
60
+ "did" => a.did,
61
+ "handle" => a.handle,
62
+ "identifier" => a.identifier,
63
+ "pds_host" => a.pds_host,
64
+ "added_at" => a.added_at.utc.iso8601(6),
65
+ }
66
+ },
67
+ }
68
+ stdout.puts JSON.generate(payload)
69
+ when "line"
70
+ store.accounts.each do |a|
71
+ marker = (a.did == store.default) ? "* " : " "
72
+ stdout.puts "#{marker}@#{a.handle} (#{a.did}) #{a.pds_host} added #{a.added_at.utc.strftime("%Y-%m-%d")}"
73
+ end
74
+ end
75
+ 0
76
+ end
77
+
78
+ def set_default(argv, env:, stdout:, stderr:, logger: nil)
79
+ value = argv.first
80
+ if value.nil? || value.empty?
81
+ stderr.puts "usage: tempest accounts set-default <handle|did>"
82
+ return 64
83
+ end
84
+
85
+ store = Tempest::AccountsStore.new(env: env, logger: logger)
86
+ target = store.resolve(value)
87
+ if target.nil?
88
+ stderr.puts "error: unknown user: #{value} (run `tempest accounts list` to see known accounts)"
89
+ return 2
90
+ end
91
+
92
+ store.set_default(target.did)
93
+ stdout.puts "default account set to @#{target.handle} (#{target.did})"
94
+ 0
95
+ end
96
+ end
97
+ end
98
+ end
@@ -1,5 +1,7 @@
1
1
  require_relative "../commands"
2
2
  require_relative "../session_store"
3
+ require_relative "../accounts_store"
4
+ require_relative "../accounts_migration"
3
5
  require_relative "../config"
4
6
  require_relative "../repl/formatter"
5
7
 
@@ -10,25 +12,73 @@ module Tempest
10
12
 
11
13
  VALID_FORMATS = %i[line json raw].freeze
12
14
 
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"])
15
+ # Loads the per-account cached session and refreshes it. Returns the
16
+ # session on success. On failure writes a human-readable line to stderr
17
+ # and returns nil; callers translate the nil into the appropriate exit
18
+ # code via `exit_code_for` (or `authenticate_with_code` for a more
19
+ # specific exit code).
20
+ #
21
+ # `user:` is the value of the global `--user <handle|did>` flag, or nil
22
+ # for the default account.
23
+ def authenticate(env:, stderr:, user: nil, logger: nil)
24
+ session, _code = authenticate_with_code(env: env, stderr: stderr, user: user, logger: logger)
25
+ session
26
+ end
27
+
28
+ # Like `authenticate` but additionally returns the exit code that the CLI
29
+ # should use when session is nil. Exit codes follow the spec:
30
+ # 2 for "unknown user" / "no accounts configured", 3 for session missing
31
+ # or refresh failure.
32
+ def authenticate_with_code(env:, stderr:, user: nil, logger: nil)
33
+ Tempest::AccountsMigration.run(env: env, stderr: stderr, logger: logger)
34
+ accounts = Tempest::AccountsStore.new(env: env, logger: logger)
35
+
36
+ target = resolve_target(accounts, user, stderr)
37
+ return [nil, 2] if target.nil?
38
+
39
+ session_store = Tempest::SessionStore.for(env, did: target.did)
40
+ session = session_store.load(identifier: nil, pds_host: nil)
20
41
  if session.nil?
21
- stderr.puts "error: no cached session — run `tempest tui` once to sign in"
22
- return nil
42
+ stderr.puts "error: session for @#{target.handle} missing — run `tempest login` to re-authenticate"
43
+ return [nil, 3]
23
44
  end
24
- session.on_change = ->(s) { store.save(s, identifier: s.identifier) }
45
+
46
+ session.identifier ||= target.identifier
47
+ session.on_change = ->(s) {
48
+ session_store.save(s, identifier: s.identifier || target.identifier)
49
+ accounts.update_handle(did: s.did, handle: s.handle) if s.did && s.handle
50
+ }
51
+
25
52
  begin
26
53
  session.refresh!
27
54
  rescue Tempest::Error => e
28
- stderr.puts "error: cached session refresh failed: #{e.message}"
55
+ stderr.puts "error: session for @#{target.handle} expired — run `tempest login` to re-authenticate (#{e.message})"
56
+ return [nil, 3]
57
+ end
58
+ [session, 0]
59
+ end
60
+
61
+ def resolve_target(accounts, user, stderr)
62
+ if user
63
+ target = accounts.resolve(user)
64
+ if target.nil?
65
+ stderr.puts "error: unknown user: #{user} (run `tempest accounts list` to see known accounts)"
66
+ return nil
67
+ end
68
+ return target
69
+ end
70
+
71
+ if accounts.default
72
+ return accounts.resolve(accounts.default)
73
+ end
74
+
75
+ if accounts.accounts.empty?
76
+ stderr.puts "error: no accounts configured — run `tempest login` to add one"
29
77
  return nil
30
78
  end
31
- session
79
+
80
+ stderr.puts "error: no default account set — run `tempest accounts set-default <handle>`"
81
+ nil
32
82
  end
33
83
 
34
84
  # Returns one of :line, :json, :raw. Callers may override with --format.
@@ -0,0 +1,118 @@
1
+ require "io/console"
2
+ require "time"
3
+
4
+ require_relative "../commands"
5
+ require_relative "../accounts_store"
6
+ require_relative "../accounts_migration"
7
+ require_relative "../config"
8
+ require_relative "../session"
9
+ require_relative "../session_store"
10
+
11
+ module Tempest
12
+ module Commands
13
+ # `tempest login` — adds a Bluesky account to tempest.
14
+ #
15
+ # Always reads identifier and app password interactively from stdin (env
16
+ # vars are intentionally not honored — see plan §F2). Optional
17
+ # `--pds-host=<url>` selects a non-bsky.social PDS. On success, persists
18
+ # the per-DID session.json and registers the account in accounts.json
19
+ # (becoming default if it is the first account).
20
+ module Login
21
+ module_function
22
+
23
+ DEFAULT_PDS_HOST = Tempest::Config::DEFAULT_PDS_HOST
24
+
25
+ def call(argv:, env:, stdout:, stderr:, stdin:, session_factory: nil, logger: nil)
26
+ Tempest::AccountsMigration.run(env: env, stderr: stderr, logger: logger)
27
+ session_factory ||= Tempest::Session.method(:create)
28
+
29
+ pds_host, _rest = parse(argv, stderr: stderr)
30
+ return 64 if pds_host == :error
31
+
32
+ stdout.print "identifier: "
33
+ stdout.flush
34
+ identifier = stdin.gets&.strip
35
+ if identifier.nil? || identifier.empty?
36
+ stderr.puts "error: identifier required"
37
+ return 64
38
+ end
39
+
40
+ password = read_password(stdout, stdin)
41
+ if password.nil? || password.empty?
42
+ stderr.puts "error: app password required"
43
+ return 64
44
+ end
45
+
46
+ stdout.puts "signing in..."
47
+ stdout.flush
48
+
49
+ config = Tempest::Config.new(
50
+ identifier: identifier,
51
+ app_password: password,
52
+ pds_host: pds_host,
53
+ )
54
+
55
+ session = create_with_2fa(config, stdout, stdin, session_factory)
56
+
57
+ session_store = Tempest::SessionStore.for(env, did: session.did)
58
+ session.identifier ||= identifier
59
+ session_store.save(session, identifier: identifier)
60
+
61
+ accounts = Tempest::AccountsStore.new(env: env, logger: logger)
62
+ accounts.add_account(
63
+ did: session.did,
64
+ handle: session.handle,
65
+ identifier: identifier,
66
+ pds_host: pds_host,
67
+ added_at: Time.now.utc,
68
+ )
69
+
70
+ logger&.info("accounts", event: "login", handle: session.handle, did: session.did, pds_host: pds_host)
71
+ stdout.puts "logged in as @#{session.handle} (#{session.did}) on #{pds_host}"
72
+ 0
73
+ rescue Tempest::AuthenticationError => e
74
+ stderr.puts "error: login failed: #{e.message}"
75
+ 3
76
+ end
77
+
78
+ def parse(argv, stderr:)
79
+ pds_host = DEFAULT_PDS_HOST
80
+ rest = []
81
+ argv.each do |arg|
82
+ if (m = arg.match(/\A--pds-host=(.+)\z/))
83
+ pds_host = m[1]
84
+ else
85
+ rest << arg
86
+ end
87
+ end
88
+ [pds_host, rest]
89
+ end
90
+
91
+ def read_password(stdout, stdin)
92
+ stdout.print "app password: "
93
+ stdout.flush
94
+ if stdin.respond_to?(:noecho)
95
+ password = stdin.noecho(&:gets)
96
+ stdout.puts ""
97
+ password&.strip
98
+ else
99
+ stdin.gets&.strip
100
+ end
101
+ end
102
+
103
+ def create_with_2fa(config, stdout, stdin, session_factory)
104
+ session_factory.call(config, auth_factor_token: nil)
105
+ rescue Tempest::AuthenticationError => e
106
+ raise unless e.code == "AuthFactorTokenRequired"
107
+
108
+ stdout.puts "Bluesky sent a sign-in code to your email. Enter it below."
109
+ stdout.print "code: "
110
+ stdout.flush
111
+ code = stdin.gets&.strip
112
+ raise Tempest::AuthenticationError.new("sign-in cancelled (no code entered)", code: e.code) if code.nil? || code.empty?
113
+
114
+ session_factory.call(config, auth_factor_token: code)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -4,6 +4,8 @@ require_relative "../config"
4
4
  require_relative "../debug_log"
5
5
  require_relative "../session"
6
6
  require_relative "../session_store"
7
+ require_relative "../accounts_store"
8
+ require_relative "../accounts_migration"
7
9
  require_relative "../cursor_store"
8
10
  require_relative "../timeline_store"
9
11
  require_relative "../xrpc_client"
@@ -26,14 +28,30 @@ module Tempest
26
28
 
27
29
  def call(argv:, env:, stdout:, stderr:, stdin:,
28
30
  session_factory: Tempest::Session.method(:create),
29
- store: nil)
31
+ store: nil, user: nil)
30
32
  Tempest::REPL::Formatter.color = stdout.respond_to?(:tty?) && stdout.tty? && env["NO_COLOR"].to_s.empty?
31
33
 
32
34
  debug_logger = build_debug_logger(env, argv: argv)
33
35
  announce_debug_logger(debug_logger, stderr)
34
36
 
35
- store ||= Tempest::SessionStore.new(path: Tempest::SessionStore.default_path(env))
36
- session = sign_in(env, stdout, stdin, session_factory, store: store)
37
+ Tempest::AccountsMigration.run(env: env, stderr: stderr, logger: debug_logger)
38
+
39
+ if store
40
+ # Test injection path: behave as the legacy single-account flow so
41
+ # existing tests pass unchanged.
42
+ session = sign_in(env, stdout, stdin, session_factory, store: store)
43
+ target_did = session.did
44
+ else
45
+ accounts = Tempest::AccountsStore.new(env: env, logger: debug_logger)
46
+ target_did, session = sign_in_with_accounts(
47
+ accounts: accounts, env: env, user: user,
48
+ stdout: stdout, stdin: stdin, stderr: stderr,
49
+ session_factory: session_factory,
50
+ )
51
+ return 3 if session.nil?
52
+ store = Tempest::SessionStore.for(env, did: target_did)
53
+ end
54
+
37
55
  client = Tempest::XRPCClient.new(session)
38
56
  input = RelineReader.new
39
57
 
@@ -66,7 +84,7 @@ module Tempest
66
84
  )
67
85
  stream_manager = Tempest::Jetstream::StreamManager.new(
68
86
  client: jetstream_client,
69
- cursor_store: cursor_store(env),
87
+ cursor_store: cursor_store(env, did: target_did),
70
88
  filter: plan.filter,
71
89
  logger: debug_logger,
72
90
  )
@@ -91,7 +109,7 @@ module Tempest
91
109
  stream_manager: stream_manager,
92
110
  handle_resolver: handle_resolver,
93
111
  avatar_store: avatar_store,
94
- timeline_store: timeline_store(env),
112
+ timeline_store: timeline_store(env, did: target_did),
95
113
  opener: opener_for(env: env),
96
114
  reauth: build_reauth(env, stdout, stdin, session_factory),
97
115
  )
@@ -145,6 +163,96 @@ module Tempest
145
163
  session
146
164
  end
147
165
 
166
+ # Multi-account-aware sign-in for `tempest tui`. Returns [did, session] on
167
+ # success, or [nil, nil] when stderr already received the error message.
168
+ #
169
+ # Order of precedence:
170
+ # 1. `user` argument: resolve against accounts.json; refresh
171
+ # `accounts/<did>/session.json`. Fail loudly on unknown user or
172
+ # refresh failure.
173
+ # 2. Default account: same flow, target is `accounts.default`.
174
+ # 3. No accounts and `TEMPEST_IDENTIFIER`/`TEMPEST_APP_PASSWORD` set:
175
+ # first-run env path. Create session via 2FA-aware factory, save it
176
+ # under the per-DID layout, and register it as default.
177
+ # 4. No accounts and no env: stderr "no accounts configured" and bail.
178
+ def sign_in_with_accounts(accounts:, env:, user:, stdout:, stdin:, stderr:, session_factory:)
179
+ if user
180
+ target = accounts.resolve(user)
181
+ if target.nil?
182
+ stderr.puts "error: unknown user: #{user} (run `tempest accounts list` to see known accounts)"
183
+ return [nil, nil]
184
+ end
185
+ return resume_account(accounts: accounts, env: env, target: target, stderr: stderr)
186
+ end
187
+
188
+ if accounts.default
189
+ target = accounts.resolve(accounts.default)
190
+ return resume_account(accounts: accounts, env: env, target: target, stderr: stderr)
191
+ end
192
+
193
+ if accounts.accounts.empty? && !nil_if_empty(env["TEMPEST_IDENTIFIER"]).nil?
194
+ return first_run_env_path(accounts: accounts, env: env, stdout: stdout, stdin: stdin, session_factory: session_factory)
195
+ end
196
+
197
+ stderr.puts "error: no accounts configured — run `tempest login` to add one"
198
+ [nil, nil]
199
+ end
200
+
201
+ def resume_account(accounts:, env:, target:, stderr:)
202
+ store = Tempest::SessionStore.for(env, did: target.did)
203
+ session = store.load(identifier: nil, pds_host: nil)
204
+ if session.nil?
205
+ stderr.puts "error: session for @#{target.handle} missing — run `tempest login` to re-authenticate"
206
+ return [nil, nil]
207
+ end
208
+
209
+ session.identifier ||= target.identifier
210
+ session.on_change = ->(s) {
211
+ store.save(s, identifier: s.identifier || target.identifier)
212
+ accounts.update_handle(did: s.did, handle: s.handle) if s.did && s.handle
213
+ }
214
+ begin
215
+ session.refresh!
216
+ rescue Tempest::Error => e
217
+ stderr.puts "error: session for @#{target.handle} expired — run `tempest login` to re-authenticate (#{e.message})"
218
+ return [nil, nil]
219
+ end
220
+ [target.did, session]
221
+ end
222
+
223
+ def first_run_env_path(accounts:, env:, stdout:, stdin:, session_factory:)
224
+ # First-run env path intentionally pins pds_host to the default so that
225
+ # the deprecated TEMPEST_PDS_HOST env var has no effect (spec §"Deprecated
226
+ # env vars"). Alternate PDS hosts must go through `tempest login
227
+ # --pds-host=...`.
228
+ identifier = nil_if_empty(env["TEMPEST_IDENTIFIER"])
229
+ app_password = nil_if_empty(env["TEMPEST_APP_PASSWORD"])
230
+ raise Tempest::Config::MissingValue, "TEMPEST_IDENTIFIER is not set" if identifier.nil?
231
+ raise Tempest::Config::MissingValue, "TEMPEST_APP_PASSWORD is not set" if app_password.nil?
232
+
233
+ config = Tempest::Config.new(
234
+ identifier: identifier,
235
+ app_password: app_password,
236
+ pds_host: Tempest::Config::DEFAULT_PDS_HOST,
237
+ )
238
+ session = create_with_2fa(config, env, stdout, stdin, session_factory)
239
+ store = Tempest::SessionStore.for(env, did: session.did)
240
+ session.identifier ||= config.identifier
241
+ session.on_change = ->(s) {
242
+ store.save(s, identifier: s.identifier || config.identifier)
243
+ accounts.update_handle(did: s.did, handle: s.handle) if s.did && s.handle
244
+ }
245
+ store.save(session, identifier: config.identifier)
246
+ accounts.add_account(
247
+ did: session.did,
248
+ handle: session.handle,
249
+ identifier: config.identifier,
250
+ pds_host: config.pds_host,
251
+ added_at: Time.now.utc,
252
+ )
253
+ [session.did, session]
254
+ end
255
+
148
256
  def nil_if_empty(value)
149
257
  value.nil? || value.empty? ? nil : value
150
258
  end
@@ -181,8 +289,12 @@ module Tempest
181
289
  true
182
290
  end
183
291
 
184
- def cursor_store(env)
185
- Tempest::CursorStore.new(path: Tempest::CursorStore.default_path(env))
292
+ def cursor_store(env, did: nil)
293
+ if did
294
+ Tempest::CursorStore.for(env, did: did)
295
+ else
296
+ Tempest::CursorStore.new(path: Tempest::CursorStore.default_path(env))
297
+ end
186
298
  end
187
299
 
188
300
  # Returns a Tempest::DebugLog::Channel. info.log is always enabled
@@ -224,8 +336,12 @@ module Tempest
224
336
  }
225
337
  end
226
338
 
227
- def timeline_store(env)
228
- Tempest::TimelineStore.new(path: Tempest::TimelineStore.default_path(env))
339
+ def timeline_store(env, did: nil)
340
+ if did
341
+ Tempest::TimelineStore.for(env, did: did)
342
+ else
343
+ Tempest::TimelineStore.new(path: Tempest::TimelineStore.default_path(env))
344
+ end
229
345
  end
230
346
 
231
347
  def avatar_cache_dir(env)
@@ -288,7 +404,7 @@ module Tempest
288
404
 
289
405
  def help_text
290
406
  <<~HELP
291
- Usage: tempest [subcommand] [options]
407
+ Usage: tempest [--user <handle|did>] [subcommand] [options]
292
408
 
293
409
  Subcommands:
294
410
  tui (default) launch the interactive TUI
@@ -297,6 +413,15 @@ module Tempest
297
413
  read posts; --format=line|json|raw, --since, --until, --limit
298
414
  whoami print the signed-in identity
299
415
  follow <handle> follow a Bluesky account
416
+ login add a Bluesky account (interactive)
417
+ accounts list show known accounts; * marks default. --format=json supported.
418
+ accounts set-default <handle|did>
419
+ pick which account `tempest` uses when --user is unset
420
+
421
+ Global options:
422
+ --user <handle|did> Pick which account to act as. Defaults to the
423
+ entry marked default in accounts.json. Not
424
+ accepted by `login` or `accounts`.
300
425
 
301
426
  TUI options:
302
427
  -h, --help Show this help
@@ -311,10 +436,9 @@ module Tempest
311
436
  ~/.local/state/tempest) and use size-based
312
437
  rotation (5 MiB x 5 files).
313
438
 
314
- Environment (required only when no cached session is available):
439
+ Environment (required only on first run, when accounts.json is absent):
315
440
  TEMPEST_IDENTIFIER Your handle (e.g. asonas.bsky.social)
316
441
  TEMPEST_APP_PASSWORD An app password generated in Bluesky settings
317
- TEMPEST_PDS_HOST Override PDS host (default: https://bsky.social)
318
442
  TEMPEST_AUTH_FACTOR_TOKEN
319
443
  Pre-supply an email sign-in code (rarely needed; the CLI will
320
444
  prompt interactively when Bluesky asks for one)
@@ -322,13 +446,6 @@ module Tempest
322
446
  TEMPEST_OPEN_CMD Command used to open URLs when :open $LX is invoked
323
447
  (default: "open"). The URL is passed as the single
324
448
  argument after the command.
325
- TEMPEST_SESSION_PATH Override the session cache path (default:
326
- $XDG_CONFIG_HOME/tempest/session.json or
327
- ~/.config/tempest/session.json). The cache holds refreshed
328
- tokens so the email sign-in code is only requested once.
329
- TEMPEST_CURSOR_PATH Override the Jetstream cursor cache path (default:
330
- $XDG_CONFIG_HOME/tempest/cursor.json). Holds the last-seen
331
- time_us so a restart can replay missed events.
332
449
  TEMPEST_FEED "home" (default) or "self"; equivalent to --feed.
333
450
  TEMPEST_DEBUG Set to 1 to behave as if --debug was passed.
334
451
  TEMPEST_LOG_DIR Override the directory holding info.log and debug.log.
@@ -3,6 +3,7 @@ require "json"
3
3
  require "time"
4
4
 
5
5
  require_relative "../tempest"
6
+ require_relative "account_paths"
6
7
 
7
8
  module Tempest
8
9
  # Persists the last-seen Jetstream `time_us` so a restarted tempest can hand
@@ -11,12 +12,11 @@ module Tempest
11
12
  # caller (StreamManager checks saved_at against its replay window).
12
13
  class CursorStore
13
14
  def self.default_path(env = ENV)
14
- explicit = env["TEMPEST_CURSOR_PATH"]
15
- return explicit if explicit && !explicit.empty?
15
+ Tempest::AccountPaths.legacy_cursor_path(env)
16
+ end
16
17
 
17
- base = env["XDG_CONFIG_HOME"]
18
- base = File.join(env["HOME"].to_s, ".config") if base.nil? || base.empty?
19
- File.join(base, "tempest", "cursor.json")
18
+ def self.for(env = ENV, did:)
19
+ new(path: Tempest::AccountPaths.cursor_path(env, did: did))
20
20
  end
21
21
 
22
22
  def initialize(path:)
@@ -28,7 +28,7 @@ module Tempest
28
28
  def save(time_us:, at: Time.now)
29
29
  payload = { "time_us" => time_us, "saved_at" => at.utc.iso8601(6) }
30
30
 
31
- FileUtils.mkdir_p(File.dirname(@path))
31
+ FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
32
32
  File.open(@path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |io|
33
33
  io.write(JSON.generate(payload))
34
34
  end
@@ -0,0 +1,21 @@
1
+ require_relative "../tempest"
2
+
3
+ module Tempest
4
+ # Emits a one-line stderr warning per deprecated environment variable that is
5
+ # still set. Called from non-interactive command entry points (`tui`, `post`,
6
+ # `feed`, `whoami`); intentionally skipped by `login` and `accounts` so their
7
+ # interactive prompts and structured output stay clean.
8
+ module DeprecatedEnvs
9
+ NAMES = %w[TEMPEST_SESSION_PATH TEMPEST_CURSOR_PATH TEMPEST_TIMELINE_PATH TEMPEST_PDS_HOST].freeze
10
+
11
+ module_function
12
+
13
+ def warn_if_set(env:, stderr:)
14
+ NAMES.each do |name|
15
+ value = env[name]
16
+ next if value.nil? || value.empty?
17
+ stderr.puts "warning: #{name} is no longer honored; tempest uses accounts/<did>/ layout"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -2,17 +2,17 @@ require "fileutils"
2
2
  require "json"
3
3
 
4
4
  require_relative "../tempest"
5
+ require_relative "account_paths"
5
6
  require_relative "session"
6
7
 
7
8
  module Tempest
8
9
  class SessionStore
9
10
  def self.default_path(env = ENV)
10
- explicit = env["TEMPEST_SESSION_PATH"]
11
- return explicit if explicit && !explicit.empty?
11
+ Tempest::AccountPaths.legacy_session_path(env)
12
+ end
12
13
 
13
- base = env["XDG_CONFIG_HOME"]
14
- base = File.join(env["HOME"].to_s, ".config") if base.nil? || base.empty?
15
- File.join(base, "tempest", "session.json")
14
+ def self.for(env = ENV, did:)
15
+ new(path: Tempest::AccountPaths.session_path(env, did: did))
16
16
  end
17
17
 
18
18
  def initialize(path:)
@@ -31,7 +31,7 @@ module Tempest
31
31
  "refresh_jwt" => session.refresh_jwt,
32
32
  }
33
33
 
34
- FileUtils.mkdir_p(File.dirname(@path))
34
+ FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
35
35
  File.open(@path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |io|
36
36
  io.write(JSON.generate(payload))
37
37
  end
@@ -3,6 +3,7 @@ require "json"
3
3
  require "time"
4
4
 
5
5
  require_relative "../tempest"
6
+ require_relative "account_paths"
6
7
  require_relative "post"
7
8
  require_relative "facet"
8
9
 
@@ -17,12 +18,11 @@ module Tempest
17
18
  MAX_POSTS = 50
18
19
 
19
20
  def self.default_path(env = ENV)
20
- explicit = env["TEMPEST_TIMELINE_PATH"]
21
- return explicit if explicit && !explicit.empty?
21
+ Tempest::AccountPaths.legacy_timeline_path(env)
22
+ end
22
23
 
23
- base = env["XDG_CONFIG_HOME"]
24
- base = File.join(env["HOME"].to_s, ".config") if base.nil? || base.empty?
25
- File.join(base, "tempest", "timeline.json")
24
+ def self.for(env = ENV, did:)
25
+ new(path: Tempest::AccountPaths.timeline_path(env, did: did))
26
26
  end
27
27
 
28
28
  def initialize(path:)
@@ -37,7 +37,7 @@ module Tempest
37
37
  "saved_at" => at.utc.iso8601(6),
38
38
  }
39
39
 
40
- FileUtils.mkdir_p(File.dirname(@path))
40
+ FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
41
41
  File.open(@path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |io|
42
42
  io.write(JSON.generate(payload))
43
43
  end
@@ -1,3 +1,3 @@
1
1
  module Tempest
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tempest-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuya Fujiwara
@@ -92,12 +92,17 @@ files:
92
92
  - README.md
93
93
  - exe/tempest
94
94
  - lib/tempest.rb
95
+ - lib/tempest/account_paths.rb
96
+ - lib/tempest/accounts_migration.rb
97
+ - lib/tempest/accounts_store.rb
95
98
  - lib/tempest/avatar_store.rb
96
99
  - lib/tempest/cli.rb
97
100
  - lib/tempest/commands.rb
101
+ - lib/tempest/commands/accounts.rb
98
102
  - lib/tempest/commands/base.rb
99
103
  - lib/tempest/commands/feed.rb
100
104
  - lib/tempest/commands/follow.rb
105
+ - lib/tempest/commands/login.rb
101
106
  - lib/tempest/commands/post.rb
102
107
  - lib/tempest/commands/tui.rb
103
108
  - lib/tempest/commands/whoami.rb
@@ -105,6 +110,7 @@ files:
105
110
  - lib/tempest/cursor_store.rb
106
111
  - lib/tempest/date_filter.rb
107
112
  - lib/tempest/debug_log.rb
113
+ - lib/tempest/deprecated_envs.rb
108
114
  - lib/tempest/facet.rb
109
115
  - lib/tempest/follows.rb
110
116
  - lib/tempest/handle_lookup.rb