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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a37704cb4e7d203009ad3e85149f128509f11fd1e78198890c27687017e8f43d
4
- data.tar.gz: 654e5bfc31deb194428128f8c2eb308c744c8c877426546f6400887b244c209c
3
+ metadata.gz: 72c774411761de2ede345e21a6961b2dace9debd2adf089b948c6cdd2447ac9b
4
+ data.tar.gz: a289b2ae8edb32a9759fc35b96165dc261254594a8711291e412a3ad1c69ece5
5
5
  SHA512:
6
- metadata.gz: 3bb70e134009c39efe0a912ff507d0a6c79f7613dda511f3dc9fa968467ddfc5df69faa78f85667cca66d7d4a019188d0968ac6ba94f2eac046f4923e835c995
7
- data.tar.gz: d641d16f38405cad36806eac8b346c9e6aa228cb46a1365cb1dd8f7ddf2f22181a531def080dc8d77fec3a1447db57ac904a2a45c3756d8ec4c4d9e48ed87c6b
6
+ metadata.gz: 88c4848ab50045afa539f92fee68585b0bcdc61719cb99723b3758eba772ace18f810b1c81d8f024173604c4161ab0b4ada6b247a32d69ba3005c572ed341b79
7
+ data.tar.gz: 21304aa42d250e86ccf868342158a7d8f1135a7344bdc1407a4395de1bd7cccdf72f55dba9d4a3bb0cab22f73a7223c524788b96b537fd264ad9402c23d7afb4
@@ -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
@@ -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" => tokens ? token_expired?(tokens) : nil,
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
- File.write(tokens_path, JSON.pretty_generate(tokens) + "\n")
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
- 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
- })
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
- abort("Token refresh failed: #{msg}\nPlease re-run: fb auth")
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 = refresh_token!(config, tokens)
368
+ tokens = refresh_token_with_lock(config, tokens)
319
369
  end
320
370
 
321
371
  tokens["access_token"]
@@ -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 command without making network calls"
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", "Log a time entry"
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 command without making network calls (writes skipped)"
1124
+ "--dry-run" => "Simulate writes while allowing read lookups"
1070
1125
  },
1071
1126
  commands: {
1072
1127
  auth: {
@@ -2,6 +2,6 @@
2
2
 
3
3
  module FreshBooks
4
4
  module CLI
5
- VERSION = "0.5.0"
5
+ VERSION = "0.5.1"
6
6
  end
7
7
  end
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.0
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-04-23 00:00:00.000000000 Z
11
+ date: 2026-05-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor