slk 0.2.0 → 0.4.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/CHANGELOG.md +39 -1
- data/README.md +30 -12
- data/bin/ci +15 -0
- data/bin/coverage +225 -0
- data/bin/test +7 -0
- data/lib/slk/api/search.rb +31 -0
- data/lib/slk/cli.rb +50 -3
- data/lib/slk/commands/base.rb +1 -0
- data/lib/slk/commands/catchup.rb +3 -2
- data/lib/slk/commands/config.rb +48 -45
- data/lib/slk/commands/help.rb +1 -0
- data/lib/slk/commands/messages.rb +59 -11
- data/lib/slk/commands/search.rb +223 -0
- data/lib/slk/commands/ssh_key_manager.rb +129 -0
- data/lib/slk/formatters/attachment_formatter.rb +16 -2
- data/lib/slk/formatters/mention_replacer.rb +13 -31
- data/lib/slk/formatters/message_formatter.rb +8 -15
- data/lib/slk/formatters/search_formatter.rb +75 -0
- data/lib/slk/models/search_result.rb +115 -0
- data/lib/slk/runner.rb +12 -0
- data/lib/slk/services/api_client.rb +60 -11
- data/lib/slk/services/cache_store.rb +55 -36
- data/lib/slk/services/encryption.rb +114 -11
- data/lib/slk/services/setup_wizard.rb +3 -3
- data/lib/slk/services/target_resolver.rb +27 -4
- data/lib/slk/services/token_loader.rb +83 -0
- data/lib/slk/services/token_saver.rb +87 -0
- data/lib/slk/services/token_store.rb +35 -65
- data/lib/slk/services/user_lookup.rb +117 -0
- data/lib/slk/support/date_parser.rb +64 -0
- data/lib/slk/support/platform.rb +34 -0
- data/lib/slk/support/xdg_paths.rb +27 -9
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +8 -0
- metadata +14 -1
|
@@ -6,21 +6,41 @@ module Slk
|
|
|
6
6
|
module Services
|
|
7
7
|
# Encrypts/decrypts tokens using age with SSH keys
|
|
8
8
|
class Encryption
|
|
9
|
+
SUPPORTED_KEY_TYPES = %w[ssh-rsa ssh-ed25519].freeze
|
|
10
|
+
|
|
11
|
+
attr_accessor :on_prompt_pub_key
|
|
12
|
+
|
|
9
13
|
def available?
|
|
10
|
-
|
|
14
|
+
# Cross-platform check for age command
|
|
15
|
+
_output, _error, status = Open3.capture3('age', '--version')
|
|
16
|
+
status.success?
|
|
17
|
+
rescue Errno::ENOENT
|
|
18
|
+
false
|
|
11
19
|
end
|
|
12
20
|
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
# Validate that the SSH key is a type supported by age
|
|
22
|
+
# @param ssh_key_path [String] Path to the SSH private key (public key at .pub)
|
|
23
|
+
# @return [true] if valid
|
|
24
|
+
# @raise [EncryptionError] if private key not found, public key not found,
|
|
25
|
+
# key type is unsupported, or public key doesn't match private key
|
|
26
|
+
def validate_key_type!(ssh_key_path)
|
|
27
|
+
raise EncryptionError, "Private key not found: #{ssh_key_path}" unless File.exist?(ssh_key_path)
|
|
15
28
|
|
|
16
|
-
public_key =
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
29
|
+
public_key = find_public_key(ssh_key_path)
|
|
30
|
+
validate_public_key_type!(public_key)
|
|
31
|
+
validate_key_pair_match!(ssh_key_path, public_key)
|
|
32
|
+
end
|
|
20
33
|
|
|
21
|
-
|
|
34
|
+
# Encrypt content using age with an SSH public key
|
|
35
|
+
# @param content [String] The content to encrypt
|
|
36
|
+
# @param ssh_key_path [String] Path to the SSH private key (public key at .pub)
|
|
37
|
+
# @param output_file [String] Path where encrypted output will be written
|
|
38
|
+
# @raise [EncryptionError] If age tool not available or public key not found
|
|
39
|
+
def encrypt(content, ssh_key_path, output_file)
|
|
40
|
+
raise EncryptionError, 'age encryption tool not available' unless available?
|
|
22
41
|
|
|
23
|
-
|
|
42
|
+
public_key = find_public_key(ssh_key_path)
|
|
43
|
+
run_age_encrypt(content, public_key, output_file)
|
|
24
44
|
end
|
|
25
45
|
|
|
26
46
|
# Decrypt an age-encrypted file using an SSH key
|
|
@@ -29,14 +49,97 @@ module Slk
|
|
|
29
49
|
# @return [String, nil] Decrypted content, or nil if file doesn't exist
|
|
30
50
|
# @raise [EncryptionError] If age tool not available, key not found, or decryption fails
|
|
31
51
|
def decrypt(encrypted_file, ssh_key_path)
|
|
32
|
-
# File not existing is not an error - it just means no encrypted data yet
|
|
33
52
|
return nil unless File.exist?(encrypted_file)
|
|
34
53
|
|
|
35
54
|
raise EncryptionError, 'age encryption tool not available' unless available?
|
|
36
55
|
raise EncryptionError, "SSH key not found: #{ssh_key_path}" unless File.exist?(ssh_key_path)
|
|
37
56
|
|
|
38
|
-
|
|
57
|
+
run_age_decrypt(encrypted_file, ssh_key_path)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def find_public_key(ssh_key_path)
|
|
63
|
+
default_pub = "#{ssh_key_path}.pub"
|
|
64
|
+
return default_pub if File.exist?(default_pub)
|
|
65
|
+
|
|
66
|
+
prompted_path = prompt_and_validate_public_key(ssh_key_path)
|
|
67
|
+
return prompted_path if prompted_path
|
|
68
|
+
|
|
69
|
+
raise EncryptionError, "Public key not found: #{default_pub}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def prompt_and_validate_public_key(ssh_key_path)
|
|
73
|
+
return nil unless @on_prompt_pub_key
|
|
74
|
+
|
|
75
|
+
prompted_path = @on_prompt_pub_key.call(ssh_key_path)
|
|
76
|
+
return nil unless prompted_path && File.exist?(prompted_path)
|
|
77
|
+
|
|
78
|
+
# Validate the prompted key before accepting it
|
|
79
|
+
validate_public_key_type!(prompted_path)
|
|
80
|
+
validate_key_pair_match!(ssh_key_path, prompted_path)
|
|
81
|
+
prompted_path
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_public_key_type!(public_key)
|
|
85
|
+
first_line = File.read(public_key).lines.first&.strip || ''
|
|
86
|
+
key_type = first_line.split.first
|
|
39
87
|
|
|
88
|
+
return true if SUPPORTED_KEY_TYPES.include?(key_type)
|
|
89
|
+
|
|
90
|
+
raise EncryptionError,
|
|
91
|
+
"Unsupported SSH key type: #{key_type || 'unknown'}. " \
|
|
92
|
+
"age only supports: #{SUPPORTED_KEY_TYPES.join(', ')}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def validate_key_pair_match!(private_key_path, public_key_path)
|
|
96
|
+
derived_pub = derive_public_key(private_key_path)
|
|
97
|
+
return true unless derived_pub # Skip validation if ssh-keygen not available
|
|
98
|
+
|
|
99
|
+
provided_pub = File.read(public_key_path).lines.first&.strip || ''
|
|
100
|
+
return true if keys_match?(derived_pub, provided_pub)
|
|
101
|
+
|
|
102
|
+
raise EncryptionError,
|
|
103
|
+
'Public key does not match private key. ' \
|
|
104
|
+
'Please provide the correct public key for this private key.'
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def derive_public_key(private_key_path)
|
|
108
|
+
output, error, status = Open3.capture3('ssh-keygen', '-y', '-f', private_key_path)
|
|
109
|
+
return output.strip if status.success?
|
|
110
|
+
|
|
111
|
+
# Check if ssh-keygen is missing vs other failures
|
|
112
|
+
return nil if ssh_keygen_not_found?(error)
|
|
113
|
+
|
|
114
|
+
# For other failures (passphrase-protected, corrupted), warn the user
|
|
115
|
+
raise EncryptionError,
|
|
116
|
+
"Cannot verify key pair: #{error.strip}. " \
|
|
117
|
+
'This may indicate a passphrase-protected or corrupted private key.'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Heuristics for detecting missing ssh-keygen command.
|
|
121
|
+
# These strings vary by OS/shell but cover common cases.
|
|
122
|
+
def ssh_keygen_not_found?(error)
|
|
123
|
+
error.include?('command not found') ||
|
|
124
|
+
error.include?('not recognized') ||
|
|
125
|
+
error.include?('No such file or directory')
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# SSH public key format: "type base64-data comment"
|
|
129
|
+
# Compare only type and key data, ignore the optional comment field
|
|
130
|
+
def keys_match?(derived, provided)
|
|
131
|
+
derived_parts = derived.split[0..1]
|
|
132
|
+
provided_parts = provided.split[0..1]
|
|
133
|
+
derived_parts == provided_parts
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def run_age_encrypt(content, public_key, output_file)
|
|
137
|
+
_output, error, status = Open3.capture3('age', '-R', public_key, '-o', output_file, stdin_data: content)
|
|
138
|
+
raise EncryptionError, "Failed to encrypt: #{error.strip}" unless status.success?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def run_age_decrypt(encrypted_file, ssh_key_path)
|
|
142
|
+
output, error, status = Open3.capture3('age', '-d', '-i', ssh_key_path, encrypted_file)
|
|
40
143
|
raise EncryptionError, "Failed to decrypt #{encrypted_file}: #{error.strip}" unless status.success?
|
|
41
144
|
|
|
42
145
|
output
|
|
@@ -122,9 +122,9 @@ module Slk
|
|
|
122
122
|
@output.success('Setup complete!')
|
|
123
123
|
@output.puts
|
|
124
124
|
@output.puts 'Try these commands:'
|
|
125
|
-
@output.puts '
|
|
126
|
-
@output.puts '
|
|
127
|
-
@output.puts '
|
|
125
|
+
@output.puts ' slk status - View your status'
|
|
126
|
+
@output.puts ' slk messages general - Read channel messages'
|
|
127
|
+
@output.puts ' slk help - See all commands'
|
|
128
128
|
end
|
|
129
129
|
end
|
|
130
130
|
end
|
|
@@ -91,17 +91,40 @@ module Slk
|
|
|
91
91
|
end
|
|
92
92
|
|
|
93
93
|
def find_user_id(workspace, username)
|
|
94
|
+
# Check cache first (reverse lookup by name)
|
|
95
|
+
cached_id = @cache.get_user_id_by_name(workspace.name, username)
|
|
96
|
+
return cached_id if cached_id
|
|
97
|
+
|
|
98
|
+
# Fall back to API call
|
|
99
|
+
fetch_user_id_from_api(workspace, username)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def fetch_user_id_from_api(workspace, username)
|
|
94
103
|
api = @runner.users_api(workspace.name)
|
|
95
|
-
|
|
96
|
-
|
|
104
|
+
users = api.list['members'] || []
|
|
105
|
+
user = find_user_by_name(users, username)
|
|
106
|
+
cache_user(workspace.name, user) if user
|
|
107
|
+
user&.dig('id')
|
|
108
|
+
end
|
|
97
109
|
|
|
98
|
-
|
|
110
|
+
def find_user_by_name(users, username)
|
|
111
|
+
users.find do |u|
|
|
99
112
|
u['name'] == username ||
|
|
100
113
|
u.dig('profile', 'display_name') == username ||
|
|
101
114
|
u.dig('profile', 'real_name') == username
|
|
102
115
|
end
|
|
116
|
+
end
|
|
103
117
|
|
|
104
|
-
|
|
118
|
+
def cache_user(workspace_name, user)
|
|
119
|
+
display_name = extract_display_name(user)
|
|
120
|
+
@cache.set_user(workspace_name, user['id'], display_name, persist: true)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def extract_display_name(user)
|
|
124
|
+
name = user.dig('profile', 'display_name').to_s.strip
|
|
125
|
+
name = user.dig('profile', 'real_name') if name.empty?
|
|
126
|
+
name = user['name'] if name.to_s.empty?
|
|
127
|
+
name
|
|
105
128
|
end
|
|
106
129
|
end
|
|
107
130
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Handles loading tokens from encrypted or plaintext files
|
|
6
|
+
class TokenLoader
|
|
7
|
+
def initialize(encryption:, paths:)
|
|
8
|
+
@encryption = encryption
|
|
9
|
+
@paths = paths
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def load(ssh_key)
|
|
13
|
+
if encrypted_file_exists? && ssh_key
|
|
14
|
+
decrypt_with_key(ssh_key)
|
|
15
|
+
elsif encrypted_file_exists?
|
|
16
|
+
raise EncryptionError, 'Cannot read encrypted tokens without SSH key'
|
|
17
|
+
elsif plain_file_exists?
|
|
18
|
+
parse_plain_file
|
|
19
|
+
else
|
|
20
|
+
{}
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def load_auto(config)
|
|
25
|
+
if encrypted_file_exists?
|
|
26
|
+
load_encrypted(config)
|
|
27
|
+
elsif plain_file_exists?
|
|
28
|
+
parse_plain_file
|
|
29
|
+
else
|
|
30
|
+
{}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def load_encrypted(config)
|
|
35
|
+
raise_missing_key_error unless config.ssh_key
|
|
36
|
+
decrypt_with_key(config.ssh_key)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def raise_missing_key_error
|
|
40
|
+
raise EncryptionError,
|
|
41
|
+
'Cannot read encrypted tokens - no SSH key configured. Run: slk config set ssh_key <path>'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def encrypted_file_exists?
|
|
45
|
+
File.exist?(encrypted_tokens_file)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def plain_file_exists?
|
|
49
|
+
File.exist?(plain_tokens_file)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def encrypted_tokens_file
|
|
53
|
+
@paths.config_file('tokens.age')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def plain_tokens_file
|
|
57
|
+
@paths.config_file('tokens.json')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def decrypt_with_key(ssh_key)
|
|
63
|
+
content = @encryption.decrypt(encrypted_tokens_file, ssh_key)
|
|
64
|
+
if content.nil?
|
|
65
|
+
raise TokenStoreError, "Encrypted tokens file disappeared unexpectedly: #{encrypted_tokens_file}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
JSON.parse(content)
|
|
69
|
+
rescue JSON::ParserError => e
|
|
70
|
+
raise TokenStoreError, "Encrypted tokens file is corrupted: #{e.message}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse_plain_file
|
|
74
|
+
content = File.read(plain_tokens_file)
|
|
75
|
+
JSON.parse(content)
|
|
76
|
+
rescue Errno::ENOENT
|
|
77
|
+
raise TokenStoreError, "Tokens file disappeared unexpectedly: #{plain_tokens_file}"
|
|
78
|
+
rescue JSON::ParserError => e
|
|
79
|
+
raise TokenStoreError, "Tokens file #{plain_tokens_file} is corrupted: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Handles saving tokens to encrypted or plaintext files.
|
|
6
|
+
# Uses atomic writes (temp file + mv) for the token file itself.
|
|
7
|
+
class TokenSaver
|
|
8
|
+
# File system errors we catch and wrap in TokenStoreError
|
|
9
|
+
FILE_ERRORS = [
|
|
10
|
+
Errno::ENOENT,
|
|
11
|
+
Errno::EACCES,
|
|
12
|
+
Errno::EPERM,
|
|
13
|
+
Errno::ENOSPC,
|
|
14
|
+
Errno::EDQUOT,
|
|
15
|
+
Errno::EROFS,
|
|
16
|
+
Errno::EIO
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(encryption:, paths:)
|
|
20
|
+
@encryption = encryption
|
|
21
|
+
@paths = paths
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def save(tokens, ssh_key)
|
|
25
|
+
@paths.ensure_config_dir
|
|
26
|
+
|
|
27
|
+
if ssh_key
|
|
28
|
+
save_encrypted(tokens, ssh_key)
|
|
29
|
+
else
|
|
30
|
+
save_plaintext(tokens)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def save_with_cleanup(tokens, ssh_key)
|
|
35
|
+
@paths.ensure_config_dir
|
|
36
|
+
|
|
37
|
+
if ssh_key
|
|
38
|
+
save_encrypted(tokens, ssh_key)
|
|
39
|
+
FileUtils.rm_f(plain_tokens_file)
|
|
40
|
+
else
|
|
41
|
+
save_plaintext(tokens)
|
|
42
|
+
FileUtils.rm_f(encrypted_tokens_file)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def save_encrypted(tokens, ssh_key)
|
|
49
|
+
temp_file = "#{encrypted_tokens_file}.tmp"
|
|
50
|
+
@encryption.encrypt(JSON.generate(tokens), ssh_key, temp_file)
|
|
51
|
+
FileUtils.mv(temp_file, encrypted_tokens_file)
|
|
52
|
+
FileUtils.rm_f(plain_tokens_file)
|
|
53
|
+
rescue EncryptionError => e
|
|
54
|
+
FileUtils.rm_f(temp_file)
|
|
55
|
+
raise TokenStoreError, "Failed to encrypt tokens: #{e.message}"
|
|
56
|
+
rescue *FILE_ERRORS => e
|
|
57
|
+
FileUtils.rm_f(temp_file)
|
|
58
|
+
raise TokenStoreError, "Failed to save encrypted tokens: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def save_plaintext(tokens)
|
|
62
|
+
temp_file = "#{plain_tokens_file}.tmp"
|
|
63
|
+
File.write(temp_file, JSON.pretty_generate(tokens))
|
|
64
|
+
restrict_file_permissions(temp_file)
|
|
65
|
+
FileUtils.mv(temp_file, plain_tokens_file)
|
|
66
|
+
rescue *FILE_ERRORS => e
|
|
67
|
+
FileUtils.rm_f(temp_file)
|
|
68
|
+
raise TokenStoreError, "Failed to save tokens: #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Restrict file to owner-only access.
|
|
72
|
+
# On Unix: chmod 600. On Windows: chmod is a no-op for security;
|
|
73
|
+
# files in %APPDATA% are already user-private by default.
|
|
74
|
+
def restrict_file_permissions(file)
|
|
75
|
+
File.chmod(0o600, file) unless Gem.win_platform?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def encrypted_tokens_file
|
|
79
|
+
@paths.config_file('tokens.age')
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def plain_tokens_file
|
|
83
|
+
@paths.config_file('tokens.json')
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -1,118 +1,88 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'token_loader'
|
|
4
|
+
require_relative 'token_saver'
|
|
5
|
+
|
|
3
6
|
module Slk
|
|
4
7
|
module Services
|
|
5
8
|
# Manages workspace tokens with optional encryption
|
|
6
9
|
class TokenStore
|
|
7
|
-
attr_accessor :on_warning
|
|
10
|
+
attr_accessor :on_warning, :on_info, :on_prompt_pub_key
|
|
8
11
|
|
|
9
12
|
def initialize(config: nil, encryption: nil, paths: nil)
|
|
10
13
|
@config = config || Configuration.new
|
|
11
14
|
@encryption = encryption || Encryption.new
|
|
12
15
|
@paths = paths || Support::XdgPaths.new
|
|
16
|
+
@loader = TokenLoader.new(encryption: @encryption, paths: @paths)
|
|
17
|
+
@saver = TokenSaver.new(encryption: @encryption, paths: @paths)
|
|
13
18
|
@on_warning = nil
|
|
19
|
+
@on_info = nil
|
|
20
|
+
@on_prompt_pub_key = nil
|
|
14
21
|
end
|
|
15
22
|
|
|
16
23
|
def workspace(name)
|
|
17
|
-
tokens =
|
|
24
|
+
tokens = @loader.load_auto(@config)
|
|
18
25
|
data = tokens[name]
|
|
19
26
|
raise WorkspaceNotFoundError, "Workspace '#{name}' not found" unless data
|
|
20
27
|
|
|
21
|
-
Models::Workspace.new(
|
|
22
|
-
name: name,
|
|
23
|
-
token: data['token'],
|
|
24
|
-
cookie: data['cookie']
|
|
25
|
-
)
|
|
28
|
+
Models::Workspace.new(name: name, token: data['token'], cookie: data['cookie'])
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
def all_workspaces
|
|
29
|
-
|
|
30
|
-
Models::Workspace.new(
|
|
31
|
-
name: name,
|
|
32
|
-
token: data['token'],
|
|
33
|
-
cookie: data['cookie']
|
|
34
|
-
)
|
|
32
|
+
@loader.load_auto(@config).map do |name, data|
|
|
33
|
+
Models::Workspace.new(name: name, token: data['token'], cookie: data['cookie'])
|
|
35
34
|
end
|
|
36
35
|
end
|
|
37
36
|
|
|
38
37
|
def workspace_names
|
|
39
|
-
|
|
38
|
+
@loader.load_auto(@config).keys
|
|
40
39
|
end
|
|
41
40
|
|
|
42
41
|
def exists?(name)
|
|
43
|
-
|
|
42
|
+
@loader.load_auto(@config).key?(name)
|
|
44
43
|
end
|
|
45
44
|
|
|
46
45
|
def add(name, token, cookie = nil)
|
|
47
|
-
# Validate by constructing a Workspace (will raise ArgumentError if invalid)
|
|
48
46
|
Models::Workspace.new(name: name, token: token, cookie: cookie)
|
|
49
|
-
|
|
50
|
-
tokens = load_tokens
|
|
47
|
+
tokens = @loader.load_auto(@config)
|
|
51
48
|
tokens[name] = { 'token' => token, 'cookie' => cookie }.compact
|
|
52
|
-
|
|
49
|
+
@saver.save(tokens, @config.ssh_key)
|
|
53
50
|
end
|
|
54
51
|
|
|
55
52
|
def remove(name) # rubocop:disable Naming/PredicateMethod
|
|
56
|
-
tokens =
|
|
53
|
+
tokens = @loader.load_auto(@config)
|
|
57
54
|
removed = tokens.delete(name)
|
|
58
|
-
|
|
55
|
+
@saver.save(tokens, @config.ssh_key) if removed
|
|
59
56
|
!removed.nil?
|
|
60
57
|
end
|
|
61
58
|
|
|
62
59
|
def empty?
|
|
63
|
-
|
|
60
|
+
@loader.load_auto(@config).empty?
|
|
64
61
|
end
|
|
65
62
|
|
|
66
|
-
|
|
63
|
+
def migrate_encryption(old_ssh_key, new_ssh_key)
|
|
64
|
+
return if old_ssh_key == new_ssh_key
|
|
67
65
|
|
|
68
|
-
|
|
69
|
-
if
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
end
|
|
76
|
-
rescue JSON::ParserError => e
|
|
77
|
-
raise TokenStoreError, "Tokens file #{plain_tokens_file} is corrupted: #{e.message}"
|
|
66
|
+
tokens = @loader.load(old_ssh_key)
|
|
67
|
+
return if tokens.empty?
|
|
68
|
+
|
|
69
|
+
@encryption.on_prompt_pub_key = @on_prompt_pub_key
|
|
70
|
+
@encryption.validate_key_type!(new_ssh_key) if new_ssh_key
|
|
71
|
+
@saver.save_with_cleanup(tokens, new_ssh_key)
|
|
72
|
+
notify_encryption_change(old_ssh_key, new_ssh_key)
|
|
78
73
|
end
|
|
79
74
|
|
|
80
|
-
|
|
81
|
-
@paths.ensure_config_dir
|
|
75
|
+
private
|
|
82
76
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
@
|
|
86
|
-
|
|
77
|
+
def notify_encryption_change(old_ssh_key, new_ssh_key)
|
|
78
|
+
if new_ssh_key && old_ssh_key
|
|
79
|
+
@on_info&.call('Tokens have been re-encrypted with the new SSH key.')
|
|
80
|
+
elsif new_ssh_key
|
|
81
|
+
@on_info&.call('Tokens have been encrypted with the new SSH key.')
|
|
87
82
|
else
|
|
88
|
-
|
|
89
|
-
File.write(plain_tokens_file, JSON.pretty_generate(tokens))
|
|
90
|
-
File.chmod(0o600, plain_tokens_file)
|
|
83
|
+
@on_warning&.call('Tokens are now stored in plaintext.')
|
|
91
84
|
end
|
|
92
85
|
end
|
|
93
|
-
|
|
94
|
-
def decrypt_tokens
|
|
95
|
-
content = @encryption.decrypt(encrypted_tokens_file, @config.ssh_key)
|
|
96
|
-
content ? JSON.parse(content) : {}
|
|
97
|
-
rescue JSON::ParserError => e
|
|
98
|
-
raise TokenStoreError, "Encrypted tokens file is corrupted: #{e.message}"
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def encrypted_file_exists?
|
|
102
|
-
File.exist?(encrypted_tokens_file)
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def plain_file_exists?
|
|
106
|
-
File.exist?(plain_tokens_file)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def encrypted_tokens_file
|
|
110
|
-
@paths.config_file('tokens.age')
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def plain_tokens_file
|
|
114
|
-
@paths.config_file('tokens.json')
|
|
115
|
-
end
|
|
116
86
|
end
|
|
117
87
|
end
|
|
118
88
|
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Consolidated service for user name resolution
|
|
6
|
+
# Provides ID → name and name → ID lookups with caching
|
|
7
|
+
class UserLookup
|
|
8
|
+
def initialize(cache_store:, workspace:, api_client: nil, on_debug: nil)
|
|
9
|
+
@cache = cache_store
|
|
10
|
+
@api = api_client
|
|
11
|
+
@workspace = workspace
|
|
12
|
+
@on_debug = on_debug
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Resolve user ID to display name (most common use case)
|
|
16
|
+
# @param user_id [String] Slack user ID (e.g., "U123ABC")
|
|
17
|
+
# @return [String, nil] Display name or nil if not found
|
|
18
|
+
def resolve_name(user_id)
|
|
19
|
+
return nil if user_id.to_s.empty?
|
|
20
|
+
|
|
21
|
+
cached = @cache.get_user(@workspace.name, user_id)
|
|
22
|
+
return cached if cached
|
|
23
|
+
|
|
24
|
+
fetch_and_cache_name(user_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Resolve user ID to display name, handling bots
|
|
28
|
+
# @param user_id [String] Slack user or bot ID
|
|
29
|
+
# @return [String, nil] Display name or nil if not found
|
|
30
|
+
def resolve_name_or_bot(user_id)
|
|
31
|
+
return nil if user_id.to_s.empty?
|
|
32
|
+
|
|
33
|
+
cached = @cache.get_user(@workspace.name, user_id)
|
|
34
|
+
return cached if cached
|
|
35
|
+
|
|
36
|
+
if user_id.start_with?('B')
|
|
37
|
+
fetch_and_cache_bot_name(user_id)
|
|
38
|
+
else
|
|
39
|
+
fetch_and_cache_name(user_id)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Find user ID by display name (reverse lookup)
|
|
44
|
+
# @param name [String] Display name to search for
|
|
45
|
+
# @return [String, nil] User ID or nil if not found
|
|
46
|
+
def find_id_by_name(name)
|
|
47
|
+
return nil if name.to_s.empty?
|
|
48
|
+
|
|
49
|
+
cached = @cache.get_user_id_by_name(@workspace.name, name)
|
|
50
|
+
return cached if cached
|
|
51
|
+
|
|
52
|
+
fetch_id_by_name(name)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def fetch_and_cache_name(user_id)
|
|
58
|
+
return nil unless @api
|
|
59
|
+
|
|
60
|
+
user = fetch_user(user_id)
|
|
61
|
+
return nil unless user
|
|
62
|
+
|
|
63
|
+
@cache.set_user(@workspace.name, user_id, user.best_name, persist: true)
|
|
64
|
+
user.best_name
|
|
65
|
+
rescue ApiError => e
|
|
66
|
+
@on_debug&.call("User lookup failed for #{user_id}: #{e.message}")
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def fetch_and_cache_bot_name(bot_id)
|
|
71
|
+
return nil unless @api
|
|
72
|
+
|
|
73
|
+
bots_api = Api::Bots.new(@api, @workspace, on_debug: @on_debug)
|
|
74
|
+
name = bots_api.get_name(bot_id)
|
|
75
|
+
@cache.set_user(@workspace.name, bot_id, name, persist: true) if name
|
|
76
|
+
name
|
|
77
|
+
rescue ApiError => e
|
|
78
|
+
@on_debug&.call("Bot lookup failed for #{bot_id}: #{e.message}")
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def fetch_user(user_id)
|
|
83
|
+
users_api = Api::Users.new(@api, @workspace, on_debug: @on_debug)
|
|
84
|
+
response = users_api.info(user_id)
|
|
85
|
+
return nil unless response['ok'] && response['user']
|
|
86
|
+
|
|
87
|
+
Models::User.from_api(response['user'])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def fetch_id_by_name(name)
|
|
91
|
+
return nil unless @api
|
|
92
|
+
|
|
93
|
+
users_api = Api::Users.new(@api, @workspace, on_debug: @on_debug)
|
|
94
|
+
users = users_api.list['members'] || []
|
|
95
|
+
user = find_user_by_name(users, name)
|
|
96
|
+
cache_user_from_api(user) if user
|
|
97
|
+
user&.dig('id')
|
|
98
|
+
rescue ApiError => e
|
|
99
|
+
@on_debug&.call("User list lookup failed: #{e.message}")
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def find_user_by_name(users, name)
|
|
104
|
+
users.find do |u|
|
|
105
|
+
u['name'] == name ||
|
|
106
|
+
u.dig('profile', 'display_name') == name ||
|
|
107
|
+
u.dig('profile', 'real_name') == name
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def cache_user_from_api(user_data)
|
|
112
|
+
user = Models::User.from_api(user_data)
|
|
113
|
+
@cache.set_user(@workspace.name, user.id, user.best_name, persist: true)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|