tempest-rb 0.2.0 → 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 +4 -4
- data/README.md +49 -9
- data/lib/tempest/account_paths.rb +60 -0
- data/lib/tempest/accounts_migration.rb +65 -0
- data/lib/tempest/accounts_store.rb +205 -0
- data/lib/tempest/cli.rb +94 -6
- data/lib/tempest/commands/accounts.rb +98 -0
- data/lib/tempest/commands/base.rb +62 -12
- data/lib/tempest/commands/follow.rb +35 -0
- data/lib/tempest/commands/login.rb +118 -0
- data/lib/tempest/commands/tui.rb +137 -19
- data/lib/tempest/cursor_store.rb +6 -6
- data/lib/tempest/deprecated_envs.rb +21 -0
- data/lib/tempest/session_store.rb +6 -6
- data/lib/tempest/timeline_store.rb +6 -6
- data/lib/tempest/version.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 828dcb07d7565737bbe9b37ef7ca876fe6136dd7aaf308df43ec8c8fac9be825
|
|
4
|
+
data.tar.gz: 3a32fde6e7693ba99acea0482875cc9919e8e4969dd201404edeeb9e01d89193
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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` |
|
|
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
|
@@ -4,17 +4,59 @@ require_relative "commands/base"
|
|
|
4
4
|
require_relative "commands/whoami"
|
|
5
5
|
require_relative "commands/post"
|
|
6
6
|
require_relative "commands/feed"
|
|
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"
|
|
7
12
|
require_relative "xrpc_client"
|
|
8
13
|
|
|
9
14
|
module Tempest
|
|
10
15
|
module CLI
|
|
11
|
-
SUBCOMMANDS = %w[tui post feed whoami].freeze
|
|
16
|
+
SUBCOMMANDS = %w[tui post feed whoami follow accounts login].freeze
|
|
12
17
|
|
|
13
18
|
module_function
|
|
14
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
|
+
|
|
15
50
|
def run(argv: ARGV, env: ENV, stdout: $stdout, stderr: $stderr, stdin: $stdin,
|
|
16
51
|
session_factory: Tempest::Session.method(:create),
|
|
17
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
|
+
|
|
18
60
|
if argv.include?("--version") || argv.include?("-v")
|
|
19
61
|
stdout.puts "tempest #{Tempest::VERSION}"
|
|
20
62
|
return 0
|
|
@@ -28,14 +70,46 @@ module Tempest
|
|
|
28
70
|
head = argv.first
|
|
29
71
|
case
|
|
30
72
|
when head.nil?, head.start_with?("-"), head == "tui"
|
|
73
|
+
Tempest::DeprecatedEnvs.warn_if_set(env: env, stderr: stderr)
|
|
31
74
|
rest = (head == "tui") ? argv.drop(1) : argv
|
|
32
75
|
Tempest::Commands::Tui.call(
|
|
33
76
|
argv: rest, env: env, stdout: stdout, stderr: stderr, stdin: stdin,
|
|
34
|
-
session_factory: session_factory, store: store,
|
|
77
|
+
session_factory: session_factory, store: store, user: user,
|
|
35
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
|
|
36
108
|
when SUBCOMMANDS.include?(head)
|
|
109
|
+
Tempest::DeprecatedEnvs.warn_if_set(env: env, stderr: stderr)
|
|
110
|
+
logger = build_subcommand_logger(env)
|
|
37
111
|
begin
|
|
38
|
-
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)
|
|
39
113
|
rescue Tempest::Error, ArgumentError => e
|
|
40
114
|
stderr.puts "error: #{e.message}"
|
|
41
115
|
Tempest::Commands::Base.exit_code_for(e)
|
|
@@ -46,9 +120,18 @@ module Tempest
|
|
|
46
120
|
end
|
|
47
121
|
end
|
|
48
122
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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?
|
|
52
135
|
client = Tempest::XRPCClient.new(session)
|
|
53
136
|
case head
|
|
54
137
|
when "whoami"
|
|
@@ -66,6 +149,11 @@ module Tempest
|
|
|
66
149
|
argv: argv.drop(1), session: session, client: client,
|
|
67
150
|
stdout: stdout, stderr: stderr,
|
|
68
151
|
)
|
|
152
|
+
when "follow"
|
|
153
|
+
Tempest::Commands::Follow.call(
|
|
154
|
+
argv: argv.drop(1), session: session, client: client,
|
|
155
|
+
stdout: stdout, stderr: stderr,
|
|
156
|
+
)
|
|
69
157
|
end
|
|
70
158
|
end
|
|
71
159
|
|
|
@@ -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
|
|
14
|
-
# success. On failure
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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,35 @@
|
|
|
1
|
+
require_relative "../commands"
|
|
2
|
+
require_relative "../commands/base"
|
|
3
|
+
require_relative "../handle_lookup"
|
|
4
|
+
|
|
5
|
+
module Tempest
|
|
6
|
+
module Commands
|
|
7
|
+
module Follow
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call(argv:, session:, client:, stdout:, stderr:)
|
|
11
|
+
handle = argv.first&.sub(/\A@/, "")
|
|
12
|
+
if handle.nil? || handle.empty?
|
|
13
|
+
stderr.puts "usage: tempest follow <handle>"
|
|
14
|
+
return 64
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
did = Tempest::HandleLookup.resolve(handle, client: client)
|
|
18
|
+
client.post(
|
|
19
|
+
"com.atproto.repo.createRecord",
|
|
20
|
+
body: {
|
|
21
|
+
"repo" => session.did,
|
|
22
|
+
"collection" => "app.bsky.graph.follow",
|
|
23
|
+
"record" => {
|
|
24
|
+
"$type" => "app.bsky.graph.follow",
|
|
25
|
+
"subject" => did,
|
|
26
|
+
"createdAt" => Time.now.utc.iso8601,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
stdout.puts "Followed @#{handle}"
|
|
31
|
+
0
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -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
|
data/lib/tempest/commands/tui.rb
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -296,6 +412,16 @@ module Tempest
|
|
|
296
412
|
feed me|timeline|author <handle> [opts]
|
|
297
413
|
read posts; --format=line|json|raw, --since, --until, --limit
|
|
298
414
|
whoami print the signed-in identity
|
|
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`.
|
|
299
425
|
|
|
300
426
|
TUI options:
|
|
301
427
|
-h, --help Show this help
|
|
@@ -310,10 +436,9 @@ module Tempest
|
|
|
310
436
|
~/.local/state/tempest) and use size-based
|
|
311
437
|
rotation (5 MiB x 5 files).
|
|
312
438
|
|
|
313
|
-
Environment (required only
|
|
439
|
+
Environment (required only on first run, when accounts.json is absent):
|
|
314
440
|
TEMPEST_IDENTIFIER Your handle (e.g. asonas.bsky.social)
|
|
315
441
|
TEMPEST_APP_PASSWORD An app password generated in Bluesky settings
|
|
316
|
-
TEMPEST_PDS_HOST Override PDS host (default: https://bsky.social)
|
|
317
442
|
TEMPEST_AUTH_FACTOR_TOKEN
|
|
318
443
|
Pre-supply an email sign-in code (rarely needed; the CLI will
|
|
319
444
|
prompt interactively when Bluesky asks for one)
|
|
@@ -321,13 +446,6 @@ module Tempest
|
|
|
321
446
|
TEMPEST_OPEN_CMD Command used to open URLs when :open $LX is invoked
|
|
322
447
|
(default: "open"). The URL is passed as the single
|
|
323
448
|
argument after the command.
|
|
324
|
-
TEMPEST_SESSION_PATH Override the session cache path (default:
|
|
325
|
-
$XDG_CONFIG_HOME/tempest/session.json or
|
|
326
|
-
~/.config/tempest/session.json). The cache holds refreshed
|
|
327
|
-
tokens so the email sign-in code is only requested once.
|
|
328
|
-
TEMPEST_CURSOR_PATH Override the Jetstream cursor cache path (default:
|
|
329
|
-
$XDG_CONFIG_HOME/tempest/cursor.json). Holds the last-seen
|
|
330
|
-
time_us so a restart can replay missed events.
|
|
331
449
|
TEMPEST_FEED "home" (default) or "self"; equivalent to --feed.
|
|
332
450
|
TEMPEST_DEBUG Set to 1 to behave as if --debug was passed.
|
|
333
451
|
TEMPEST_LOG_DIR Override the directory holding info.log and debug.log.
|
data/lib/tempest/cursor_store.rb
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
15
|
+
Tempest::AccountPaths.legacy_cursor_path(env)
|
|
16
|
+
end
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
11
|
-
|
|
11
|
+
Tempest::AccountPaths.legacy_session_path(env)
|
|
12
|
+
end
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
21
|
-
|
|
21
|
+
Tempest::AccountPaths.legacy_timeline_path(env)
|
|
22
|
+
end
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
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
|
data/lib/tempest/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yuya Fujiwara
|
|
@@ -92,11 +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
|
|
104
|
+
- lib/tempest/commands/follow.rb
|
|
105
|
+
- lib/tempest/commands/login.rb
|
|
100
106
|
- lib/tempest/commands/post.rb
|
|
101
107
|
- lib/tempest/commands/tui.rb
|
|
102
108
|
- lib/tempest/commands/whoami.rb
|
|
@@ -104,6 +110,7 @@ files:
|
|
|
104
110
|
- lib/tempest/cursor_store.rb
|
|
105
111
|
- lib/tempest/date_filter.rb
|
|
106
112
|
- lib/tempest/debug_log.rb
|
|
113
|
+
- lib/tempest/deprecated_envs.rb
|
|
107
114
|
- lib/tempest/facet.rb
|
|
108
115
|
- lib/tempest/follows.rb
|
|
109
116
|
- lib/tempest/handle_lookup.rb
|