rogiq 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/exe/rogiq +49 -0
- data/lib/rogiq/cli.rb +51 -0
- data/lib/rogiq/commands/accounts.rb +114 -0
- data/lib/rogiq/commands/ai.rb +71 -0
- data/lib/rogiq/commands/auth.rb +194 -0
- data/lib/rogiq/commands/base.rb +26 -0
- data/lib/rogiq/commands/billing.rb +90 -0
- data/lib/rogiq/commands/billing_stripe.rb +15 -0
- data/lib/rogiq/commands/clients.rb +117 -0
- data/lib/rogiq/commands/content.rb +71 -0
- data/lib/rogiq/commands/diagnose.rb +134 -0
- data/lib/rogiq/commands/jobs.rb +126 -0
- data/lib/rogiq/commands/queue.rb +115 -0
- data/lib/rogiq/commands/security.rb +33 -0
- data/lib/rogiq/commands/seo.rb +49 -0
- data/lib/rogiq/commands/seo_audit.rb +51 -0
- data/lib/rogiq/commands/status.rb +166 -0
- data/lib/rogiq/commands/sync.rb +60 -0
- data/lib/rogiq/config_store.rb +59 -0
- data/lib/rogiq/formatters.rb +103 -0
- data/lib/rogiq/helpers.rb +53 -0
- data/lib/rogiq/http_api.rb +81 -0
- data/lib/rogiq/rails_loader.rb +22 -0
- data/lib/rogiq/remote/accounts.rb +38 -0
- data/lib/rogiq/remote/ai.rb +50 -0
- data/lib/rogiq/remote/billing.rb +39 -0
- data/lib/rogiq/remote/clients.rb +44 -0
- data/lib/rogiq/remote/command_base.rb +41 -0
- data/lib/rogiq/remote/content.rb +37 -0
- data/lib/rogiq/remote/diagnose.rb +24 -0
- data/lib/rogiq/remote/jobs.rb +52 -0
- data/lib/rogiq/remote/queue.rb +72 -0
- data/lib/rogiq/remote/security.rb +26 -0
- data/lib/rogiq/remote/seo.rb +32 -0
- data/lib/rogiq/remote/status.rb +35 -0
- data/lib/rogiq/remote/sync.rb +49 -0
- data/lib/rogiq/remote_cli.rb +73 -0
- data/lib/rogiq/version.rb +5 -0
- metadata +96 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RogIQ
|
|
4
|
+
module Commands
|
|
5
|
+
class SEOAudit < RogIQ::Commands::Base
|
|
6
|
+
desc "run CLIENT", "Create audit run + enqueue SeoModule::AuditKickoffJob"
|
|
7
|
+
map run: :kickoff
|
|
8
|
+
def kickoff(client_identifier)
|
|
9
|
+
RogIQ.load_rails!
|
|
10
|
+
client = RogIQ::Helpers.resolve_client(client_identifier)
|
|
11
|
+
unless client
|
|
12
|
+
fmt.error_msg("Client not found")
|
|
13
|
+
exit 1
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
settings = ::ClientSeoModuleSetting.find_or_create_by!(client_id: client.id)
|
|
17
|
+
seed = client.website.presence
|
|
18
|
+
if seed.blank?
|
|
19
|
+
fmt.error_msg("Client needs a website URL for audit seed")
|
|
20
|
+
exit 1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
audit_run = client.client_seo_module_audit_runs.create!(
|
|
24
|
+
status: "pending",
|
|
25
|
+
max_pages: settings.crawl_max_pages,
|
|
26
|
+
seed_url: seed.to_s.strip,
|
|
27
|
+
started_at: Time.current
|
|
28
|
+
)
|
|
29
|
+
::SeoModule::AuditKickoffJob.perform_later(audit_run.id)
|
|
30
|
+
fmt.success("Audit run #{audit_run.id} enqueued.")
|
|
31
|
+
fmt.output(id: audit_run.id, status: audit_run.status, seed_url: audit_run.seed_url)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
desc "status CLIENT", "Latest SEO module audit runs"
|
|
35
|
+
def status(client_identifier)
|
|
36
|
+
RogIQ.load_rails!
|
|
37
|
+
client = RogIQ::Helpers.resolve_client(client_identifier)
|
|
38
|
+
unless client
|
|
39
|
+
fmt.error_msg("Client not found")
|
|
40
|
+
exit 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
runs = client.client_seo_module_audit_runs.order(created_at: :desc).limit(15)
|
|
44
|
+
rows = runs.map do |r|
|
|
45
|
+
[r.id, r.status, r.seed_url.to_s.truncate(60), r.created_at.iso8601]
|
|
46
|
+
end
|
|
47
|
+
fmt.output(headers: %w[id status seed_url created_at], rows: rows)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module RogIQ
|
|
7
|
+
module Commands
|
|
8
|
+
class Status < RogIQ::Commands::Base
|
|
9
|
+
default_task :dashboard
|
|
10
|
+
|
|
11
|
+
desc "dashboard", "System health (default: full dashboard)"
|
|
12
|
+
method_option :ping, type: :boolean, default: false, desc: "HTTP GET /up only (no Rails boot)"
|
|
13
|
+
method_option :queues, type: :boolean, default: false, desc: "Solid Queue breakdown by queue name"
|
|
14
|
+
method_option :db, type: :boolean, default: false, desc: "Database connectivity + schema version"
|
|
15
|
+
method_option :redis, type: :boolean, default: false, desc: "Redis ping (REDIS_URL)"
|
|
16
|
+
method_option :ai, type: :boolean, default: false, desc: "AI provider env configuration"
|
|
17
|
+
method_option :host, type: :string, default: nil, desc: "Base URL for --ping (default ROGIQ_HEALTH_URL or http://127.0.0.1:3000)"
|
|
18
|
+
|
|
19
|
+
def dashboard
|
|
20
|
+
return ping_only if options[:ping]
|
|
21
|
+
|
|
22
|
+
subset = options[:queues] || options[:db] || options[:redis] || options[:ai]
|
|
23
|
+
return dashboard_partial if subset
|
|
24
|
+
|
|
25
|
+
dashboard_full
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def ping_only
|
|
31
|
+
host_opt = options[:host].to_s.strip
|
|
32
|
+
base = if host_opt.empty?
|
|
33
|
+
ENV.fetch("ROGIQ_HEALTH_URL", "http://127.0.0.1:3000").chomp("/")
|
|
34
|
+
else
|
|
35
|
+
host_opt.chomp("/")
|
|
36
|
+
end
|
|
37
|
+
uri = URI("#{base}/up")
|
|
38
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
39
|
+
http.use_ssl = uri.scheme == "https"
|
|
40
|
+
http.open_timeout = 3
|
|
41
|
+
http.read_timeout = 5
|
|
42
|
+
req_path = uri.path.to_s
|
|
43
|
+
req_path = "/" if req_path.empty?
|
|
44
|
+
res = http.request_get(req_path)
|
|
45
|
+
payload = { url: uri.to_s, code: res.code, body: res.body.to_s[0, 200] }
|
|
46
|
+
fmt.output(payload)
|
|
47
|
+
exit(res.code.to_i >= 400 ? 1 : 0)
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
fmt.error_msg("Ping failed: #{e.message}")
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def dashboard_partial
|
|
54
|
+
RogIQ.load_rails!
|
|
55
|
+
rows = []
|
|
56
|
+
headers = %w[check status detail]
|
|
57
|
+
|
|
58
|
+
if options[:queues]
|
|
59
|
+
rows.concat(queue_rows)
|
|
60
|
+
end
|
|
61
|
+
if options[:db]
|
|
62
|
+
rows << db_row
|
|
63
|
+
end
|
|
64
|
+
if options[:redis]
|
|
65
|
+
rows << redis_row
|
|
66
|
+
end
|
|
67
|
+
if options[:ai]
|
|
68
|
+
rows << ai_row
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
fmt.output(headers: headers, rows: rows)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def dashboard_full
|
|
75
|
+
RogIQ.load_rails!
|
|
76
|
+
headers = %w[check status detail]
|
|
77
|
+
rows = []
|
|
78
|
+
rows << db_row
|
|
79
|
+
rows << redis_row
|
|
80
|
+
rows << ai_row
|
|
81
|
+
rows << queue_summary_row
|
|
82
|
+
rows.concat(queue_rows) if solid_queue_tables?
|
|
83
|
+
|
|
84
|
+
fmt.output(headers: headers, rows: rows)
|
|
85
|
+
|
|
86
|
+
host = ENV.fetch("ROGIQ_HEALTH_URL", "http://127.0.0.1:3000").chomp("/")
|
|
87
|
+
ping_http("#{host}/health", "HTTP /health")
|
|
88
|
+
ping_http("#{host}/up", "HTTP /up")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def ping_http(url, label)
|
|
92
|
+
uri = URI(url)
|
|
93
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
94
|
+
http.use_ssl = uri.scheme == "https"
|
|
95
|
+
http.open_timeout = 2
|
|
96
|
+
http.read_timeout = 3
|
|
97
|
+
res = http.request_get(uri.path)
|
|
98
|
+
fmt.say("#{label}: #{res.code}")
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
fmt.warn_msg("#{label}: unreachable (#{e.message})")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def db_row
|
|
104
|
+
ver = ActiveRecord::Base.connection.select_value(
|
|
105
|
+
"SELECT MAX(version) FROM schema_migrations"
|
|
106
|
+
)
|
|
107
|
+
["database", "ok", "migration=#{ver}"]
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
["database", "error", e.message.to_s[0, 120]]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def redis_row
|
|
113
|
+
url = ENV["REDIS_URL"].presence
|
|
114
|
+
return ["redis", "skipped", "REDIS_URL not set"] unless url
|
|
115
|
+
|
|
116
|
+
require "redis"
|
|
117
|
+
r = Redis.new(url: url)
|
|
118
|
+
pong = r.ping
|
|
119
|
+
info = r.info("memory") rescue {}
|
|
120
|
+
used = info["used_memory_human"]
|
|
121
|
+
["redis", pong || "ok", "memory=#{used}"]
|
|
122
|
+
rescue LoadError
|
|
123
|
+
["redis", "skipped", "redis gem"]
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
["redis", "error", e.message.to_s[0, 120]]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def ai_row
|
|
129
|
+
openai = ENV["OPENAI_API_KEY"].present?
|
|
130
|
+
anthropic = ENV["ANTHROPIC_API_KEY"].present?
|
|
131
|
+
detail = "openai=#{openai} anthropic=#{anthropic}"
|
|
132
|
+
ready = defined?(AIEngine) && AIEngine.ready?
|
|
133
|
+
["ai_engine", ready ? "ready" : "not_ready", detail]
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
["ai_engine", "error", e.message.to_s[0, 120]]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def queue_summary_row
|
|
139
|
+
return ["solid_queue", "n/a", "tables missing"] unless solid_queue_tables?
|
|
140
|
+
|
|
141
|
+
r = SolidQueue::ReadyExecution.count
|
|
142
|
+
s = SolidQueue::ScheduledExecution.count
|
|
143
|
+
f = SolidQueue::FailedExecution.count
|
|
144
|
+
c = SolidQueue::ClaimedExecution.count
|
|
145
|
+
["solid_queue", "ok", "ready=#{r} scheduled=#{s} failed=#{f} claimed=#{c}"]
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
["solid_queue", "error", e.message.to_s[0, 120]]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def queue_rows
|
|
151
|
+
return [] unless solid_queue_tables?
|
|
152
|
+
|
|
153
|
+
SolidQueue::Job.group(:queue_name).count.sort.map do |name, cnt|
|
|
154
|
+
paused = SolidQueue::Queue.find_by_name(name).paused?
|
|
155
|
+
["queue:#{name}", paused ? "paused" : "active", "jobs=#{cnt}"]
|
|
156
|
+
end
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
[["queues", "error", e.message.to_s[0, 120]]]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def solid_queue_tables?
|
|
162
|
+
ActiveRecord::Base.connection.data_source_exists?("solid_queue_jobs")
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RogIQ
|
|
4
|
+
module Commands
|
|
5
|
+
class Sync < RogIQ::Commands::Base
|
|
6
|
+
desc "postman", "rails postman:sync"
|
|
7
|
+
def postman
|
|
8
|
+
RogIQ.load_rails!
|
|
9
|
+
Rails.application.load_tasks
|
|
10
|
+
Rake::Task["postman:sync"].invoke
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
desc "stripe", "rake stripe:ensure_subscription_prices"
|
|
14
|
+
def stripe
|
|
15
|
+
RogIQ.load_rails!
|
|
16
|
+
Rails.application.load_tasks
|
|
17
|
+
Rake::Task["stripe:ensure_subscription_prices"].invoke
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc "wordpress CLIENT", "Enqueue WordPress taxonomies + authors sync jobs"
|
|
21
|
+
def wordpress(identifier)
|
|
22
|
+
RogIQ.load_rails!
|
|
23
|
+
client = RogIQ::Helpers.resolve_client(identifier)
|
|
24
|
+
unless client
|
|
25
|
+
fmt.error_msg("Client not found")
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
::Integrations::SyncWordpressTaxonomiesJob.perform_later(
|
|
30
|
+
account_id: client.account_id,
|
|
31
|
+
client_id: client.id
|
|
32
|
+
)
|
|
33
|
+
::Integrations::SyncWordpressAuthorsJob.perform_later(
|
|
34
|
+
account_id: client.account_id,
|
|
35
|
+
client_id: client.id
|
|
36
|
+
)
|
|
37
|
+
fmt.success("WordPress sync jobs enqueued for client #{client.id}.")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
desc "integrations CLIENT", "Enqueue Integrations::SyncJob for each client integration"
|
|
41
|
+
def integrations(identifier)
|
|
42
|
+
RogIQ.load_rails!
|
|
43
|
+
client = RogIQ::Helpers.resolve_client(identifier)
|
|
44
|
+
unless client
|
|
45
|
+
fmt.error_msg("Client not found")
|
|
46
|
+
exit 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
ids = client.integrations.pluck(:id)
|
|
50
|
+
if ids.empty?
|
|
51
|
+
fmt.warn_msg("No integrations linked to this client.")
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
ids.each { |iid| ::Integrations::SyncJob.perform_later(iid) }
|
|
56
|
+
fmt.success("Enqueued #{ids.size} Integrations::SyncJob(s).")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module RogIQ
|
|
7
|
+
module ConfigStore
|
|
8
|
+
DIR = File.join(Dir.home, ".rogiq").freeze
|
|
9
|
+
PATH = File.join(DIR, "config.yml").freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def load
|
|
14
|
+
return {} unless File.file?(PATH)
|
|
15
|
+
|
|
16
|
+
YAML.safe_load(File.read(PATH), permitted_classes: [ Symbol, Time ]) || {}
|
|
17
|
+
rescue Psych::SyntaxError
|
|
18
|
+
{}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def save(hash)
|
|
22
|
+
FileUtils.mkdir_p(DIR)
|
|
23
|
+
File.write(PATH, YAML.dump(stringify_keys(hash)))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def merge!(attrs)
|
|
27
|
+
save(load.merge(stringify_keys(attrs)))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def clear!
|
|
31
|
+
FileUtils.rm_f(PATH)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def remote_available?
|
|
35
|
+
h = load
|
|
36
|
+
!h["token"].to_s.strip.empty? && !h["api_base_url"].to_s.strip.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def api_base_url
|
|
40
|
+
v = load["api_base_url"].to_s.strip
|
|
41
|
+
v.empty? ? nil : v
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def token
|
|
45
|
+
v = load["token"].to_s.strip
|
|
46
|
+
v.empty? ? nil : v
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def account_id
|
|
50
|
+
v = load["account_id"]
|
|
51
|
+
s = v.to_s.strip
|
|
52
|
+
s.empty? ? nil : s
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def stringify_keys(hash)
|
|
56
|
+
hash.to_h.transform_keys(&:to_s)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
module RogIQ
|
|
7
|
+
class Formatters
|
|
8
|
+
attr_reader :format, :quiet
|
|
9
|
+
|
|
10
|
+
def initialize(format: "table", quiet: false)
|
|
11
|
+
@format = format.to_s
|
|
12
|
+
@quiet = quiet
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def say(msg = "")
|
|
16
|
+
Kernel.puts(msg) unless quiet
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def success(msg)
|
|
20
|
+
say(colorize(msg, "\e[32m"))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def warn_msg(msg)
|
|
24
|
+
say(colorize(msg, "\e[33m"))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def error_msg(msg)
|
|
28
|
+
$stderr.puts(colorize(msg, "\e[31m"))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def output(data)
|
|
32
|
+
if data.is_a?(Hash) && data[:headers] && data[:rows]
|
|
33
|
+
return table(data[:headers], data[:rows]) if format != "json"
|
|
34
|
+
|
|
35
|
+
STDOUT.puts(JSON.pretty_generate({ headers: data[:headers], rows: data[:rows] }))
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if format == "json"
|
|
40
|
+
STDOUT.puts(JSON.pretty_generate(jsonify(data)))
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
case data
|
|
45
|
+
when Hash
|
|
46
|
+
say(JSON.pretty_generate(jsonify(data)))
|
|
47
|
+
when String
|
|
48
|
+
say(data)
|
|
49
|
+
else
|
|
50
|
+
say(data.inspect)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def jsonify(obj)
|
|
55
|
+
case obj
|
|
56
|
+
when Hash
|
|
57
|
+
obj.transform_values { |v| jsonify(v) }
|
|
58
|
+
when Array
|
|
59
|
+
obj.map { |v| jsonify(v) }
|
|
60
|
+
when Time, DateTime
|
|
61
|
+
obj.iso8601
|
|
62
|
+
when Date
|
|
63
|
+
obj.iso8601
|
|
64
|
+
else
|
|
65
|
+
if obj.respond_to?(:iso8601)
|
|
66
|
+
obj.iso8601
|
|
67
|
+
else
|
|
68
|
+
obj
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def table(headers, rows)
|
|
74
|
+
return if quiet
|
|
75
|
+
|
|
76
|
+
rows = rows.map { |r| r.map(&:to_s) }
|
|
77
|
+
widths = headers.each_with_index.map do |h, i|
|
|
78
|
+
[h.to_s.length, *rows.map { |r| r[i].to_s.length }].max
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
sep = widths.map { |w| "-" * w }.join("-+-")
|
|
82
|
+
header_line = headers.each_with_index.map { |h, i| h.to_s.ljust(widths[i]) }.join(" | ")
|
|
83
|
+
say(header_line)
|
|
84
|
+
say(sep)
|
|
85
|
+
rows.each do |row|
|
|
86
|
+
say(row.each_with_index.map { |cell, i| cell.to_s.ljust(widths[i]) }.join(" | "))
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def confirm!(prompt, yes: false)
|
|
91
|
+
return true if yes
|
|
92
|
+
|
|
93
|
+
print("#{prompt} [y/N] ")
|
|
94
|
+
$stdin.gets.to_s.strip.downcase.start_with?("y")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def colorize(str, code)
|
|
98
|
+
return str unless $stdout.tty?
|
|
99
|
+
|
|
100
|
+
"#{code}#{str}\e[0m"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RogIQ
|
|
4
|
+
module Helpers
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
UUID_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
8
|
+
|
|
9
|
+
def uuid?(value)
|
|
10
|
+
value.to_s.match?(UUID_RE)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def resolve_client(identifier)
|
|
14
|
+
RogIQ.load_rails!
|
|
15
|
+
s = identifier.to_s.strip
|
|
16
|
+
return nil if s.blank?
|
|
17
|
+
|
|
18
|
+
if uuid?(s)
|
|
19
|
+
::Client.find_by(id: s)
|
|
20
|
+
else
|
|
21
|
+
::Client.find_by("LOWER(name) = ?", s.downcase) ||
|
|
22
|
+
::Client.joins(:account).find_by("LOWER(accounts.slug) = ?", s.downcase) ||
|
|
23
|
+
::Client.find_by(id: s)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def resolve_account(identifier)
|
|
28
|
+
RogIQ.load_rails!
|
|
29
|
+
s = identifier.to_s.strip
|
|
30
|
+
return nil if s.blank?
|
|
31
|
+
|
|
32
|
+
if uuid?(s)
|
|
33
|
+
::Account.find_by(id: s)
|
|
34
|
+
else
|
|
35
|
+
::Account.find_by("LOWER(slug) = ?", s.downcase) ||
|
|
36
|
+
::Account.find_by("LOWER(name) = ?", s.downcase) ||
|
|
37
|
+
::Account.find_by(id: s)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fmt_from_options(options)
|
|
42
|
+
RogIQ::Formatters.new(format: options[:format] || options["format"], quiet: options[:quiet] || options["quiet"])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def with_saved_argv(new_argv)
|
|
46
|
+
old = ARGV.dup
|
|
47
|
+
ARGV.replace(new_argv)
|
|
48
|
+
yield
|
|
49
|
+
ensure
|
|
50
|
+
ARGV.replace(old)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module RogIQ
|
|
8
|
+
class HttpApi
|
|
9
|
+
class Error < StandardError
|
|
10
|
+
attr_reader :status, :body
|
|
11
|
+
|
|
12
|
+
def initialize(message, status: nil, body: nil)
|
|
13
|
+
super(message)
|
|
14
|
+
@status = status
|
|
15
|
+
@body = body
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.from_config!
|
|
20
|
+
base = RogIQ::ConfigStore.api_base_url
|
|
21
|
+
token = RogIQ::ConfigStore.token
|
|
22
|
+
raise Error, "Not logged in. Run: rogiq login" if base.nil? || token.nil?
|
|
23
|
+
|
|
24
|
+
new(base: base, token: token, account_id: RogIQ::ConfigStore.account_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(base:, token:, account_id: nil)
|
|
28
|
+
@base = base.to_s.chomp("/")
|
|
29
|
+
@token = token
|
|
30
|
+
@account_id = account_id
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get(path, query = {})
|
|
34
|
+
request(Net::HTTP::Get, path, query: query)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def post(path, body = nil, query: {})
|
|
38
|
+
request(Net::HTTP::Post, path, query: query, body: body)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def request(method_class, path, query: {}, body: nil)
|
|
44
|
+
p = path.start_with?("/") ? path : "/#{path}"
|
|
45
|
+
uri = URI.parse("#{@base}#{p}")
|
|
46
|
+
uri.query = URI.encode_www_form(query.compact) if query.any?
|
|
47
|
+
|
|
48
|
+
req = method_class.new(uri)
|
|
49
|
+
req["Authorization"] = "Bearer #{@token}"
|
|
50
|
+
req["Accept"] = "application/json"
|
|
51
|
+
if body
|
|
52
|
+
req["Content-Type"] = "application/json"
|
|
53
|
+
req.body = JSON.generate(body)
|
|
54
|
+
end
|
|
55
|
+
aid = @account_id.to_s.strip
|
|
56
|
+
req["X-Account-ID"] = aid unless aid.empty?
|
|
57
|
+
|
|
58
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
59
|
+
http.use_ssl = uri.scheme == "https"
|
|
60
|
+
http.open_timeout = 15
|
|
61
|
+
http.read_timeout = 120
|
|
62
|
+
res = http.request(req)
|
|
63
|
+
payload = parse_json_body(res)
|
|
64
|
+
|
|
65
|
+
return payload if res.is_a?(Net::HTTPSuccess)
|
|
66
|
+
|
|
67
|
+
msg = [ payload["error"], payload["message"] ].map { |x| x.to_s.strip }.reject(&:empty?).first
|
|
68
|
+
msg ||= "HTTP #{res.code}"
|
|
69
|
+
raise Error.new(msg.to_s, status: res.code.to_i, body: payload)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse_json_body(res)
|
|
73
|
+
b = res.body.to_s.strip
|
|
74
|
+
return {} if b.empty?
|
|
75
|
+
|
|
76
|
+
JSON.parse(b)
|
|
77
|
+
rescue JSON::ParserError
|
|
78
|
+
{ "error" => b[0, 300] }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RogIQ
|
|
4
|
+
# Boots the Rails application (api/) once. Safe to call multiple times.
|
|
5
|
+
def self.load_rails!
|
|
6
|
+
return if defined?(Rails) && Rails.application&.initialized?
|
|
7
|
+
|
|
8
|
+
root = api_root
|
|
9
|
+
Dir.chdir(root) do
|
|
10
|
+
require File.join(root, "config/environment")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.api_root
|
|
15
|
+
File.expand_path("../..", __dir__)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Monorepo root (parent of +api/+).
|
|
19
|
+
def self.repo_root
|
|
20
|
+
File.expand_path("../../..", __dir__)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rogiq/remote/command_base"
|
|
4
|
+
|
|
5
|
+
module RogIQ
|
|
6
|
+
module Remote
|
|
7
|
+
class Accounts < RogIQ::Remote::CommandBase
|
|
8
|
+
desc "list", "List accounts"
|
|
9
|
+
method_option :search, type: :string
|
|
10
|
+
method_option :limit, type: :numeric, default: 40, aliases: "-n"
|
|
11
|
+
def list
|
|
12
|
+
q = { limit: options[:limit] }
|
|
13
|
+
q[:search] = options[:search] unless options[:search].to_s.strip.empty?
|
|
14
|
+
emit(api.get("/api/v1/cli/accounts/list", q))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc "show IDENTIFIER", "Account by id, slug, or name"
|
|
18
|
+
def show(identifier)
|
|
19
|
+
emit(api.get("/api/v1/cli/accounts/show", { identifier: identifier }))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
desc "usage IDENTIFIER", "Usage meters, wallet, open overages"
|
|
23
|
+
def usage(identifier)
|
|
24
|
+
emit(api.get("/api/v1/cli/accounts/usage", { identifier: identifier }))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
desc "subscription IDENTIFIER", "Subscription + plan summary"
|
|
28
|
+
def subscription(identifier)
|
|
29
|
+
emit(api.get("/api/v1/cli/accounts/subscription", { identifier: identifier }))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
desc "users IDENTIFIER", "Active account users + roles"
|
|
33
|
+
def users(identifier)
|
|
34
|
+
emit(api.get("/api/v1/cli/accounts/users", { identifier: identifier }))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rogiq/remote/command_base"
|
|
4
|
+
|
|
5
|
+
module RogIQ
|
|
6
|
+
module Remote
|
|
7
|
+
class Ai < RogIQ::Remote::CommandBase
|
|
8
|
+
desc "status", "AI engine readiness + provider keys"
|
|
9
|
+
def status
|
|
10
|
+
emit(api.get("/api/v1/cli/ai/status"))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
desc "cost", "Roll up AI usage cost (ai_usage_daily)"
|
|
14
|
+
method_option :days, type: :numeric, default: 30
|
|
15
|
+
method_option :account, type: :string, desc: "Account id or slug"
|
|
16
|
+
def cost
|
|
17
|
+
q = { days: options[:days] }
|
|
18
|
+
q[:account] = options[:account] unless options[:account].to_s.strip.empty?
|
|
19
|
+
emit(api.get("/api/v1/cli/ai/cost", q))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
desc "models", "List AIModelConfig rows"
|
|
23
|
+
method_option :limit, type: :numeric, default: 100
|
|
24
|
+
def models
|
|
25
|
+
emit(api.get("/api/v1/cli/ai/models", { limit: options[:limit] }))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
desc "sync", "Enqueue AiModelSyncJob"
|
|
29
|
+
method_option :yes, type: :boolean, default: false, aliases: "-y"
|
|
30
|
+
def sync
|
|
31
|
+
unless options[:yes]
|
|
32
|
+
exit 1 unless yes?("Enqueue AiModelSyncJob?")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
emit(api.post("/api/v1/cli/ai/sync", { confirm: true }))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc "health_check", "Enqueue AiModelHealthCheckJob"
|
|
39
|
+
map "health-check" => :health_check
|
|
40
|
+
method_option :yes, type: :boolean, default: false, aliases: "-y"
|
|
41
|
+
def health_check
|
|
42
|
+
unless options[:yes]
|
|
43
|
+
exit 1 unless yes?("Enqueue AiModelHealthCheckJob?")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
emit(api.post("/api/v1/cli/ai/health_check", { confirm: true }))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|