freshbooks-cli 0.1.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 +7 -0
- data/bin/fb +6 -0
- data/lib/fb/api.rb +199 -0
- data/lib/fb/auth.rb +330 -0
- data/lib/fb/cli.rb +347 -0
- data/lib/fb/spinner.rb +32 -0
- data/lib/fb/version.rb +5 -0
- data/lib/fb.rb +7 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d293f888f52e04f38fd9c325292b83ab1373d3587ca615e48e3c428c9eb4c27d
|
|
4
|
+
data.tar.gz: 0b7b59a6bcde4b4511019cb72b3f3518b92b96d3b2db4f53176f31f0ad20d888
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b7668df61766607e2d14eafc157772e673d86c8353a7161bdc0452fd3f33c00cbb57b2975d60c62d0ad317fbf22fc934cd060299b4292d010cd192f13aa64746
|
|
7
|
+
data.tar.gz: 8329f4f4d3d76efd1d9d808cce0fff2d018be1791f93725d9df14ed1ca1c106aa80e3ce6f45c2e4d9853fcc1836484d28c3c6a2e25409cb93266e9b717e81f8c
|
data/bin/fb
ADDED
data/lib/fb/api.rb
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "httparty"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module FB
|
|
7
|
+
class Api
|
|
8
|
+
BASE = "https://api.freshbooks.com"
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def headers
|
|
12
|
+
token = Auth.valid_access_token
|
|
13
|
+
{
|
|
14
|
+
"Authorization" => "Bearer #{token}",
|
|
15
|
+
"Content-Type" => "application/json"
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def config
|
|
20
|
+
@config = nil
|
|
21
|
+
@config = Auth.require_config
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def business_id
|
|
25
|
+
c = config
|
|
26
|
+
unless c["business_id"]
|
|
27
|
+
c = Auth.require_business(c)
|
|
28
|
+
end
|
|
29
|
+
c["business_id"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def account_id
|
|
33
|
+
c = config
|
|
34
|
+
unless c["account_id"]
|
|
35
|
+
c = Auth.require_business(c)
|
|
36
|
+
end
|
|
37
|
+
c["account_id"]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# --- Paginated fetch ---
|
|
41
|
+
|
|
42
|
+
def fetch_all_pages(url, result_key, params: {})
|
|
43
|
+
page = 1
|
|
44
|
+
all_items = []
|
|
45
|
+
|
|
46
|
+
loop do
|
|
47
|
+
response = HTTParty.get(url, {
|
|
48
|
+
headers: headers,
|
|
49
|
+
query: params.merge(page: page, per_page: 100)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
unless response.success?
|
|
53
|
+
body = response.parsed_response
|
|
54
|
+
msg = extract_error(body) || response.body
|
|
55
|
+
abort("API error: #{msg}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
data = response.parsed_response
|
|
59
|
+
items = dig_results(data, result_key)
|
|
60
|
+
break if items.nil? || items.empty?
|
|
61
|
+
|
|
62
|
+
all_items.concat(items)
|
|
63
|
+
|
|
64
|
+
meta = dig_meta(data)
|
|
65
|
+
break if meta.nil?
|
|
66
|
+
break if page >= meta["pages"].to_i
|
|
67
|
+
|
|
68
|
+
page += 1
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
all_items
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# --- Clients ---
|
|
75
|
+
|
|
76
|
+
def fetch_clients
|
|
77
|
+
url = "#{BASE}/accounting/account/#{account_id}/users/clients"
|
|
78
|
+
fetch_all_pages(url, "clients")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# --- Projects ---
|
|
82
|
+
|
|
83
|
+
def fetch_projects
|
|
84
|
+
url = "#{BASE}/projects/business/#{business_id}/projects"
|
|
85
|
+
fetch_all_pages(url, "projects")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def fetch_projects_for_client(client_id)
|
|
89
|
+
all = fetch_projects
|
|
90
|
+
all.select { |p| p["client_id"].to_i == client_id.to_i }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# --- Services ---
|
|
94
|
+
|
|
95
|
+
def fetch_services
|
|
96
|
+
url = "#{BASE}/comments/business/#{business_id}/services"
|
|
97
|
+
response = HTTParty.get(url, { headers: headers })
|
|
98
|
+
|
|
99
|
+
unless response.success?
|
|
100
|
+
body = response.parsed_response
|
|
101
|
+
msg = extract_error(body) || response.body
|
|
102
|
+
abort("API error: #{msg}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
data = response.parsed_response
|
|
106
|
+
services_hash = data.dig("result", "services") || {}
|
|
107
|
+
services_hash.values
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# --- Time Entries ---
|
|
111
|
+
|
|
112
|
+
def fetch_time_entries(started_from: nil, started_to: nil)
|
|
113
|
+
url = "#{BASE}/timetracking/business/#{business_id}/time_entries"
|
|
114
|
+
params = {}
|
|
115
|
+
params["started_from"] = "#{started_from}T00:00:00Z" if started_from
|
|
116
|
+
params["started_to"] = "#{started_to}T23:59:59Z" if started_to
|
|
117
|
+
fetch_all_pages(url, "time_entries", params: params)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def create_time_entry(entry)
|
|
121
|
+
url = "#{BASE}/timetracking/business/#{business_id}/time_entries"
|
|
122
|
+
body = { time_entry: entry }
|
|
123
|
+
|
|
124
|
+
response = HTTParty.post(url, {
|
|
125
|
+
headers: headers,
|
|
126
|
+
body: body.to_json
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
unless response.success?
|
|
130
|
+
body = response.parsed_response
|
|
131
|
+
msg = extract_error(body) || response.body
|
|
132
|
+
abort("API error: #{msg}")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
response.parsed_response
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# --- Name Resolution (for entries display) ---
|
|
139
|
+
|
|
140
|
+
def build_name_maps
|
|
141
|
+
cache = Auth.load_cache
|
|
142
|
+
now = Time.now.to_i
|
|
143
|
+
|
|
144
|
+
if cache["updated_at"] && (now - cache["updated_at"]) < 600
|
|
145
|
+
return {
|
|
146
|
+
clients: (cache["clients"] || {}),
|
|
147
|
+
projects: (cache["projects"] || {})
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
clients = fetch_clients
|
|
152
|
+
projects = fetch_projects
|
|
153
|
+
|
|
154
|
+
client_map = {}
|
|
155
|
+
clients.each do |c|
|
|
156
|
+
name = c["organization"]
|
|
157
|
+
name = "#{c["fname"]} #{c["lname"]}" if name.nil? || name.empty?
|
|
158
|
+
client_map[c["id"].to_s] = name
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
project_map = {}
|
|
162
|
+
projects.each do |p|
|
|
163
|
+
project_map[p["id"].to_s] = p["title"]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
cache_data = {
|
|
167
|
+
"updated_at" => now,
|
|
168
|
+
"clients" => client_map,
|
|
169
|
+
"projects" => project_map
|
|
170
|
+
}
|
|
171
|
+
Auth.save_cache(cache_data)
|
|
172
|
+
|
|
173
|
+
{ clients: client_map, projects: project_map }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
def extract_error(body)
|
|
179
|
+
return nil unless body.is_a?(Hash)
|
|
180
|
+
body["error_description"] ||
|
|
181
|
+
body.dig("response", "errors", 0, "message") ||
|
|
182
|
+
body.dig("error") ||
|
|
183
|
+
body.dig("message")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def dig_results(data, key)
|
|
187
|
+
data.dig("result", key) ||
|
|
188
|
+
data.dig("response", "result", key) ||
|
|
189
|
+
data.dig(key)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def dig_meta(data)
|
|
193
|
+
data.dig("result", "meta") ||
|
|
194
|
+
data.dig("response", "result", "meta") ||
|
|
195
|
+
data.dig("meta")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
data/lib/fb/auth.rb
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
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 require_config
|
|
98
|
+
config = load_config
|
|
99
|
+
return config if config
|
|
100
|
+
|
|
101
|
+
puts "No config found. Let's set up FreshBooks CLI.\n\n"
|
|
102
|
+
setup_config
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# --- Tokens ---
|
|
106
|
+
|
|
107
|
+
def load_tokens
|
|
108
|
+
return nil unless File.exist?(tokens_path)
|
|
109
|
+
JSON.parse(File.read(tokens_path))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def save_tokens(tokens)
|
|
113
|
+
ensure_data_dir
|
|
114
|
+
File.write(tokens_path, JSON.pretty_generate(tokens) + "\n")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def token_expired?(tokens)
|
|
118
|
+
return true unless tokens
|
|
119
|
+
created = tokens["created_at"] || 0
|
|
120
|
+
expires_in = tokens["expires_in"] || 0
|
|
121
|
+
Time.now.to_i >= (created + expires_in - 60)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def refresh_token!(config, tokens)
|
|
125
|
+
response = HTTParty.post(TOKEN_URL, {
|
|
126
|
+
headers: { "Content-Type" => "application/json" },
|
|
127
|
+
body: {
|
|
128
|
+
grant_type: "refresh_token",
|
|
129
|
+
client_id: config["client_id"],
|
|
130
|
+
client_secret: config["client_secret"],
|
|
131
|
+
redirect_uri: REDIRECT_URI,
|
|
132
|
+
refresh_token: tokens["refresh_token"]
|
|
133
|
+
}.to_json
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
unless response.success?
|
|
137
|
+
body = response.parsed_response
|
|
138
|
+
msg = body.is_a?(Hash) ? (body["error_description"] || body["error"] || response.body) : response.body
|
|
139
|
+
abort("Token refresh failed: #{msg}\nPlease re-run: fb auth")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
data = response.parsed_response
|
|
143
|
+
new_tokens = {
|
|
144
|
+
"access_token" => data["access_token"],
|
|
145
|
+
"refresh_token" => data["refresh_token"],
|
|
146
|
+
"expires_in" => data["expires_in"],
|
|
147
|
+
"created_at" => Time.now.to_i
|
|
148
|
+
}
|
|
149
|
+
save_tokens(new_tokens)
|
|
150
|
+
new_tokens
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def valid_access_token
|
|
154
|
+
config = require_config
|
|
155
|
+
tokens = load_tokens
|
|
156
|
+
|
|
157
|
+
unless tokens
|
|
158
|
+
puts "Not authenticated yet. Starting auth flow...\n\n"
|
|
159
|
+
tokens = authorize(config)
|
|
160
|
+
discover_business(tokens["access_token"], config)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if token_expired?(tokens)
|
|
164
|
+
puts "Token expired, refreshing..."
|
|
165
|
+
tokens = refresh_token!(config, tokens)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
tokens["access_token"]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def require_business(config)
|
|
172
|
+
return config if config["business_id"] && config["account_id"]
|
|
173
|
+
|
|
174
|
+
tokens = load_tokens
|
|
175
|
+
unless tokens
|
|
176
|
+
puts "Not authenticated yet. Starting auth flow...\n\n"
|
|
177
|
+
tokens = authorize(config)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
discover_business(tokens["access_token"], config)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# --- OAuth Flow ---
|
|
184
|
+
|
|
185
|
+
def authorize(config)
|
|
186
|
+
url = "#{AUTH_URL}?client_id=#{config["client_id"]}&response_type=code&redirect_uri=#{URI.encode_www_form_component(REDIRECT_URI)}"
|
|
187
|
+
|
|
188
|
+
puts "Open this URL in your browser:\n\n"
|
|
189
|
+
puts " #{url}\n\n"
|
|
190
|
+
puts "After authorizing, you'll be redirected to a URL that fails to load."
|
|
191
|
+
puts "Copy the full URL from your browser's address bar and paste it here.\n\n"
|
|
192
|
+
|
|
193
|
+
print "Redirect URL: "
|
|
194
|
+
redirect_url = $stdin.gets&.strip
|
|
195
|
+
abort("Aborted.") if redirect_url.nil? || redirect_url.empty?
|
|
196
|
+
|
|
197
|
+
uri = URI.parse(redirect_url)
|
|
198
|
+
params = URI.decode_www_form(uri.query || "").to_h
|
|
199
|
+
code = params["code"]
|
|
200
|
+
|
|
201
|
+
abort("Could not find 'code' parameter in the URL.") unless code
|
|
202
|
+
|
|
203
|
+
exchange_code(config, code)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def exchange_code(config, code)
|
|
207
|
+
response = HTTParty.post(TOKEN_URL, {
|
|
208
|
+
headers: { "Content-Type" => "application/json" },
|
|
209
|
+
body: {
|
|
210
|
+
grant_type: "authorization_code",
|
|
211
|
+
client_id: config["client_id"],
|
|
212
|
+
client_secret: config["client_secret"],
|
|
213
|
+
redirect_uri: REDIRECT_URI,
|
|
214
|
+
code: code
|
|
215
|
+
}.to_json
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
unless response.success?
|
|
219
|
+
body = response.parsed_response
|
|
220
|
+
msg = body.is_a?(Hash) ? (body["error_description"] || body["error"] || response.body) : response.body
|
|
221
|
+
abort("Token exchange failed: #{msg}")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
data = response.parsed_response
|
|
225
|
+
check_scopes(data["scope"])
|
|
226
|
+
|
|
227
|
+
tokens = {
|
|
228
|
+
"access_token" => data["access_token"],
|
|
229
|
+
"refresh_token" => data["refresh_token"],
|
|
230
|
+
"expires_in" => data["expires_in"],
|
|
231
|
+
"created_at" => Time.now.to_i
|
|
232
|
+
}
|
|
233
|
+
save_tokens(tokens)
|
|
234
|
+
puts "Authentication successful!"
|
|
235
|
+
tokens
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def check_scopes(granted_scope)
|
|
239
|
+
return if granted_scope.nil? # skip check if API doesn't return scopes
|
|
240
|
+
|
|
241
|
+
granted = granted_scope.split(" ")
|
|
242
|
+
missing = REQUIRED_SCOPES - granted
|
|
243
|
+
|
|
244
|
+
return if missing.empty?
|
|
245
|
+
|
|
246
|
+
puts "ERROR: Your FreshBooks app is missing required scopes:\n\n"
|
|
247
|
+
missing.each { |s| puts " - #{s}" }
|
|
248
|
+
puts "\nAdd them at https://my.freshbooks.com/#/developer"
|
|
249
|
+
puts "then re-run: fb auth"
|
|
250
|
+
abort
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# --- Business Discovery ---
|
|
254
|
+
|
|
255
|
+
def fetch_identity(access_token)
|
|
256
|
+
response = HTTParty.get(ME_URL, {
|
|
257
|
+
headers: { "Authorization" => "Bearer #{access_token}" }
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
unless response.success?
|
|
261
|
+
abort("Failed to fetch user identity: #{response.body}")
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
response.parsed_response["response"]
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def discover_business(access_token, config)
|
|
268
|
+
identity = fetch_identity(access_token)
|
|
269
|
+
memberships = identity.dig("business_memberships") || []
|
|
270
|
+
businesses = memberships.select { |m| m.dig("business", "account_id") }
|
|
271
|
+
|
|
272
|
+
if businesses.empty?
|
|
273
|
+
abort("No business memberships found on your FreshBooks account.")
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
selected = if businesses.length == 1
|
|
277
|
+
businesses.first
|
|
278
|
+
else
|
|
279
|
+
puts "\nMultiple businesses found:\n\n"
|
|
280
|
+
businesses.each_with_index do |m, i|
|
|
281
|
+
biz = m["business"]
|
|
282
|
+
puts " #{i + 1}. #{biz["name"]} (ID: #{biz["id"]})"
|
|
283
|
+
end
|
|
284
|
+
print "\nSelect a business (1-#{businesses.length}): "
|
|
285
|
+
choice = $stdin.gets&.strip&.to_i || 1
|
|
286
|
+
choice = 1 if choice < 1 || choice > businesses.length
|
|
287
|
+
businesses[choice - 1]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
biz = selected["business"]
|
|
291
|
+
config["business_id"] = biz["id"]
|
|
292
|
+
config["account_id"] = biz["account_id"]
|
|
293
|
+
save_config(config)
|
|
294
|
+
|
|
295
|
+
puts "Business: #{biz["name"]}"
|
|
296
|
+
puts " business_id: #{biz["id"]}"
|
|
297
|
+
puts " account_id: #{biz["account_id"]}"
|
|
298
|
+
config
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# --- Defaults ---
|
|
302
|
+
|
|
303
|
+
def load_defaults
|
|
304
|
+
return {} unless File.exist?(defaults_path)
|
|
305
|
+
JSON.parse(File.read(defaults_path))
|
|
306
|
+
rescue JSON::ParserError
|
|
307
|
+
{}
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def save_defaults(defaults)
|
|
311
|
+
ensure_data_dir
|
|
312
|
+
File.write(defaults_path, JSON.pretty_generate(defaults) + "\n")
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# --- Cache ---
|
|
316
|
+
|
|
317
|
+
def load_cache
|
|
318
|
+
return {} unless File.exist?(cache_path)
|
|
319
|
+
JSON.parse(File.read(cache_path))
|
|
320
|
+
rescue JSON::ParserError
|
|
321
|
+
{}
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def save_cache(cache)
|
|
325
|
+
ensure_data_dir
|
|
326
|
+
File.write(cache_path, JSON.pretty_generate(cache) + "\n")
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
data/lib/fb/cli.rb
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "json"
|
|
5
|
+
require "date"
|
|
6
|
+
|
|
7
|
+
module FB
|
|
8
|
+
class Cli < Thor
|
|
9
|
+
def self.exit_on_failure?
|
|
10
|
+
true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# --- auth ---
|
|
14
|
+
|
|
15
|
+
desc "auth", "Authenticate with FreshBooks via OAuth2"
|
|
16
|
+
def auth
|
|
17
|
+
config = Auth.require_config
|
|
18
|
+
tokens = Auth.authorize(config)
|
|
19
|
+
Auth.discover_business(tokens["access_token"], config)
|
|
20
|
+
puts "\nReady to go! Try: fb entries"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# --- log ---
|
|
24
|
+
|
|
25
|
+
desc "log", "Log a time entry"
|
|
26
|
+
method_option :client, type: :string, desc: "Pre-select client by name"
|
|
27
|
+
method_option :project, type: :string, desc: "Pre-select project by name"
|
|
28
|
+
method_option :service, type: :string, desc: "Pre-select service by name"
|
|
29
|
+
method_option :duration, type: :numeric, desc: "Duration in hours (e.g. 2.5)"
|
|
30
|
+
method_option :note, type: :string, desc: "Work description"
|
|
31
|
+
method_option :date, type: :string, desc: "Date (YYYY-MM-DD, defaults to today)"
|
|
32
|
+
method_option :yes, type: :boolean, default: false, desc: "Skip confirmation"
|
|
33
|
+
def log
|
|
34
|
+
Auth.require_config
|
|
35
|
+
defaults = Auth.load_defaults
|
|
36
|
+
interactive = !(options[:client] && options[:duration] && options[:note])
|
|
37
|
+
|
|
38
|
+
client = select_client(defaults, interactive)
|
|
39
|
+
project = select_project(client["id"], defaults, interactive)
|
|
40
|
+
service = select_service(defaults, interactive)
|
|
41
|
+
date = pick_date(interactive)
|
|
42
|
+
duration_hours = pick_duration(interactive)
|
|
43
|
+
note = pick_note(interactive)
|
|
44
|
+
|
|
45
|
+
client_name = display_name(client)
|
|
46
|
+
|
|
47
|
+
puts "\n--- Time Entry Summary ---"
|
|
48
|
+
puts " Client: #{client_name}"
|
|
49
|
+
puts " Project: #{project ? project["title"] : "(none)"}"
|
|
50
|
+
puts " Service: #{service ? service["name"] : "(none)"}"
|
|
51
|
+
puts " Date: #{date}"
|
|
52
|
+
puts " Duration: #{duration_hours}h"
|
|
53
|
+
puts " Note: #{note}"
|
|
54
|
+
puts "--------------------------\n\n"
|
|
55
|
+
|
|
56
|
+
unless options[:yes]
|
|
57
|
+
print "Submit? (Y/n): "
|
|
58
|
+
answer = $stdin.gets&.strip&.downcase
|
|
59
|
+
abort("Cancelled.") if answer == "n"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
entry = {
|
|
63
|
+
"is_logged" => true,
|
|
64
|
+
"duration" => (duration_hours * 3600).to_i,
|
|
65
|
+
"note" => note,
|
|
66
|
+
"started_at" => date,
|
|
67
|
+
"client_id" => client["id"]
|
|
68
|
+
}
|
|
69
|
+
entry["project_id"] = project["id"] if project
|
|
70
|
+
entry["service_id"] = service["id"] if service
|
|
71
|
+
|
|
72
|
+
Api.create_time_entry(entry)
|
|
73
|
+
puts "Time entry created!"
|
|
74
|
+
|
|
75
|
+
new_defaults = { "client_id" => client["id"] }
|
|
76
|
+
new_defaults["project_id"] = project["id"] if project
|
|
77
|
+
new_defaults["service_id"] = service["id"] if service
|
|
78
|
+
Auth.save_defaults(new_defaults)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# --- entries ---
|
|
82
|
+
|
|
83
|
+
desc "entries", "List time entries (defaults to current month)"
|
|
84
|
+
method_option :month, type: :numeric, desc: "Month (1-12)"
|
|
85
|
+
method_option :year, type: :numeric, desc: "Year"
|
|
86
|
+
method_option :from, type: :string, desc: "Start date (YYYY-MM-DD)"
|
|
87
|
+
method_option :to, type: :string, desc: "End date (YYYY-MM-DD)"
|
|
88
|
+
method_option :format, type: :string, default: "table", desc: "Output format: table or json"
|
|
89
|
+
def entries
|
|
90
|
+
Auth.require_config
|
|
91
|
+
|
|
92
|
+
today = Date.today
|
|
93
|
+
|
|
94
|
+
if options[:from] || options[:to]
|
|
95
|
+
first_day = options[:from] ? Date.parse(options[:from]) : nil
|
|
96
|
+
last_day = options[:to] ? Date.parse(options[:to]) : nil
|
|
97
|
+
else
|
|
98
|
+
month = options[:month] || today.month
|
|
99
|
+
year = options[:year] || today.year
|
|
100
|
+
first_day = Date.new(year, month, 1)
|
|
101
|
+
last_day = Date.new(year, month, -1)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
label = if first_day && last_day
|
|
105
|
+
"#{first_day} to #{last_day}"
|
|
106
|
+
elsif first_day
|
|
107
|
+
"from #{first_day} onwards"
|
|
108
|
+
elsif last_day
|
|
109
|
+
"up to #{last_day}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
entries = Spinner.spin("Fetching time entries#{label ? " (#{label})" : ""}") do
|
|
113
|
+
Api.fetch_time_entries(
|
|
114
|
+
started_from: first_day&.to_s,
|
|
115
|
+
started_to: last_day&.to_s
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if entries.empty?
|
|
120
|
+
puts "No time entries#{label ? " #{label}" : ""}."
|
|
121
|
+
return
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
if options[:format] == "json"
|
|
125
|
+
puts JSON.pretty_generate(entries)
|
|
126
|
+
return
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
maps = Spinner.spin("Resolving names") { Api.build_name_maps }
|
|
130
|
+
entries.sort_by! { |e| e["started_at"] || "" }
|
|
131
|
+
|
|
132
|
+
rows = entries.map do |e|
|
|
133
|
+
date = e["started_at"] || "?"
|
|
134
|
+
client = maps[:clients][e["client_id"].to_s] || e["client_id"].to_s
|
|
135
|
+
project = maps[:projects][e["project_id"].to_s] || "-"
|
|
136
|
+
note = (e["note"] || "").slice(0, 50)
|
|
137
|
+
hours = (e["duration"].to_i / 3600.0).round(2)
|
|
138
|
+
[date, client, project, note, "#{hours}h"]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
headers = ["Date", "Client", "Project", "Note", "Duration"]
|
|
142
|
+
widths = headers.each_with_index.map do |h, i|
|
|
143
|
+
[h.length, *rows.map { |r| r[i].to_s.length }].max
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
fmt = widths.map { |w| "%-#{w}s" }.join(" ")
|
|
147
|
+
puts fmt % headers
|
|
148
|
+
puts widths.map { |w| "-" * w }.join(" ")
|
|
149
|
+
rows.each { |r| puts fmt % r }
|
|
150
|
+
|
|
151
|
+
total = entries.sum { |e| e["duration"].to_i } / 3600.0
|
|
152
|
+
puts "\nTotal: #{total.round(2)}h"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# --- help ---
|
|
156
|
+
|
|
157
|
+
desc "help [COMMAND]", "Describe available commands or one specific command"
|
|
158
|
+
method_option :format, type: :string, desc: "Output format: text (default) or json"
|
|
159
|
+
def help(command = nil)
|
|
160
|
+
if options[:format] == "json"
|
|
161
|
+
puts JSON.pretty_generate(help_json)
|
|
162
|
+
return
|
|
163
|
+
end
|
|
164
|
+
super
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def select_client(defaults, interactive)
|
|
170
|
+
clients = Spinner.spin("Fetching clients") { Api.fetch_clients }
|
|
171
|
+
|
|
172
|
+
if options[:client]
|
|
173
|
+
match = clients.find { |c| display_name(c).downcase == options[:client].downcase }
|
|
174
|
+
abort("Client not found: #{options[:client]}") unless match
|
|
175
|
+
return match
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
abort("No clients found.") if clients.empty?
|
|
179
|
+
|
|
180
|
+
puts "\nClients:\n\n"
|
|
181
|
+
clients.each_with_index do |c, i|
|
|
182
|
+
name = display_name(c)
|
|
183
|
+
default_marker = c["id"].to_i == defaults["client_id"].to_i ? " [default]" : ""
|
|
184
|
+
puts " #{i + 1}. #{name}#{default_marker}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
default_idx = clients.index { |c| c["id"].to_i == defaults["client_id"].to_i }
|
|
188
|
+
prompt = default_idx ? "\nSelect client (1-#{clients.length}) [#{default_idx + 1}]: " : "\nSelect client (1-#{clients.length}): "
|
|
189
|
+
print prompt
|
|
190
|
+
input = $stdin.gets&.strip
|
|
191
|
+
|
|
192
|
+
idx = if input.nil? || input.empty?
|
|
193
|
+
default_idx || 0
|
|
194
|
+
else
|
|
195
|
+
input.to_i - 1
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
abort("Invalid selection.") if idx < 0 || idx >= clients.length
|
|
199
|
+
clients[idx]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def select_project(client_id, defaults, interactive)
|
|
203
|
+
projects = Spinner.spin("Fetching projects") { Api.fetch_projects_for_client(client_id) }
|
|
204
|
+
|
|
205
|
+
if options[:project]
|
|
206
|
+
match = projects.find { |p| p["title"].downcase == options[:project].downcase }
|
|
207
|
+
abort("Project not found: #{options[:project]}") unless match
|
|
208
|
+
return match
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
return nil if projects.empty?
|
|
212
|
+
|
|
213
|
+
puts "\nProjects:\n\n"
|
|
214
|
+
projects.each_with_index do |p, i|
|
|
215
|
+
default_marker = p["id"].to_i == defaults["project_id"].to_i ? " [default]" : ""
|
|
216
|
+
puts " #{i + 1}. #{p["title"]}#{default_marker}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
default_idx = projects.index { |p| p["id"].to_i == defaults["project_id"].to_i }
|
|
220
|
+
prompt = default_idx ? "\nSelect project (1-#{projects.length}, Enter to skip) [#{default_idx + 1}]: " : "\nSelect project (1-#{projects.length}, Enter to skip): "
|
|
221
|
+
print prompt
|
|
222
|
+
input = $stdin.gets&.strip
|
|
223
|
+
|
|
224
|
+
if input.nil? || input.empty?
|
|
225
|
+
return default_idx ? projects[default_idx] : nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
idx = input.to_i - 1
|
|
229
|
+
return nil if idx < 0 || idx >= projects.length
|
|
230
|
+
projects[idx]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def select_service(defaults, interactive)
|
|
234
|
+
if options[:service]
|
|
235
|
+
services = Spinner.spin("Fetching services") { Api.fetch_services }
|
|
236
|
+
match = services.find { |s| s["name"].downcase == options[:service].downcase }
|
|
237
|
+
abort("Service not found: #{options[:service]}") unless match
|
|
238
|
+
return match
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
return nil unless interactive
|
|
242
|
+
|
|
243
|
+
services = Spinner.spin("Fetching services") { Api.fetch_services }
|
|
244
|
+
return nil if services.empty?
|
|
245
|
+
|
|
246
|
+
puts "\nServices:\n\n"
|
|
247
|
+
services.each_with_index do |s, i|
|
|
248
|
+
default_marker = s["id"].to_i == defaults["service_id"].to_i ? " [default]" : ""
|
|
249
|
+
puts " #{i + 1}. #{s["name"]}#{default_marker}"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
default_idx = services.index { |s| s["id"].to_i == defaults["service_id"].to_i }
|
|
253
|
+
prompt = default_idx ? "\nSelect service (1-#{services.length}, Enter to skip) [#{default_idx + 1}]: " : "\nSelect service (1-#{services.length}, Enter to skip): "
|
|
254
|
+
print prompt
|
|
255
|
+
input = $stdin.gets&.strip
|
|
256
|
+
|
|
257
|
+
if input.nil? || input.empty?
|
|
258
|
+
return default_idx ? services[default_idx] : nil
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
idx = input.to_i - 1
|
|
262
|
+
return nil if idx < 0 || idx >= services.length
|
|
263
|
+
services[idx]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def pick_date(interactive)
|
|
267
|
+
return options[:date] if options[:date]
|
|
268
|
+
|
|
269
|
+
today = Date.today.to_s
|
|
270
|
+
return today unless interactive
|
|
271
|
+
|
|
272
|
+
print "\nDate [#{today}]: "
|
|
273
|
+
input = $stdin.gets&.strip
|
|
274
|
+
(input.nil? || input.empty?) ? today : input
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def pick_duration(interactive)
|
|
278
|
+
return options[:duration] if options[:duration]
|
|
279
|
+
|
|
280
|
+
print "\nDuration (hours): "
|
|
281
|
+
input = $stdin.gets&.strip
|
|
282
|
+
abort("Duration is required.") if input.nil? || input.empty?
|
|
283
|
+
input.to_f
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def pick_note(interactive)
|
|
287
|
+
return options[:note] if options[:note]
|
|
288
|
+
|
|
289
|
+
print "\nNote: "
|
|
290
|
+
input = $stdin.gets&.strip
|
|
291
|
+
abort("Note is required.") if input.nil? || input.empty?
|
|
292
|
+
input
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def display_name(client)
|
|
296
|
+
name = client["organization"]
|
|
297
|
+
(name.nil? || name.empty?) ? "#{client["fname"]} #{client["lname"]}" : name
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def help_json
|
|
301
|
+
{
|
|
302
|
+
name: "fb",
|
|
303
|
+
description: "FreshBooks time tracking CLI",
|
|
304
|
+
required_scopes: Auth::REQUIRED_SCOPES,
|
|
305
|
+
commands: {
|
|
306
|
+
auth: {
|
|
307
|
+
description: "Authenticate with FreshBooks via OAuth2",
|
|
308
|
+
usage: "fb auth",
|
|
309
|
+
interactive: true
|
|
310
|
+
},
|
|
311
|
+
log: {
|
|
312
|
+
description: "Log a time entry (interactive prompts with defaults from last use)",
|
|
313
|
+
usage: "fb log",
|
|
314
|
+
interactive: true,
|
|
315
|
+
flags: {
|
|
316
|
+
"--client" => "Pre-select client by name (skip prompt)",
|
|
317
|
+
"--project" => "Pre-select project by name (skip prompt)",
|
|
318
|
+
"--service" => "Pre-select service by name (skip prompt)",
|
|
319
|
+
"--duration" => "Duration in hours (e.g. 2.5)",
|
|
320
|
+
"--note" => "Work description",
|
|
321
|
+
"--date" => "Date (YYYY-MM-DD, defaults to today)",
|
|
322
|
+
"--yes" => "Skip confirmation prompt"
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
entries: {
|
|
326
|
+
description: "List time entries (defaults to current month)",
|
|
327
|
+
usage: "fb entries",
|
|
328
|
+
flags: {
|
|
329
|
+
"--from" => "Start date (YYYY-MM-DD, open-ended if omitted)",
|
|
330
|
+
"--to" => "End date (YYYY-MM-DD, open-ended if omitted)",
|
|
331
|
+
"--month" => "Month (1-12, defaults to current)",
|
|
332
|
+
"--year" => "Year (defaults to current)",
|
|
333
|
+
"--format" => "Output format: table (default) or json"
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
help: {
|
|
337
|
+
description: "Show help information",
|
|
338
|
+
usage: "fb help [COMMAND]",
|
|
339
|
+
flags: {
|
|
340
|
+
"--format" => "Output format: text (default) or json"
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
data/lib/fb/spinner.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FB
|
|
4
|
+
module Spinner
|
|
5
|
+
FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
6
|
+
|
|
7
|
+
def self.spin(message)
|
|
8
|
+
done = false
|
|
9
|
+
result = nil
|
|
10
|
+
|
|
11
|
+
thread = Thread.new do
|
|
12
|
+
i = 0
|
|
13
|
+
while !done
|
|
14
|
+
print "\r#{FRAMES[i % FRAMES.length]} #{message}"
|
|
15
|
+
$stdout.flush
|
|
16
|
+
i += 1
|
|
17
|
+
sleep 0.08
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
begin
|
|
22
|
+
result = yield
|
|
23
|
+
ensure
|
|
24
|
+
done = true
|
|
25
|
+
thread.join
|
|
26
|
+
print "\r✓ #{message}\n"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
result
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/fb/version.rb
ADDED
data/lib/fb.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: freshbooks-cli
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- parasquid
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: thor
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.3'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.3'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: httparty
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.24'
|
|
33
|
+
- - "<"
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '1.0'
|
|
36
|
+
type: :runtime
|
|
37
|
+
prerelease: false
|
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '0.24'
|
|
43
|
+
- - "<"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '1.0'
|
|
46
|
+
description: Manage FreshBooks time entries from the command line. Supports OAuth2
|
|
47
|
+
auth, interactive time logging with defaults, and monthly entry listings.
|
|
48
|
+
email:
|
|
49
|
+
- git@parasquid.com
|
|
50
|
+
executables:
|
|
51
|
+
- fb
|
|
52
|
+
extensions: []
|
|
53
|
+
extra_rdoc_files: []
|
|
54
|
+
files:
|
|
55
|
+
- bin/fb
|
|
56
|
+
- lib/fb.rb
|
|
57
|
+
- lib/fb/api.rb
|
|
58
|
+
- lib/fb/auth.rb
|
|
59
|
+
- lib/fb/cli.rb
|
|
60
|
+
- lib/fb/spinner.rb
|
|
61
|
+
- lib/fb/version.rb
|
|
62
|
+
homepage: https://github.com/parasquid/freshbooks-cli
|
|
63
|
+
licenses:
|
|
64
|
+
- GPL-3.0-only
|
|
65
|
+
metadata:
|
|
66
|
+
source_code_uri: https://github.com/parasquid/freshbooks-cli
|
|
67
|
+
bug_tracker_uri: https://github.com/parasquid/freshbooks-cli/issues
|
|
68
|
+
rdoc_options: []
|
|
69
|
+
require_paths:
|
|
70
|
+
- lib
|
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '3.0'
|
|
76
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
requirements: []
|
|
82
|
+
rubygems_version: 4.0.3
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: FreshBooks time tracking CLI
|
|
85
|
+
test_files: []
|