freshbooks-cli 0.3.2 → 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/bin/fb +2 -2
- data/lib/freshbooks/api.rb +325 -0
- data/lib/freshbooks/auth.rb +484 -0
- data/lib/freshbooks/cli.rb +1106 -0
- data/lib/freshbooks/spinner.rb +50 -0
- data/lib/freshbooks/version.rb +7 -0
- data/lib/freshbooks.rb +7 -0
- metadata +22 -8
- data/lib/fb/api.rb +0 -301
- data/lib/fb/auth.rb +0 -379
- data/lib/fb/cli.rb +0 -1060
- data/lib/fb/spinner.rb +0 -48
- data/lib/fb/version.rb +0 -5
- data/lib/fb.rb +0 -7
data/lib/fb/auth.rb
DELETED
|
@@ -1,379 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "httparty"
|
|
4
|
-
require "json"
|
|
5
|
-
require "uri"
|
|
6
|
-
require "fileutils"
|
|
7
|
-
|
|
8
|
-
module FB
|
|
9
|
-
class Auth
|
|
10
|
-
TOKEN_URL = "https://api.freshbooks.com/auth/oauth/token"
|
|
11
|
-
AUTH_URL = "https://auth.freshbooks.com/oauth/authorize"
|
|
12
|
-
ME_URL = "https://api.freshbooks.com/auth/api/v1/users/me"
|
|
13
|
-
REDIRECT_URI = "https://localhost"
|
|
14
|
-
REQUIRED_SCOPES = %w[
|
|
15
|
-
user:profile:read
|
|
16
|
-
user:clients:read
|
|
17
|
-
user:projects:read
|
|
18
|
-
user:billable_items:read
|
|
19
|
-
user:time_entries:read
|
|
20
|
-
user:time_entries:write
|
|
21
|
-
].freeze
|
|
22
|
-
|
|
23
|
-
class << self
|
|
24
|
-
def data_dir
|
|
25
|
-
@data_dir ||= File.join(Dir.home, ".fb")
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def data_dir=(path)
|
|
29
|
-
@data_dir = path
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def config_path
|
|
33
|
-
File.join(data_dir, "config.json")
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def tokens_path
|
|
37
|
-
File.join(data_dir, "tokens.json")
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def defaults_path
|
|
41
|
-
File.join(data_dir, "defaults.json")
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def cache_path
|
|
45
|
-
File.join(data_dir, "cache.json")
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def ensure_data_dir
|
|
49
|
-
FileUtils.mkdir_p(data_dir)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# --- Config ---
|
|
53
|
-
|
|
54
|
-
def load_config
|
|
55
|
-
return nil unless File.exist?(config_path)
|
|
56
|
-
contents = File.read(config_path).strip
|
|
57
|
-
return nil if contents.empty?
|
|
58
|
-
config = JSON.parse(contents)
|
|
59
|
-
return nil unless config["client_id"] && config["client_secret"]
|
|
60
|
-
config
|
|
61
|
-
rescue JSON::ParserError
|
|
62
|
-
nil
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def save_config(config)
|
|
66
|
-
ensure_data_dir
|
|
67
|
-
File.write(config_path, JSON.pretty_generate(config) + "\n")
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def setup_config
|
|
71
|
-
puts "Welcome to FreshBooks CLI setup!\n\n"
|
|
72
|
-
puts "You need a FreshBooks Developer App. Create one at:"
|
|
73
|
-
puts " https://my.freshbooks.com/#/developer\n\n"
|
|
74
|
-
puts "Set the redirect URI to: #{REDIRECT_URI}\n\n"
|
|
75
|
-
puts "Required scopes:"
|
|
76
|
-
puts " user:profile:read (enabled by default)"
|
|
77
|
-
puts " user:clients:read"
|
|
78
|
-
puts " user:projects:read"
|
|
79
|
-
puts " user:billable_items:read"
|
|
80
|
-
puts " user:time_entries:read"
|
|
81
|
-
puts " user:time_entries:write\n\n"
|
|
82
|
-
|
|
83
|
-
print "Client ID: "
|
|
84
|
-
client_id = $stdin.gets&.strip
|
|
85
|
-
abort("Aborted.") if client_id.nil? || client_id.empty?
|
|
86
|
-
|
|
87
|
-
print "Client Secret: "
|
|
88
|
-
client_secret = $stdin.gets&.strip
|
|
89
|
-
abort("Aborted.") if client_secret.nil? || client_secret.empty?
|
|
90
|
-
|
|
91
|
-
config = { "client_id" => client_id, "client_secret" => client_secret }
|
|
92
|
-
save_config(config)
|
|
93
|
-
puts "\nConfig saved to #{config_path}"
|
|
94
|
-
config
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def setup_config_from_args(client_id, client_secret)
|
|
98
|
-
abort("Missing --client-id") if client_id.nil? || client_id.empty?
|
|
99
|
-
abort("Missing --client-secret") if client_secret.nil? || client_secret.empty?
|
|
100
|
-
|
|
101
|
-
config = { "client_id" => client_id, "client_secret" => client_secret }
|
|
102
|
-
save_config(config)
|
|
103
|
-
config
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def authorize_url(config)
|
|
107
|
-
"#{AUTH_URL}?client_id=#{config["client_id"]}&response_type=code&redirect_uri=#{URI.encode_www_form_component(REDIRECT_URI)}"
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def extract_code_from_url(redirect_url)
|
|
111
|
-
uri = URI.parse(redirect_url)
|
|
112
|
-
params = URI.decode_www_form(uri.query || "").to_h
|
|
113
|
-
params["code"]
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def auth_status
|
|
117
|
-
config = load_config
|
|
118
|
-
tokens = load_tokens
|
|
119
|
-
{
|
|
120
|
-
"config_exists" => !config.nil?,
|
|
121
|
-
"config_path" => config_path,
|
|
122
|
-
"tokens_exist" => !tokens.nil?,
|
|
123
|
-
"tokens_expired" => tokens ? token_expired?(tokens) : nil,
|
|
124
|
-
"business_id" => config&.dig("business_id"),
|
|
125
|
-
"account_id" => config&.dig("account_id")
|
|
126
|
-
}
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
def fetch_businesses(access_token)
|
|
130
|
-
identity = fetch_identity(access_token)
|
|
131
|
-
memberships = identity.dig("business_memberships") || []
|
|
132
|
-
memberships.select { |m| m.dig("business", "account_id") }
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def select_business(config, business_id, businesses)
|
|
136
|
-
selected = businesses.find { |m| m.dig("business", "id").to_s == business_id.to_s }
|
|
137
|
-
abort("Business not found: #{business_id}. Available: #{businesses.map { |m| "#{m.dig("business", "name")} (#{m.dig("business", "id")})" }.join(", ")}") unless selected
|
|
138
|
-
|
|
139
|
-
biz = selected["business"]
|
|
140
|
-
config["business_id"] = biz["id"]
|
|
141
|
-
config["account_id"] = biz["account_id"]
|
|
142
|
-
save_config(config)
|
|
143
|
-
config
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def require_config
|
|
147
|
-
config = load_config
|
|
148
|
-
return config if config
|
|
149
|
-
|
|
150
|
-
puts "No config found. Let's set up FreshBooks CLI.\n\n"
|
|
151
|
-
setup_config
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# --- Tokens ---
|
|
155
|
-
|
|
156
|
-
def load_tokens
|
|
157
|
-
return nil unless File.exist?(tokens_path)
|
|
158
|
-
JSON.parse(File.read(tokens_path))
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def save_tokens(tokens)
|
|
162
|
-
ensure_data_dir
|
|
163
|
-
File.write(tokens_path, JSON.pretty_generate(tokens) + "\n")
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def token_expired?(tokens)
|
|
167
|
-
return true unless tokens
|
|
168
|
-
created = tokens["created_at"] || 0
|
|
169
|
-
expires_in = tokens["expires_in"] || 0
|
|
170
|
-
Time.now.to_i >= (created + expires_in - 60)
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def refresh_token!(config, tokens)
|
|
174
|
-
response = HTTParty.post(TOKEN_URL, {
|
|
175
|
-
headers: { "Content-Type" => "application/json" },
|
|
176
|
-
body: {
|
|
177
|
-
grant_type: "refresh_token",
|
|
178
|
-
client_id: config["client_id"],
|
|
179
|
-
client_secret: config["client_secret"],
|
|
180
|
-
redirect_uri: REDIRECT_URI,
|
|
181
|
-
refresh_token: tokens["refresh_token"]
|
|
182
|
-
}.to_json
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
unless response.success?
|
|
186
|
-
body = response.parsed_response
|
|
187
|
-
msg = body.is_a?(Hash) ? (body["error_description"] || body["error"] || response.body) : response.body
|
|
188
|
-
abort("Token refresh failed: #{msg}\nPlease re-run: fb auth")
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
data = response.parsed_response
|
|
192
|
-
new_tokens = {
|
|
193
|
-
"access_token" => data["access_token"],
|
|
194
|
-
"refresh_token" => data["refresh_token"],
|
|
195
|
-
"expires_in" => data["expires_in"],
|
|
196
|
-
"created_at" => Time.now.to_i
|
|
197
|
-
}
|
|
198
|
-
save_tokens(new_tokens)
|
|
199
|
-
new_tokens
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def valid_access_token
|
|
203
|
-
config = require_config
|
|
204
|
-
tokens = load_tokens
|
|
205
|
-
|
|
206
|
-
unless tokens
|
|
207
|
-
puts "Not authenticated yet. Starting auth flow...\n\n"
|
|
208
|
-
tokens = authorize(config)
|
|
209
|
-
discover_business(tokens["access_token"], config)
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
if token_expired?(tokens)
|
|
213
|
-
puts "Token expired, refreshing..."
|
|
214
|
-
tokens = refresh_token!(config, tokens)
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
tokens["access_token"]
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
def require_business(config)
|
|
221
|
-
return config if config["business_id"] && config["account_id"]
|
|
222
|
-
|
|
223
|
-
tokens = load_tokens
|
|
224
|
-
unless tokens
|
|
225
|
-
puts "Not authenticated yet. Starting auth flow...\n\n"
|
|
226
|
-
tokens = authorize(config)
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
discover_business(tokens["access_token"], config)
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
# --- OAuth Flow ---
|
|
233
|
-
|
|
234
|
-
def authorize(config)
|
|
235
|
-
url = "#{AUTH_URL}?client_id=#{config["client_id"]}&response_type=code&redirect_uri=#{URI.encode_www_form_component(REDIRECT_URI)}"
|
|
236
|
-
|
|
237
|
-
puts "Open this URL in your browser:\n\n"
|
|
238
|
-
puts " #{url}\n\n"
|
|
239
|
-
puts "After authorizing, you'll be redirected to a URL that fails to load."
|
|
240
|
-
puts "Copy the full URL from your browser's address bar and paste it here.\n\n"
|
|
241
|
-
|
|
242
|
-
print "Redirect URL: "
|
|
243
|
-
redirect_url = $stdin.gets&.strip
|
|
244
|
-
abort("Aborted.") if redirect_url.nil? || redirect_url.empty?
|
|
245
|
-
|
|
246
|
-
uri = URI.parse(redirect_url)
|
|
247
|
-
params = URI.decode_www_form(uri.query || "").to_h
|
|
248
|
-
code = params["code"]
|
|
249
|
-
|
|
250
|
-
abort("Could not find 'code' parameter in the URL.") unless code
|
|
251
|
-
|
|
252
|
-
exchange_code(config, code)
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
def exchange_code(config, code)
|
|
256
|
-
response = HTTParty.post(TOKEN_URL, {
|
|
257
|
-
headers: { "Content-Type" => "application/json" },
|
|
258
|
-
body: {
|
|
259
|
-
grant_type: "authorization_code",
|
|
260
|
-
client_id: config["client_id"],
|
|
261
|
-
client_secret: config["client_secret"],
|
|
262
|
-
redirect_uri: REDIRECT_URI,
|
|
263
|
-
code: code
|
|
264
|
-
}.to_json
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
unless response.success?
|
|
268
|
-
body = response.parsed_response
|
|
269
|
-
msg = body.is_a?(Hash) ? (body["error_description"] || body["error"] || response.body) : response.body
|
|
270
|
-
abort("Token exchange failed: #{msg}")
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
data = response.parsed_response
|
|
274
|
-
check_scopes(data["scope"])
|
|
275
|
-
|
|
276
|
-
tokens = {
|
|
277
|
-
"access_token" => data["access_token"],
|
|
278
|
-
"refresh_token" => data["refresh_token"],
|
|
279
|
-
"expires_in" => data["expires_in"],
|
|
280
|
-
"created_at" => Time.now.to_i
|
|
281
|
-
}
|
|
282
|
-
save_tokens(tokens)
|
|
283
|
-
puts "Authentication successful!"
|
|
284
|
-
tokens
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
def check_scopes(granted_scope)
|
|
288
|
-
return if granted_scope.nil? # skip check if API doesn't return scopes
|
|
289
|
-
|
|
290
|
-
granted = granted_scope.split(" ")
|
|
291
|
-
missing = REQUIRED_SCOPES - granted
|
|
292
|
-
|
|
293
|
-
return if missing.empty?
|
|
294
|
-
|
|
295
|
-
puts "ERROR: Your FreshBooks app is missing required scopes:\n\n"
|
|
296
|
-
missing.each { |s| puts " - #{s}" }
|
|
297
|
-
puts "\nAdd them at https://my.freshbooks.com/#/developer"
|
|
298
|
-
puts "then re-run: fb auth"
|
|
299
|
-
abort
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
# --- Business Discovery ---
|
|
303
|
-
|
|
304
|
-
def fetch_identity(access_token)
|
|
305
|
-
response = HTTParty.get(ME_URL, {
|
|
306
|
-
headers: { "Authorization" => "Bearer #{access_token}" }
|
|
307
|
-
})
|
|
308
|
-
|
|
309
|
-
unless response.success?
|
|
310
|
-
abort("Failed to fetch user identity: #{response.body}")
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
response.parsed_response["response"]
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
def discover_business(access_token, config)
|
|
317
|
-
identity = fetch_identity(access_token)
|
|
318
|
-
memberships = identity.dig("business_memberships") || []
|
|
319
|
-
businesses = memberships.select { |m| m.dig("business", "account_id") }
|
|
320
|
-
|
|
321
|
-
if businesses.empty?
|
|
322
|
-
abort("No business memberships found on your FreshBooks account.")
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
selected = if businesses.length == 1
|
|
326
|
-
businesses.first
|
|
327
|
-
else
|
|
328
|
-
puts "\nMultiple businesses found:\n\n"
|
|
329
|
-
businesses.each_with_index do |m, i|
|
|
330
|
-
biz = m["business"]
|
|
331
|
-
puts " #{i + 1}. #{biz["name"]} (ID: #{biz["id"]})"
|
|
332
|
-
end
|
|
333
|
-
print "\nSelect a business (1-#{businesses.length}): "
|
|
334
|
-
choice = $stdin.gets&.strip&.to_i || 1
|
|
335
|
-
choice = 1 if choice < 1 || choice > businesses.length
|
|
336
|
-
businesses[choice - 1]
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
biz = selected["business"]
|
|
340
|
-
config["business_id"] = biz["id"]
|
|
341
|
-
config["account_id"] = biz["account_id"]
|
|
342
|
-
save_config(config)
|
|
343
|
-
|
|
344
|
-
puts "Business: #{biz["name"]}"
|
|
345
|
-
puts " business_id: #{biz["id"]}"
|
|
346
|
-
puts " account_id: #{biz["account_id"]}"
|
|
347
|
-
config
|
|
348
|
-
end
|
|
349
|
-
|
|
350
|
-
# --- Defaults ---
|
|
351
|
-
|
|
352
|
-
def load_defaults
|
|
353
|
-
return {} unless File.exist?(defaults_path)
|
|
354
|
-
JSON.parse(File.read(defaults_path))
|
|
355
|
-
rescue JSON::ParserError
|
|
356
|
-
{}
|
|
357
|
-
end
|
|
358
|
-
|
|
359
|
-
def save_defaults(defaults)
|
|
360
|
-
ensure_data_dir
|
|
361
|
-
File.write(defaults_path, JSON.pretty_generate(defaults) + "\n")
|
|
362
|
-
end
|
|
363
|
-
|
|
364
|
-
# --- Cache ---
|
|
365
|
-
|
|
366
|
-
def load_cache
|
|
367
|
-
return {} unless File.exist?(cache_path)
|
|
368
|
-
JSON.parse(File.read(cache_path))
|
|
369
|
-
rescue JSON::ParserError
|
|
370
|
-
{}
|
|
371
|
-
end
|
|
372
|
-
|
|
373
|
-
def save_cache(cache)
|
|
374
|
-
ensure_data_dir
|
|
375
|
-
File.write(cache_path, JSON.pretty_generate(cache) + "\n")
|
|
376
|
-
end
|
|
377
|
-
end
|
|
378
|
-
end
|
|
379
|
-
end
|