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.
- 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 -1079
- data/lib/fb/spinner.rb +0 -48
- data/lib/fb/version.rb +0 -5
- data/lib/fb.rb +0 -7
|
@@ -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
|