freshbooks-cli 0.5.0 → 0.5.1
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/lib/freshbooks/api.rb +0 -4
- data/lib/freshbooks/auth.rb +66 -16
- data/lib/freshbooks/cli.rb +62 -7
- data/lib/freshbooks/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72c774411761de2ede345e21a6961b2dace9debd2adf089b948c6cdd2447ac9b
|
|
4
|
+
data.tar.gz: a289b2ae8edb32a9759fc35b96165dc261254594a8711291e412a3ad1c69ece5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88c4848ab50045afa539f92fee68585b0bcdc61719cb99723b3758eba772ace18f810b1c81d8f024173604c4161ab0b4ada6b247a32d69ba3005c572ed341b79
|
|
7
|
+
data.tar.gz: 21304aa42d250e86ccf868342158a7d8f1135a7344bdc1407a4395de1bd7cccdf72f55dba9d4a3bb0cab22f73a7223c524788b96b537fd264ad9402c23d7afb4
|
data/lib/freshbooks/api.rb
CHANGED
|
@@ -41,8 +41,6 @@ module FreshBooks
|
|
|
41
41
|
# --- Paginated fetch ---
|
|
42
42
|
|
|
43
43
|
def fetch_all_pages(url, result_key, params: {})
|
|
44
|
-
return [] if Thread.current[:fb_dry_run]
|
|
45
|
-
|
|
46
44
|
page = 1
|
|
47
45
|
all_items = []
|
|
48
46
|
|
|
@@ -132,8 +130,6 @@ module FreshBooks
|
|
|
132
130
|
# --- Services ---
|
|
133
131
|
|
|
134
132
|
def fetch_services(force: false)
|
|
135
|
-
return (Auth.load_cache["services_data"] || []) if Thread.current[:fb_dry_run]
|
|
136
|
-
|
|
137
133
|
unless force
|
|
138
134
|
cached = cached_data("services_data")
|
|
139
135
|
return cached if cached
|
data/lib/freshbooks/auth.rb
CHANGED
|
@@ -9,6 +9,8 @@ require "dotenv"
|
|
|
9
9
|
module FreshBooks
|
|
10
10
|
module CLI
|
|
11
11
|
class Auth
|
|
12
|
+
class TokenRefreshError < StandardError; end
|
|
13
|
+
|
|
12
14
|
TOKEN_URL = "https://api.freshbooks.com/auth/oauth/token"
|
|
13
15
|
AUTH_URL = "https://auth.freshbooks.com/oauth/authorize"
|
|
14
16
|
ME_URL = "https://api.freshbooks.com/auth/api/v1/users/me"
|
|
@@ -60,6 +62,10 @@ module FreshBooks
|
|
|
60
62
|
File.join(data_dir, "tokens.json")
|
|
61
63
|
end
|
|
62
64
|
|
|
65
|
+
def tokens_lock_path
|
|
66
|
+
File.join(data_dir, "tokens.json.lock")
|
|
67
|
+
end
|
|
68
|
+
|
|
63
69
|
def defaults_path
|
|
64
70
|
File.join(data_dir, "defaults.json")
|
|
65
71
|
end
|
|
@@ -200,14 +206,28 @@ module FreshBooks
|
|
|
200
206
|
def auth_status
|
|
201
207
|
config = load_config
|
|
202
208
|
tokens = load_tokens
|
|
209
|
+
refresh_error = nil
|
|
210
|
+
|
|
211
|
+
if config && tokens && token_expired?(tokens)
|
|
212
|
+
begin
|
|
213
|
+
tokens = refresh_token_with_lock(config, tokens, abort_on_failure: false)
|
|
214
|
+
rescue TokenRefreshError => e
|
|
215
|
+
refresh_error = e.message
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
tokens_expired = tokens ? token_expired?(tokens) : nil
|
|
220
|
+
requires_reauth = config.nil? || tokens.nil? || tokens_expired == true
|
|
203
221
|
{
|
|
204
222
|
"config_exists" => !config.nil?,
|
|
205
223
|
"config_path" => config_path,
|
|
206
224
|
"tokens_exist" => !tokens.nil?,
|
|
207
|
-
"tokens_expired" =>
|
|
225
|
+
"tokens_expired" => tokens_expired,
|
|
226
|
+
"requires_reauth" => requires_reauth,
|
|
227
|
+
"refresh_error" => refresh_error,
|
|
208
228
|
"business_id" => config&.dig("business_id"),
|
|
209
229
|
"account_id" => config&.dig("account_id")
|
|
210
|
-
}
|
|
230
|
+
}.compact
|
|
211
231
|
end
|
|
212
232
|
|
|
213
233
|
def fetch_businesses(access_token)
|
|
@@ -258,7 +278,15 @@ module FreshBooks
|
|
|
258
278
|
|
|
259
279
|
def save_tokens(tokens)
|
|
260
280
|
ensure_data_dir
|
|
261
|
-
|
|
281
|
+
tmp_path = "#{tokens_path}.#{$$}.tmp"
|
|
282
|
+
File.open(tmp_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
|
|
283
|
+
file.write(JSON.pretty_generate(tokens) + "\n")
|
|
284
|
+
file.flush
|
|
285
|
+
file.fsync
|
|
286
|
+
end
|
|
287
|
+
File.rename(tmp_path, tokens_path)
|
|
288
|
+
ensure
|
|
289
|
+
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
|
|
262
290
|
end
|
|
263
291
|
|
|
264
292
|
def token_expired?(tokens)
|
|
@@ -268,22 +296,30 @@ module FreshBooks
|
|
|
268
296
|
Time.now.to_i >= (created + expires_in - 60)
|
|
269
297
|
end
|
|
270
298
|
|
|
271
|
-
def refresh_token!(config, tokens)
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
299
|
+
def refresh_token!(config, tokens, abort_on_failure: true)
|
|
300
|
+
begin
|
|
301
|
+
response = HTTParty.post(TOKEN_URL, {
|
|
302
|
+
headers: { "Content-Type" => "application/json" },
|
|
303
|
+
body: {
|
|
304
|
+
grant_type: "refresh_token",
|
|
305
|
+
client_id: config["client_id"],
|
|
306
|
+
client_secret: config["client_secret"],
|
|
307
|
+
redirect_uri: REDIRECT_URI,
|
|
308
|
+
refresh_token: tokens["refresh_token"]
|
|
309
|
+
}.to_json
|
|
310
|
+
})
|
|
311
|
+
rescue StandardError => e
|
|
312
|
+
message = "Token refresh failed: #{e.class}: #{e.message}\nPlease re-run: fb auth"
|
|
313
|
+
raise if abort_on_failure
|
|
314
|
+
raise TokenRefreshError, message
|
|
315
|
+
end
|
|
282
316
|
|
|
283
317
|
unless response.success?
|
|
284
318
|
body = response.parsed_response
|
|
285
319
|
msg = body.is_a?(Hash) ? (body["error_description"] || body["error"] || response.body) : response.body
|
|
286
|
-
|
|
320
|
+
message = "Token refresh failed: #{msg}\nPlease re-run: fb auth"
|
|
321
|
+
abort(message) if abort_on_failure
|
|
322
|
+
raise TokenRefreshError, message
|
|
287
323
|
end
|
|
288
324
|
|
|
289
325
|
data = response.parsed_response
|
|
@@ -297,6 +333,20 @@ module FreshBooks
|
|
|
297
333
|
new_tokens
|
|
298
334
|
end
|
|
299
335
|
|
|
336
|
+
def refresh_token_with_lock(config, tokens, abort_on_failure: true)
|
|
337
|
+
ensure_data_dir
|
|
338
|
+
File.open(tokens_lock_path, File::RDWR | File::CREAT, 0o600) do |lock_file|
|
|
339
|
+
lock_file.flock(File::LOCK_EX)
|
|
340
|
+
|
|
341
|
+
latest_tokens = load_tokens || tokens
|
|
342
|
+
return latest_tokens if latest_tokens && !token_expired?(latest_tokens)
|
|
343
|
+
|
|
344
|
+
refresh_token!(config, latest_tokens || tokens, abort_on_failure: abort_on_failure)
|
|
345
|
+
ensure
|
|
346
|
+
lock_file.flock(File::LOCK_UN)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
300
350
|
def valid_access_token
|
|
301
351
|
if Thread.current[:fb_dry_run]
|
|
302
352
|
tokens = load_tokens
|
|
@@ -315,7 +365,7 @@ module FreshBooks
|
|
|
315
365
|
|
|
316
366
|
if token_expired?(tokens)
|
|
317
367
|
puts "Token expired, refreshing..."
|
|
318
|
-
tokens =
|
|
368
|
+
tokens = refresh_token_with_lock(config, tokens)
|
|
319
369
|
end
|
|
320
370
|
|
|
321
371
|
tokens["access_token"]
|
data/lib/freshbooks/cli.rb
CHANGED
|
@@ -13,10 +13,48 @@ module FreshBooks
|
|
|
13
13
|
true
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
def self.dispatch(command, given_args, given_opts, config)
|
|
17
|
+
requested_command = command || given_args.first
|
|
18
|
+
|
|
19
|
+
super
|
|
20
|
+
rescue Thor::UnknownArgumentError => e
|
|
21
|
+
if requested_command.to_s == "log"
|
|
22
|
+
unknown_option = e.unknown.first
|
|
23
|
+
raise Thor::InvocationError, log_parse_error_message(unknown_option, e.message)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
raise
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.handle_argument_error(command, error, args, arity)
|
|
30
|
+
unknown_option = args.find { |arg| arg.to_s.start_with?("--") }
|
|
31
|
+
if command.name == "log" && unknown_option
|
|
32
|
+
raise Thor::InvocationError, log_parse_error_message(unknown_option, error.message)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.log_parse_error_message(option, fallback)
|
|
39
|
+
suggestion = { "--hours" => "--duration", "--notes" => "--note" }[option]
|
|
40
|
+
lines = ["Unknown option: #{option || fallback}"]
|
|
41
|
+
lines << "Did you mean: #{suggestion}" if suggestion
|
|
42
|
+
lines.concat([
|
|
43
|
+
"",
|
|
44
|
+
"fb log requires:",
|
|
45
|
+
" --duration HOURS",
|
|
46
|
+
" --note TEXT",
|
|
47
|
+
"",
|
|
48
|
+
"Example:",
|
|
49
|
+
' fb log --project "Sample Project" --service "Development" --duration 0.5 --note "Reviewed pull requests" --yes'
|
|
50
|
+
])
|
|
51
|
+
lines.join("\n")
|
|
52
|
+
end
|
|
53
|
+
|
|
16
54
|
class_option :no_interactive, type: :boolean, default: false, desc: "Disable interactive prompts (auto-detected when not a TTY)"
|
|
17
55
|
class_option :interactive, type: :boolean, default: false, desc: "Force interactive mode even when not a TTY"
|
|
18
56
|
class_option :format, type: :string, desc: "Output format: table (default) or json"
|
|
19
|
-
class_option :dry_run, type: :boolean, default: false, desc: "Simulate
|
|
57
|
+
class_option :dry_run, type: :boolean, default: false, desc: "Simulate writes while allowing read lookups"
|
|
20
58
|
|
|
21
59
|
no_commands do
|
|
22
60
|
def invoke_command(command, *args)
|
|
@@ -124,6 +162,7 @@ module FreshBooks
|
|
|
124
162
|
if status_data["tokens_exist"]
|
|
125
163
|
puts "Expired: #{status_data["tokens_expired"] ? "yes" : "no"}"
|
|
126
164
|
end
|
|
165
|
+
puts "Requires re-auth: #{status_data["requires_reauth"] ? "yes" : "no"}"
|
|
127
166
|
puts "Business ID: #{status_data["business_id"] || "not set"}"
|
|
128
167
|
puts "Account ID: #{status_data["account_id"] || "not set"}"
|
|
129
168
|
end
|
|
@@ -210,16 +249,32 @@ module FreshBooks
|
|
|
210
249
|
|
|
211
250
|
# --- log ---
|
|
212
251
|
|
|
213
|
-
desc "log
|
|
252
|
+
desc "log [--client NAME] [--project NAME] [--service NAME] --duration HOURS --note TEXT [--date YYYY-MM-DD] [--internal] [--yes]",
|
|
253
|
+
"Log a time entry"
|
|
254
|
+
long_desc <<~DESC
|
|
255
|
+
Log a FreshBooks time entry.
|
|
256
|
+
|
|
257
|
+
Non-interactive usage requires --duration and --note. Use --client when multiple clients exist, or --internal with --project for internal work.
|
|
258
|
+
|
|
259
|
+
Examples:
|
|
260
|
+
fb log --project "Sample Project" --service "Development" --duration 0.5 --note "Reviewed pull requests" --yes
|
|
261
|
+
fb log --internal --project "Admin" --hours 1 --notes "Planning" --yes
|
|
262
|
+
DESC
|
|
214
263
|
method_option :client, type: :string, desc: "Pre-select client by name"
|
|
215
264
|
method_option :project, type: :string, desc: "Pre-select project by name"
|
|
216
265
|
method_option :service, type: :string, desc: "Pre-select service by name"
|
|
217
|
-
method_option :duration, type: :numeric, desc: "Duration in hours (e.g. 2.5)"
|
|
218
|
-
method_option :note, type: :string, desc: "Work description"
|
|
266
|
+
method_option :duration, type: :numeric, aliases: "--hours", desc: "Duration in hours (e.g. 2.5)"
|
|
267
|
+
method_option :note, type: :string, aliases: "--notes", desc: "Work description"
|
|
219
268
|
method_option :date, type: :string, desc: "Date (YYYY-MM-DD, defaults to today)"
|
|
220
269
|
method_option :internal, type: :boolean, default: false, desc: "Log to an internal project with no client"
|
|
221
270
|
method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
|
|
271
|
+
method_option :help, type: :boolean, desc: "Show command help"
|
|
222
272
|
def log
|
|
273
|
+
if options[:help]
|
|
274
|
+
self.class.command_help(shell, "log")
|
|
275
|
+
return
|
|
276
|
+
end
|
|
277
|
+
|
|
223
278
|
Auth.valid_access_token
|
|
224
279
|
defaults = Auth.load_defaults
|
|
225
280
|
|
|
@@ -517,8 +572,8 @@ module FreshBooks
|
|
|
517
572
|
|
|
518
573
|
desc "edit", "Edit a time entry"
|
|
519
574
|
method_option :id, type: :numeric, desc: "Time entry ID (skip interactive picker)"
|
|
520
|
-
method_option :duration, type: :numeric, desc: "New duration in hours"
|
|
521
|
-
method_option :note, type: :string, desc: "New note"
|
|
575
|
+
method_option :duration, type: :numeric, aliases: "--hours", desc: "New duration in hours"
|
|
576
|
+
method_option :note, type: :string, aliases: "--notes", desc: "New note"
|
|
522
577
|
method_option :date, type: :string, desc: "New date (YYYY-MM-DD)"
|
|
523
578
|
method_option :client, type: :string, desc: "New client name"
|
|
524
579
|
method_option :project, type: :string, desc: "New project name"
|
|
@@ -1066,7 +1121,7 @@ module FreshBooks
|
|
|
1066
1121
|
global_flags: {
|
|
1067
1122
|
"--no-interactive" => "Disable interactive prompts (auto-detected when not a TTY)",
|
|
1068
1123
|
"--format json" => "Output format: json (available on all commands)",
|
|
1069
|
-
"--dry-run" => "Simulate
|
|
1124
|
+
"--dry-run" => "Simulate writes while allowing read lookups"
|
|
1070
1125
|
},
|
|
1071
1126
|
commands: {
|
|
1072
1127
|
auth: {
|
data/lib/freshbooks/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: freshbooks-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- parasquid
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-05-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: thor
|