freshbooks-cli 0.3.3 → 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.
@@ -0,0 +1,484 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httparty"
4
+ require "json"
5
+ require "uri"
6
+ require "fileutils"
7
+ require "dotenv"
8
+
9
+ module FreshBooks
10
+ module CLI
11
+ class Auth
12
+ TOKEN_URL = "https://api.freshbooks.com/auth/oauth/token"
13
+ AUTH_URL = "https://auth.freshbooks.com/oauth/authorize"
14
+ ME_URL = "https://api.freshbooks.com/auth/api/v1/users/me"
15
+ REDIRECT_URI = "https://localhost"
16
+ REQUIRED_SCOPES = %w[
17
+ user:profile:read
18
+ user:clients:read
19
+ user:projects:read
20
+ user:billable_items:read
21
+ user:time_entries:read
22
+ user:time_entries:write
23
+ ].freeze
24
+
25
+ class << self
26
+ def data_dir
27
+ return @data_dir unless @data_dir.nil?
28
+ resolve_data_dir
29
+ end
30
+
31
+ def data_dir=(path)
32
+ @data_dir = path
33
+ end
34
+
35
+ private
36
+
37
+ def macos?
38
+ RUBY_PLATFORM.include?("darwin")
39
+ end
40
+
41
+ def resolve_data_dir
42
+ return ENV["FRESHBOOKS_HOME"] if ENV["FRESHBOOKS_HOME"]
43
+ legacy = File.join(Dir.home, ".fb")
44
+ return legacy if File.exist?(legacy)
45
+ if macos?
46
+ File.join(Dir.home, "Library", "Application Support", "freshbooks")
47
+ else
48
+ xdg_base = ENV["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config")
49
+ File.join(xdg_base, "freshbooks")
50
+ end
51
+ end
52
+
53
+ public
54
+
55
+ def config_path
56
+ File.join(data_dir, "config.json")
57
+ end
58
+
59
+ def tokens_path
60
+ File.join(data_dir, "tokens.json")
61
+ end
62
+
63
+ def defaults_path
64
+ File.join(data_dir, "defaults.json")
65
+ end
66
+
67
+ def cache_path
68
+ File.join(data_dir, "cache.json")
69
+ end
70
+
71
+ def ensure_data_dir
72
+ FileUtils.mkdir_p(data_dir)
73
+ end
74
+
75
+ # --- Config ---
76
+
77
+ def load_config
78
+ load_dotenv
79
+ client_id = ENV["FRESHBOOKS_CLIENT_ID"]&.strip
80
+ client_secret = ENV["FRESHBOOKS_CLIENT_SECRET"]&.strip
81
+ return nil if client_id.nil? || client_id.empty? || client_secret.nil? || client_secret.empty?
82
+ config = { "client_id" => client_id, "client_secret" => client_secret }
83
+ if File.exist?(config_path)
84
+ begin
85
+ file_config = JSON.parse(File.read(config_path).strip)
86
+ config["business_id"] = file_config["business_id"] if file_config["business_id"]
87
+ config["account_id"] = file_config["account_id"] if file_config["account_id"]
88
+ rescue JSON::ParserError
89
+ end
90
+ end
91
+ config
92
+ end
93
+
94
+ def save_config(config)
95
+ ensure_data_dir
96
+ safe_config = config.reject { |k, _| ["client_id", "client_secret"].include?(k) }
97
+ File.write(config_path, JSON.pretty_generate(safe_config) + "\n")
98
+ end
99
+
100
+ def setup_config
101
+ puts "Welcome to FreshBooks CLI setup!\n\n"
102
+ puts "You need a FreshBooks Developer App. Create one at:"
103
+ puts " https://my.freshbooks.com/#/developer\n\n"
104
+ puts "Set the redirect URI to: #{REDIRECT_URI}\n\n"
105
+ puts "Required scopes:"
106
+ puts " user:profile:read (enabled by default)"
107
+ puts " user:clients:read"
108
+ puts " user:projects:read"
109
+ puts " user:billable_items:read"
110
+ puts " user:time_entries:read"
111
+ puts " user:time_entries:write\n\n"
112
+
113
+ print "Client ID: "
114
+ client_id = $stdin.gets&.strip
115
+ abort("Aborted.") if client_id.nil? || client_id.empty?
116
+
117
+ print "Client Secret: "
118
+ client_secret = IO.console.getpass("")
119
+ abort("Aborted.") if client_secret.nil? || client_secret.empty?
120
+
121
+ env_path = File.join(data_dir, ".env")
122
+ if File.exist?(env_path) && File.read(env_path).match?(/^FRESHBOOKS_CLIENT_ID=/)
123
+ print "\nCredentials already exist in #{env_path}. Overwrite? (y/n): "
124
+ answer = $stdin.gets&.strip&.downcase
125
+ abort("Aborted.") unless answer == "y"
126
+ contents = File.read(env_path)
127
+ contents = contents.gsub(/^FRESHBOOKS_CLIENT_ID=.*$/, "FRESHBOOKS_CLIENT_ID=#{client_id}")
128
+ contents = contents.gsub(/^FRESHBOOKS_CLIENT_SECRET=.*$/, "FRESHBOOKS_CLIENT_SECRET=#{client_secret}")
129
+ File.write(env_path, contents)
130
+ else
131
+ write_credentials_to_env(env_path, client_id, client_secret)
132
+ end
133
+
134
+ puts "\nCredentials saved to #{env_path}"
135
+ { "client_id" => client_id, "client_secret" => client_secret }
136
+ end
137
+
138
+ def load_dotenv
139
+ migrate_credentials_from_config
140
+ dot_env_paths = [
141
+ File.join(data_dir, ".env"),
142
+ File.join(Dir.pwd, ".env")
143
+ ].select { |p| File.exist?(p) }
144
+ Dotenv.load(*dot_env_paths) unless dot_env_paths.empty?
145
+ end
146
+
147
+ def migrate_credentials_from_config
148
+ return unless File.exist?(config_path)
149
+ contents = File.read(config_path).strip
150
+ return if contents.empty?
151
+ config = JSON.parse(contents) rescue {}
152
+ client_id = config["client_id"]
153
+ client_secret = config["client_secret"]
154
+ return unless client_id || client_secret
155
+ write_credentials_to_env(File.join(data_dir, ".env"), client_id.to_s, client_secret.to_s)
156
+ safe_config = config.reject { |k, _| ["client_id", "client_secret"].include?(k) }
157
+ File.write(config_path, JSON.pretty_generate(safe_config) + "\n")
158
+ end
159
+
160
+ def write_credentials_to_env(env_path, client_id, client_secret)
161
+ ensure_data_dir
162
+ if File.exist?(env_path)
163
+ contents = File.read(env_path)
164
+ append = ""
165
+ append += "FRESHBOOKS_CLIENT_ID=#{client_id}\n" unless contents.match?(/^FRESHBOOKS_CLIENT_ID=/)
166
+ append += "FRESHBOOKS_CLIENT_SECRET=#{client_secret}\n" unless contents.match?(/^FRESHBOOKS_CLIENT_SECRET=/)
167
+ File.open(env_path, "a") { |f| f.write(append) } unless append.empty?
168
+ else
169
+ File.write(env_path, "FRESHBOOKS_CLIENT_ID=#{client_id}\nFRESHBOOKS_CLIENT_SECRET=#{client_secret}\n")
170
+ end
171
+ end
172
+
173
+ def setup_config_from_args
174
+ load_dotenv
175
+
176
+ client_id = ENV["FRESHBOOKS_CLIENT_ID"]
177
+ client_secret = ENV["FRESHBOOKS_CLIENT_SECRET"]
178
+
179
+ if client_id.nil? || client_id.strip.empty?
180
+ abort("Missing FRESHBOOKS_CLIENT_ID. Set it via:\n export FRESHBOOKS_CLIENT_ID=your_id\n or add it to #{data_dir}/.env")
181
+ end
182
+
183
+ if client_secret.nil? || client_secret.strip.empty?
184
+ abort("Missing FRESHBOOKS_CLIENT_SECRET. Set it via:\n export FRESHBOOKS_CLIENT_SECRET=your_secret\n or add it to #{data_dir}/.env")
185
+ end
186
+
187
+ { "client_id" => client_id.strip, "client_secret" => client_secret.strip }
188
+ end
189
+
190
+ def authorize_url(config)
191
+ "#{AUTH_URL}?client_id=#{config["client_id"]}&response_type=code&redirect_uri=#{URI.encode_www_form_component(REDIRECT_URI)}"
192
+ end
193
+
194
+ def extract_code_from_url(redirect_url)
195
+ uri = URI.parse(redirect_url)
196
+ params = URI.decode_www_form(uri.query || "").to_h
197
+ params["code"]
198
+ end
199
+
200
+ def auth_status
201
+ config = load_config
202
+ tokens = load_tokens
203
+ {
204
+ "config_exists" => !config.nil?,
205
+ "config_path" => config_path,
206
+ "tokens_exist" => !tokens.nil?,
207
+ "tokens_expired" => tokens ? token_expired?(tokens) : nil,
208
+ "business_id" => config&.dig("business_id"),
209
+ "account_id" => config&.dig("account_id")
210
+ }
211
+ end
212
+
213
+ def fetch_businesses(access_token)
214
+ identity = fetch_identity(access_token)
215
+ memberships = identity.dig("business_memberships") || []
216
+ memberships.select { |m| m.dig("business", "account_id") }
217
+ end
218
+
219
+ def select_business(config, business_id, businesses)
220
+ selected = businesses.find { |m| m.dig("business", "id").to_s == business_id.to_s }
221
+ abort("Business not found: #{business_id}. Available: #{businesses.map { |m| "#{m.dig("business", "name")} (#{m.dig("business", "id")})" }.join(", ")}") unless selected
222
+
223
+ biz = selected["business"]
224
+ config["business_id"] = biz["id"]
225
+ config["account_id"] = biz["account_id"]
226
+ save_config(config)
227
+ config
228
+ end
229
+
230
+ def require_config
231
+ if Thread.current[:fb_dry_run]
232
+ config = {}
233
+ if File.exist?(config_path)
234
+ begin
235
+ config = JSON.parse(File.read(config_path).strip)
236
+ rescue JSON::ParserError
237
+ config = {}
238
+ end
239
+ end
240
+ config["business_id"] ||= "0"
241
+ config["account_id"] ||= "0"
242
+ return config
243
+ end
244
+
245
+ config = load_config
246
+ return config if config
247
+
248
+ puts "No config found. Let's set up FreshBooks CLI.\n\n"
249
+ setup_config
250
+ end
251
+
252
+ # --- Tokens ---
253
+
254
+ def load_tokens
255
+ return nil unless File.exist?(tokens_path)
256
+ JSON.parse(File.read(tokens_path))
257
+ end
258
+
259
+ def save_tokens(tokens)
260
+ ensure_data_dir
261
+ File.write(tokens_path, JSON.pretty_generate(tokens) + "\n")
262
+ end
263
+
264
+ def token_expired?(tokens)
265
+ return true unless tokens
266
+ created = tokens["created_at"] || 0
267
+ expires_in = tokens["expires_in"] || 0
268
+ Time.now.to_i >= (created + expires_in - 60)
269
+ end
270
+
271
+ def refresh_token!(config, tokens)
272
+ response = HTTParty.post(TOKEN_URL, {
273
+ headers: { "Content-Type" => "application/json" },
274
+ body: {
275
+ grant_type: "refresh_token",
276
+ client_id: config["client_id"],
277
+ client_secret: config["client_secret"],
278
+ redirect_uri: REDIRECT_URI,
279
+ refresh_token: tokens["refresh_token"]
280
+ }.to_json
281
+ })
282
+
283
+ unless response.success?
284
+ body = response.parsed_response
285
+ msg = body.is_a?(Hash) ? (body["error_description"] || body["error"] || response.body) : response.body
286
+ abort("Token refresh failed: #{msg}\nPlease re-run: fb auth")
287
+ end
288
+
289
+ data = response.parsed_response
290
+ new_tokens = {
291
+ "access_token" => data["access_token"],
292
+ "refresh_token" => data["refresh_token"],
293
+ "expires_in" => data["expires_in"],
294
+ "created_at" => Time.now.to_i
295
+ }
296
+ save_tokens(new_tokens)
297
+ new_tokens
298
+ end
299
+
300
+ def valid_access_token
301
+ if Thread.current[:fb_dry_run]
302
+ tokens = load_tokens
303
+ return tokens["access_token"] if tokens && !token_expired?(tokens)
304
+ return "dry-run-token"
305
+ end
306
+
307
+ config = require_config
308
+ tokens = load_tokens
309
+
310
+ unless tokens
311
+ puts "Not authenticated yet. Starting auth flow...\n\n"
312
+ tokens = authorize(config)
313
+ discover_business(tokens["access_token"], config)
314
+ end
315
+
316
+ if token_expired?(tokens)
317
+ puts "Token expired, refreshing..."
318
+ tokens = refresh_token!(config, tokens)
319
+ end
320
+
321
+ tokens["access_token"]
322
+ end
323
+
324
+ def require_business(config)
325
+ return config if config["business_id"] && config["account_id"]
326
+
327
+ tokens = load_tokens
328
+ unless tokens
329
+ puts "Not authenticated yet. Starting auth flow...\n\n"
330
+ tokens = authorize(config)
331
+ end
332
+
333
+ discover_business(tokens["access_token"], config)
334
+ end
335
+
336
+ # --- OAuth Flow ---
337
+
338
+ def authorize(config)
339
+ url = "#{AUTH_URL}?client_id=#{config["client_id"]}&response_type=code&redirect_uri=#{URI.encode_www_form_component(REDIRECT_URI)}"
340
+
341
+ puts "Open this URL in your browser:\n\n"
342
+ puts " #{url}\n\n"
343
+ puts "After authorizing, you'll be redirected to a URL that fails to load."
344
+ puts "Copy the full URL from your browser's address bar and paste it here.\n\n"
345
+
346
+ print "Redirect URL: "
347
+ redirect_url = $stdin.gets&.strip
348
+ abort("Aborted.") if redirect_url.nil? || redirect_url.empty?
349
+
350
+ uri = URI.parse(redirect_url)
351
+ params = URI.decode_www_form(uri.query || "").to_h
352
+ code = params["code"]
353
+
354
+ abort("Could not find 'code' parameter in the URL.") unless code
355
+
356
+ exchange_code(config, code)
357
+ end
358
+
359
+ def exchange_code(config, code)
360
+ response = HTTParty.post(TOKEN_URL, {
361
+ headers: { "Content-Type" => "application/json" },
362
+ body: {
363
+ grant_type: "authorization_code",
364
+ client_id: config["client_id"],
365
+ client_secret: config["client_secret"],
366
+ redirect_uri: REDIRECT_URI,
367
+ code: code
368
+ }.to_json
369
+ })
370
+
371
+ unless response.success?
372
+ body = response.parsed_response
373
+ msg = body.is_a?(Hash) ? (body["error_description"] || body["error"] || response.body) : response.body
374
+ abort("Token exchange failed: #{msg}")
375
+ end
376
+
377
+ data = response.parsed_response
378
+ check_scopes(data["scope"])
379
+
380
+ tokens = {
381
+ "access_token" => data["access_token"],
382
+ "refresh_token" => data["refresh_token"],
383
+ "expires_in" => data["expires_in"],
384
+ "created_at" => Time.now.to_i
385
+ }
386
+ save_tokens(tokens)
387
+ puts "Authentication successful!"
388
+ tokens
389
+ end
390
+
391
+ def check_scopes(granted_scope)
392
+ return if granted_scope.nil? # skip check if API doesn't return scopes
393
+
394
+ granted = granted_scope.split(" ")
395
+ missing = REQUIRED_SCOPES - granted
396
+
397
+ return if missing.empty?
398
+
399
+ puts "ERROR: Your FreshBooks app is missing required scopes:\n\n"
400
+ missing.each { |s| puts " - #{s}" }
401
+ puts "\nAdd them at https://my.freshbooks.com/#/developer"
402
+ puts "then re-run: fb auth"
403
+ abort
404
+ end
405
+
406
+ # --- Business Discovery ---
407
+
408
+ def fetch_identity(access_token)
409
+ response = HTTParty.get(ME_URL, {
410
+ headers: { "Authorization" => "Bearer #{access_token}" }
411
+ })
412
+
413
+ unless response.success?
414
+ abort("Failed to fetch user identity: #{response.body}")
415
+ end
416
+
417
+ response.parsed_response["response"]
418
+ end
419
+
420
+ def discover_business(access_token, config)
421
+ identity = fetch_identity(access_token)
422
+ memberships = identity.dig("business_memberships") || []
423
+ businesses = memberships.select { |m| m.dig("business", "account_id") }
424
+
425
+ if businesses.empty?
426
+ abort("No business memberships found on your FreshBooks account.")
427
+ end
428
+
429
+ selected = if businesses.length == 1
430
+ businesses.first
431
+ else
432
+ puts "\nMultiple businesses found:\n\n"
433
+ businesses.each_with_index do |m, i|
434
+ biz = m["business"]
435
+ puts " #{i + 1}. #{biz["name"]} (ID: #{biz["id"]})"
436
+ end
437
+ print "\nSelect a business (1-#{businesses.length}): "
438
+ choice = $stdin.gets&.strip&.to_i || 1
439
+ choice = 1 if choice < 1 || choice > businesses.length
440
+ businesses[choice - 1]
441
+ end
442
+
443
+ biz = selected["business"]
444
+ config["business_id"] = biz["id"]
445
+ config["account_id"] = biz["account_id"]
446
+ save_config(config)
447
+
448
+ puts "Business: #{biz["name"]}"
449
+ puts " business_id: #{biz["id"]}"
450
+ puts " account_id: #{biz["account_id"]}"
451
+ config
452
+ end
453
+
454
+ # --- Defaults ---
455
+
456
+ def load_defaults
457
+ return {} unless File.exist?(defaults_path)
458
+ JSON.parse(File.read(defaults_path))
459
+ rescue JSON::ParserError
460
+ {}
461
+ end
462
+
463
+ def save_defaults(defaults)
464
+ ensure_data_dir
465
+ File.write(defaults_path, JSON.pretty_generate(defaults) + "\n")
466
+ end
467
+
468
+ # --- Cache ---
469
+
470
+ def load_cache
471
+ return {} unless File.exist?(cache_path)
472
+ JSON.parse(File.read(cache_path))
473
+ rescue JSON::ParserError
474
+ {}
475
+ end
476
+
477
+ def save_cache(cache)
478
+ ensure_data_dir
479
+ File.write(cache_path, JSON.pretty_generate(cache) + "\n")
480
+ end
481
+ end
482
+ end
483
+ end
484
+ end