brainiac 0.0.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 +7 -0
- checksums.yaml.gz.sig +2 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +126 -0
- data/README.md +1166 -0
- data/Rakefile +12 -0
- data/bin/brainiac +1521 -0
- data/brainiac.gemspec +30 -0
- data/certs/stowzilla.pem +26 -0
- data/docs/waybar-config.md +96 -0
- data/lib/brainiac/agents.rb +203 -0
- data/lib/brainiac/brain.rb +197 -0
- data/lib/brainiac/card_index.rb +389 -0
- data/lib/brainiac/config.rb +263 -0
- data/lib/brainiac/cron.rb +629 -0
- data/lib/brainiac/deployments.rb +258 -0
- data/lib/brainiac/handlers/discord.rb +1643 -0
- data/lib/brainiac/handlers/fizzy.rb +1249 -0
- data/lib/brainiac/handlers/github.rb +598 -0
- data/lib/brainiac/handlers/zoho.rb +487 -0
- data/lib/brainiac/helpers.rb +760 -0
- data/lib/brainiac/planning.rb +237 -0
- data/lib/brainiac/prompts.rb +620 -0
- data/lib/brainiac/sessions.rb +282 -0
- data/lib/brainiac/skills.rb +276 -0
- data/lib/brainiac/users.rb +76 -0
- data/lib/brainiac/version.rb +6 -0
- data/lib/brainiac/zoho_mail_api.rb +109 -0
- data/lib/brainiac.rb +10 -0
- data/lib/user_registry.rb +159 -0
- data/monitor/daemon.rb +99 -0
- data/monitor/deploy-env-macos.rb +131 -0
- data/monitor/menubar.rb +295 -0
- data/monitor/open-action.sh +15 -0
- data/monitor/setup-menubar.rb +78 -0
- data/monitor/setup-waybar-deploy-envs.rb +121 -0
- data/monitor/setup-waybar-deployments.rb +96 -0
- data/monitor/setup-waybar-module.rb +113 -0
- data/monitor/setup-xbar-plugin.rb +35 -0
- data/monitor/view-logs-macos.rb +210 -0
- data/monitor/view-logs-rofi.rb +194 -0
- data/monitor/view-logs.rb +119 -0
- data/monitor/waybar-config-updater.rb +56 -0
- data/monitor/waybar-deploy-env.rb +206 -0
- data/monitor/waybar-deployments.rb +239 -0
- data/monitor/waybar.rb +146 -0
- data/monitor/xbar.3s.rb +179 -0
- data/receiver.rb +956 -0
- data/templates/agents.json.example +10 -0
- data/templates/discord.json.example +17 -0
- data/templates/fizzy.json.example +24 -0
- data/templates/github.json.example +4 -0
- data/templates/testflight.json.example +8 -0
- data/templates/users.json.example +121 -0
- data/templates/zoho.json.example +27 -0
- data/views/dashboard.erb +437 -0
- data.tar.gz.sig +0 -0
- metadata +235 -0
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Zoho Mail REST API client for fetching email content.
|
|
4
|
+
# Used when webhook payloads don't include the body (which is most of the time).
|
|
5
|
+
#
|
|
6
|
+
# Requires zoho.json to have:
|
|
7
|
+
# "api": {
|
|
8
|
+
# "client_id": "...",
|
|
9
|
+
# "client_secret": "...",
|
|
10
|
+
# "refresh_token": "...",
|
|
11
|
+
# "account_id": "..."
|
|
12
|
+
# }
|
|
13
|
+
|
|
14
|
+
ZOHO_TOKEN_URL = "https://accounts.zoho.com/oauth/v2/token"
|
|
15
|
+
ZOHO_MAIL_API_BASE = "https://mail.zoho.com/api/accounts"
|
|
16
|
+
|
|
17
|
+
# In-memory cache for access token (expires after ~55 min to be safe)
|
|
18
|
+
@zoho_access_token = nil
|
|
19
|
+
@zoho_token_expires_at = Time.at(0)
|
|
20
|
+
|
|
21
|
+
def zoho_api_configured?
|
|
22
|
+
api = ZOHO_CONFIG["api"]
|
|
23
|
+
api && api["client_id"] && api["client_secret"] && api["refresh_token"] && api["account_id"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def zoho_refresh_access_token!
|
|
27
|
+
api = ZOHO_CONFIG["api"]
|
|
28
|
+
uri = URI(ZOHO_TOKEN_URL)
|
|
29
|
+
res = Net::HTTP.post_form(uri, {
|
|
30
|
+
"grant_type" => "refresh_token",
|
|
31
|
+
"client_id" => api["client_id"],
|
|
32
|
+
"client_secret" => api["client_secret"],
|
|
33
|
+
"refresh_token" => api["refresh_token"]
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
data = JSON.parse(res.body)
|
|
37
|
+
if data["access_token"]
|
|
38
|
+
@zoho_access_token = data["access_token"]
|
|
39
|
+
@zoho_token_expires_at = Time.now + 3300 # ~55 min
|
|
40
|
+
LOG.info "[Zoho:API] Refreshed access token"
|
|
41
|
+
@zoho_access_token
|
|
42
|
+
else
|
|
43
|
+
LOG.error "[Zoho:API] Token refresh failed: #{data["error"]}"
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
LOG.error "[Zoho:API] Token refresh error: #{e.message}"
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def zoho_access_token
|
|
52
|
+
return @zoho_access_token if @zoho_access_token && Time.now < @zoho_token_expires_at
|
|
53
|
+
|
|
54
|
+
zoho_refresh_access_token!
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Fetch email content by messageId using the "original message" endpoint.
|
|
58
|
+
# This endpoint doesn't require a folder ID — just accountId + messageId.
|
|
59
|
+
# Returns plain-text body extracted from the MIME content, or nil.
|
|
60
|
+
def fetch_zoho_email_content(message_id)
|
|
61
|
+
return nil unless zoho_api_configured?
|
|
62
|
+
|
|
63
|
+
token = zoho_access_token
|
|
64
|
+
return nil unless token
|
|
65
|
+
|
|
66
|
+
account_id = ZOHO_CONFIG.dig("api", "account_id")
|
|
67
|
+
uri = URI("#{ZOHO_MAIL_API_BASE}/#{account_id}/messages/#{message_id}/originalmessage")
|
|
68
|
+
|
|
69
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
70
|
+
http.use_ssl = true
|
|
71
|
+
req = Net::HTTP::Get.new(uri)
|
|
72
|
+
req["Authorization"] = "Zoho-oauthtoken #{token}"
|
|
73
|
+
req["Accept"] = "application/json"
|
|
74
|
+
|
|
75
|
+
res = http.request(req)
|
|
76
|
+
data = JSON.parse(res.body)
|
|
77
|
+
|
|
78
|
+
if data.dig("status", "code") == 200
|
|
79
|
+
raw_mime = data.dig("data", "content").to_s
|
|
80
|
+
text = extract_text_from_mime(raw_mime)
|
|
81
|
+
LOG.info "[Zoho:API] Fetched content for message #{message_id} (#{text.length} chars)"
|
|
82
|
+
text
|
|
83
|
+
else
|
|
84
|
+
LOG.warn "[Zoho:API] Failed to fetch content: #{data.dig("status", "description")}"
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
LOG.error "[Zoho:API] Error fetching content: #{e.message}"
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Extract readable text from raw MIME content.
|
|
93
|
+
# Prefers text/plain part; falls back to stripping HTML from text/html part.
|
|
94
|
+
def extract_text_from_mime(mime)
|
|
95
|
+
# Try to find text/plain part
|
|
96
|
+
if mime =~ %r{Content-Type: text/plain[^\r\n]*\r?\n(?:Content-Transfer-Encoding:[^\r\n]*\r?\n)?(?:\r?\n)(.*?)(?:\r?\n------=_Part|\z)}mi
|
|
97
|
+
return Regexp.last_match(1).gsub("\r\n", "\n").strip
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Fallback: extract text/html part and strip tags
|
|
101
|
+
if mime =~ %r{Content-Type: text/html[^\r\n]*\r?\n(?:Content-Transfer-Encoding:[^\r\n]*\r?\n)?(?:\r?\n)(.*?)(?:\r?\n------=_Part|\z)}mi
|
|
102
|
+
html = Regexp.last_match(1).gsub("\r\n", "\n")
|
|
103
|
+
return html.gsub(/<[^>]+>/, " ").gsub(/ /i, " ").gsub(/&/i, "&")
|
|
104
|
+
.gsub(/</i, "<").gsub(/>/i, ">").gsub(/\s+/, " ").strip
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Last resort: strip all HTML-ish content from the whole thing
|
|
108
|
+
mime.gsub(/<[^>]+>/, " ").gsub(/\s+/, " ").strip
|
|
109
|
+
end
|
data/lib/brainiac.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
require_relative "brainiac/config"
|
|
2
|
+
require_relative "brainiac/users"
|
|
3
|
+
require_relative "brainiac/agents"
|
|
4
|
+
require_relative "brainiac/brain"
|
|
5
|
+
require_relative "brainiac/skills"
|
|
6
|
+
require_relative "brainiac/sessions"
|
|
7
|
+
require_relative "brainiac/prompts"
|
|
8
|
+
require_relative "brainiac/planning"
|
|
9
|
+
require_relative "brainiac/helpers"
|
|
10
|
+
require_relative "brainiac/cron"
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
# UserRegistry - Centralized user identity tracking
|
|
7
|
+
#
|
|
8
|
+
# Resolves user identities across platforms (Discord, GitHub, Fizzy)
|
|
9
|
+
# and provides canonical names, aliases, and relationships.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# registry = UserRegistry.new
|
|
13
|
+
# user = registry.find_by_discord_id('832331260088287242')
|
|
14
|
+
# puts user['canonical_name'] # => "Adam Dalton"
|
|
15
|
+
# puts user['identities']['github']['username'] # => "dalton"
|
|
16
|
+
#
|
|
17
|
+
class UserRegistry
|
|
18
|
+
USERS_FILE = File.expand_path("~/.brainiac/users.json")
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@data = load_data
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Find user by Discord user ID
|
|
25
|
+
def find_by_discord_id(user_id)
|
|
26
|
+
@data["users"].find { |u| u.dig("identities", "discord", "user_id") == user_id.to_s }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Find user by Discord username
|
|
30
|
+
def find_by_discord_username(username)
|
|
31
|
+
@data["users"].find { |u| u.dig("identities", "discord", "username") == username.to_s }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Find user by GitHub username
|
|
35
|
+
def find_by_github_username(username)
|
|
36
|
+
@data["users"].find { |u| u.dig("identities", "github", "username") == username.to_s }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Find user by Fizzy username
|
|
40
|
+
def find_by_fizzy_username(username)
|
|
41
|
+
@data["users"].find { |u| u.dig("identities", "fizzy", "username") == username.to_s }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Find user by canonical name
|
|
45
|
+
def find_by_canonical_name(name)
|
|
46
|
+
@data["users"].find { |u| u["canonical_name"].downcase == name.downcase }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Find user by any identifier (tries all platforms)
|
|
50
|
+
def find(identifier)
|
|
51
|
+
find_by_discord_id(identifier) ||
|
|
52
|
+
find_by_discord_username(identifier) ||
|
|
53
|
+
find_by_github_username(identifier) ||
|
|
54
|
+
find_by_fizzy_username(identifier) ||
|
|
55
|
+
find_by_canonical_name(identifier)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get all users
|
|
59
|
+
def all
|
|
60
|
+
@data["users"]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Get all human users (exclude AI agents)
|
|
64
|
+
def humans
|
|
65
|
+
@data["users"].reject { |u| u["notes"]&.include?("AI agent") }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Get all AI agents
|
|
69
|
+
def agents
|
|
70
|
+
@data["users"].select { |u| u["notes"]&.include?("AI agent") }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Reload data from disk
|
|
74
|
+
def reload!
|
|
75
|
+
@data = load_data
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def load_data
|
|
81
|
+
return { "users" => [] } unless File.exist?(USERS_FILE)
|
|
82
|
+
|
|
83
|
+
JSON.parse(File.read(USERS_FILE))
|
|
84
|
+
rescue JSON::ParserError => e
|
|
85
|
+
warn "Failed to parse #{USERS_FILE}: #{e.message}"
|
|
86
|
+
{ "users" => [] }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# CLI interface when run directly
|
|
91
|
+
if __FILE__ == $PROGRAM_NAME
|
|
92
|
+
require "optparse"
|
|
93
|
+
|
|
94
|
+
options = {}
|
|
95
|
+
OptionParser.new do |opts|
|
|
96
|
+
opts.banner = "Usage: user_registry.rb [options] [identifier]"
|
|
97
|
+
|
|
98
|
+
opts.on("-d", "--discord-id ID", "Find by Discord user ID") do |id|
|
|
99
|
+
options[:discord_id] = id
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
opts.on("-u", "--discord-username USERNAME", "Find by Discord username") do |username|
|
|
103
|
+
options[:discord_username] = username
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
opts.on("-g", "--github USERNAME", "Find by GitHub username") do |username|
|
|
107
|
+
options[:github] = username
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
opts.on("-f", "--fizzy USERNAME", "Find by Fizzy username") do |username|
|
|
111
|
+
options[:fizzy] = username
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
opts.on("-l", "--list", "List all users") do
|
|
115
|
+
options[:list] = true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
opts.on("--humans", "List only human users") do
|
|
119
|
+
options[:humans] = true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
opts.on("--agents", "List only AI agents") do
|
|
123
|
+
options[:agents] = true
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
opts.on("-h", "--help", "Show this help") do
|
|
127
|
+
puts opts
|
|
128
|
+
exit
|
|
129
|
+
end
|
|
130
|
+
end.parse!
|
|
131
|
+
|
|
132
|
+
registry = UserRegistry.new
|
|
133
|
+
|
|
134
|
+
if options[:list]
|
|
135
|
+
puts JSON.pretty_generate(registry.all)
|
|
136
|
+
elsif options[:humans]
|
|
137
|
+
puts JSON.pretty_generate(registry.humans)
|
|
138
|
+
elsif options[:agents]
|
|
139
|
+
puts JSON.pretty_generate(registry.agents)
|
|
140
|
+
elsif options[:discord_id]
|
|
141
|
+
user = registry.find_by_discord_id(options[:discord_id])
|
|
142
|
+
puts user ? JSON.pretty_generate(user) : "User not found"
|
|
143
|
+
elsif options[:discord_username]
|
|
144
|
+
user = registry.find_by_discord_username(options[:discord_username])
|
|
145
|
+
puts user ? JSON.pretty_generate(user) : "User not found"
|
|
146
|
+
elsif options[:github]
|
|
147
|
+
user = registry.find_by_github_username(options[:github])
|
|
148
|
+
puts user ? JSON.pretty_generate(user) : "User not found"
|
|
149
|
+
elsif options[:fizzy]
|
|
150
|
+
user = registry.find_by_fizzy_username(options[:fizzy])
|
|
151
|
+
puts user ? JSON.pretty_generate(user) : "User not found"
|
|
152
|
+
elsif ARGV[0]
|
|
153
|
+
user = registry.find(ARGV[0])
|
|
154
|
+
puts user ? JSON.pretty_generate(user) : "User not found"
|
|
155
|
+
else
|
|
156
|
+
puts "No search criteria provided. Use --help for usage."
|
|
157
|
+
exit 1
|
|
158
|
+
end
|
|
159
|
+
end
|
data/monitor/daemon.rb
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Brainiac Monitor Daemon
|
|
5
|
+
# Polls /api/status and exposes agent state via Unix socket for waybar
|
|
6
|
+
# No longer triggers config updates - waybar module polls this socket directly
|
|
7
|
+
|
|
8
|
+
require "json"
|
|
9
|
+
require "net/http"
|
|
10
|
+
require "socket"
|
|
11
|
+
|
|
12
|
+
SOCKET_PATH = "/tmp/brainiac-monitor.sock"
|
|
13
|
+
API_URL = "http://localhost:4567/api/status"
|
|
14
|
+
POLL_INTERVAL = 2 # seconds
|
|
15
|
+
CONFIG_PATH = File.expand_path("~/.brainiac/waybar.json")
|
|
16
|
+
|
|
17
|
+
# Load agent configuration from JSON
|
|
18
|
+
def load_agent_config
|
|
19
|
+
config = JSON.parse(File.read(CONFIG_PATH))
|
|
20
|
+
agents = {}
|
|
21
|
+
config["agents"].each do |agent|
|
|
22
|
+
agents[agent["name"]] = { color: agent["color"], emoji: agent["emoji"] }
|
|
23
|
+
end
|
|
24
|
+
agents
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
warn "Failed to load waybar.json: #{e.message}"
|
|
27
|
+
{}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
AGENTS = load_agent_config.freeze
|
|
31
|
+
|
|
32
|
+
@state = { sessions: [], count: 0, recent: [], last_update: nil }
|
|
33
|
+
|
|
34
|
+
def fetch_status
|
|
35
|
+
uri = URI(API_URL)
|
|
36
|
+
response = Net::HTTP.get_response(uri)
|
|
37
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
38
|
+
|
|
39
|
+
JSON.parse(response.body)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
warn "Failed to fetch status: #{e.message}"
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def update_state
|
|
46
|
+
data = fetch_status
|
|
47
|
+
return unless data
|
|
48
|
+
|
|
49
|
+
@state = {
|
|
50
|
+
sessions: data["sessions"],
|
|
51
|
+
count: data["count"],
|
|
52
|
+
recent: data["recent"] || [],
|
|
53
|
+
last_update: Time.now.to_i
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def handle_client(client)
|
|
58
|
+
client.puts @state.to_json
|
|
59
|
+
client.close
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
warn "Error handling client: #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def start_server
|
|
65
|
+
FileUtils.rm_f(SOCKET_PATH)
|
|
66
|
+
|
|
67
|
+
server = UNIXServer.new(SOCKET_PATH)
|
|
68
|
+
File.chmod(0o666, SOCKET_PATH)
|
|
69
|
+
|
|
70
|
+
# Write PID file
|
|
71
|
+
File.write("/tmp/brainiac-daemon.pid", Process.pid)
|
|
72
|
+
|
|
73
|
+
puts "Monitor daemon started, socket: #{SOCKET_PATH}"
|
|
74
|
+
|
|
75
|
+
# Start polling thread
|
|
76
|
+
poller = Thread.new do
|
|
77
|
+
loop do
|
|
78
|
+
update_state
|
|
79
|
+
sleep POLL_INTERVAL
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Initial state fetch
|
|
84
|
+
update_state
|
|
85
|
+
|
|
86
|
+
# Accept client connections
|
|
87
|
+
loop do
|
|
88
|
+
client = server.accept
|
|
89
|
+
Thread.new { handle_client(client) }
|
|
90
|
+
end
|
|
91
|
+
rescue Interrupt
|
|
92
|
+
puts "\nShutting down..."
|
|
93
|
+
poller&.kill
|
|
94
|
+
FileUtils.rm_f(SOCKET_PATH)
|
|
95
|
+
FileUtils.rm_f("/tmp/brainiac-daemon.pid")
|
|
96
|
+
exit 0
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
start_server
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Brainiac macOS Deploy Action
|
|
5
|
+
# Prompts for a Fizzy card number via osascript, finds the worktree, and deploys.
|
|
6
|
+
# Usage: deploy-env-macos.rb <env_key>
|
|
7
|
+
|
|
8
|
+
require "English"
|
|
9
|
+
require "json"
|
|
10
|
+
require "net/http"
|
|
11
|
+
require "shellwords"
|
|
12
|
+
require "uri"
|
|
13
|
+
|
|
14
|
+
SERVER_URL = "http://localhost:4567"
|
|
15
|
+
|
|
16
|
+
env_key = ARGV[0]
|
|
17
|
+
exit unless env_key
|
|
18
|
+
|
|
19
|
+
def fetch_deployments
|
|
20
|
+
uri = URI("#{SERVER_URL}/api/deployments")
|
|
21
|
+
response = Net::HTTP.get_response(uri)
|
|
22
|
+
JSON.parse(response.body)["deployments"] || []
|
|
23
|
+
rescue StandardError
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
deployments = fetch_deployments
|
|
28
|
+
deployment = deployments&.find { |d| d["env"] == env_key }
|
|
29
|
+
|
|
30
|
+
prefill = deployment && deployment["status"] == "occupied" && deployment["card_number"] ? deployment["card_number"].to_s : ""
|
|
31
|
+
|
|
32
|
+
# Prompt via AppleScript
|
|
33
|
+
prompt_script = <<~APPLESCRIPT
|
|
34
|
+
set defaultAnswer to "#{prefill}"
|
|
35
|
+
set dialogResult to display dialog "Fizzy card number:" default answer defaultAnswer with title "Deploy to #{env_key}" buttons {"Cancel", "Deploy"} default button "Deploy"
|
|
36
|
+
return text returned of dialogResult
|
|
37
|
+
APPLESCRIPT
|
|
38
|
+
|
|
39
|
+
card_number = `osascript -e #{Shellwords.escape(prompt_script)} 2>/dev/null`.strip
|
|
40
|
+
exit if card_number.empty?
|
|
41
|
+
|
|
42
|
+
# Find worktree via card_map.json
|
|
43
|
+
card_map_path = File.expand_path("~/.brainiac/card_map.json")
|
|
44
|
+
worktree = nil
|
|
45
|
+
if File.exist?(card_map_path)
|
|
46
|
+
card_map = begin
|
|
47
|
+
JSON.parse(File.read(card_map_path))
|
|
48
|
+
rescue StandardError
|
|
49
|
+
{}
|
|
50
|
+
end
|
|
51
|
+
entry = card_map.values.find { |e| e["card_number"].to_s == card_number }
|
|
52
|
+
worktree = entry["worktree"] if entry && entry["worktree"] && File.directory?(entry["worktree"].to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Fallback: glob for worktree directories
|
|
56
|
+
unless worktree
|
|
57
|
+
matches = Dir.glob(File.expand_path("~/projects/sogholdings/*fizzy-#{card_number}-*/"))
|
|
58
|
+
worktree = matches.find { |d| File.directory?(d) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
unless worktree
|
|
62
|
+
system("osascript", "-e",
|
|
63
|
+
"display dialog \"No worktree found for card ##{card_number}\" buttons {\"OK\"} default button \"OK\" with title \"Deploy Failed\" with icon stop")
|
|
64
|
+
exit
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Resolve AWS_PROFILE
|
|
68
|
+
config_file = File.expand_path("~/.brainiac/deployments.json")
|
|
69
|
+
aws_profile = nil
|
|
70
|
+
if File.exist?(config_file)
|
|
71
|
+
cfg = begin
|
|
72
|
+
JSON.parse(File.read(config_file))
|
|
73
|
+
rescue StandardError
|
|
74
|
+
{}
|
|
75
|
+
end
|
|
76
|
+
aws_profile = cfg.dig("environments", env_key, "aws_profile")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Mark deploying
|
|
80
|
+
begin
|
|
81
|
+
uri = URI("#{SERVER_URL}/api/deployments/#{env_key}/deploying")
|
|
82
|
+
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
|
|
83
|
+
req.body = { worktree: worktree }.to_json
|
|
84
|
+
Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
|
|
85
|
+
rescue StandardError
|
|
86
|
+
# Non-fatal
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Build deploy script
|
|
90
|
+
deploy_script = <<~BASH
|
|
91
|
+
cd #{Shellwords.escape(worktree)}
|
|
92
|
+
#{"export AWS_PROFILE=#{Shellwords.escape(aws_profile)}" if aws_profile}
|
|
93
|
+
echo "🚀 Deploying card ##{card_number} to #{env_key}..."
|
|
94
|
+
echo " Worktree: #{worktree}"
|
|
95
|
+
echo
|
|
96
|
+
logfile=$(mktemp)
|
|
97
|
+
./scripts/deploy.sh #{Shellwords.escape(env_key)} 2>&1 | tee "$logfile"
|
|
98
|
+
status=${PIPESTATUS[0]}
|
|
99
|
+
if [ $status -ne 0 ] && grep -q "checksums previously recorded in the dependency lock file" "$logfile"; then
|
|
100
|
+
echo
|
|
101
|
+
echo "⚠️ Terraform lock file mismatch — removing lock and running init -upgrade..."
|
|
102
|
+
echo
|
|
103
|
+
rm -f infrastructure/#{Shellwords.escape(env_key)}/.terraform.lock.hcl
|
|
104
|
+
(cd infrastructure/#{Shellwords.escape(env_key)} && terraform init -upgrade)
|
|
105
|
+
echo
|
|
106
|
+
echo "🔄 Retrying deploy..."
|
|
107
|
+
echo
|
|
108
|
+
./scripts/deploy.sh #{Shellwords.escape(env_key)} 2>&1
|
|
109
|
+
status=$?
|
|
110
|
+
fi
|
|
111
|
+
rm -f "$logfile"
|
|
112
|
+
echo
|
|
113
|
+
if [ $status -eq 0 ]; then echo "✅ Deploy complete"; else echo "❌ Deploy failed (exit $status)"; fi
|
|
114
|
+
echo
|
|
115
|
+
echo "Press any key to close..."
|
|
116
|
+
read -n 1
|
|
117
|
+
BASH
|
|
118
|
+
|
|
119
|
+
# Write to temp file and run in Terminal.app
|
|
120
|
+
script_file = "/tmp/brainiac-deploy-#{env_key}-#{$PROCESS_ID}.sh"
|
|
121
|
+
File.write(script_file, deploy_script)
|
|
122
|
+
File.chmod(0o755, script_file)
|
|
123
|
+
|
|
124
|
+
terminal_script = <<~APPLESCRIPT
|
|
125
|
+
tell application "Terminal"
|
|
126
|
+
activate
|
|
127
|
+
do script "#{script_file.gsub('"', '\\"')}; rm -f #{script_file.gsub('"', '\\"')}"
|
|
128
|
+
end tell
|
|
129
|
+
APPLESCRIPT
|
|
130
|
+
|
|
131
|
+
system("osascript", "-e", terminal_script)
|