feat(cli): Add languages and image commands to all 42 SDKs

- Add `un languages [--json]` command to list available execution languages
- Add `un image` command with full image management:
  - --list, --info, --delete, --lock, --unlock
  - --publish, --visibility, --spawn, --clone
- Update CLI_SPEC.md with new command documentation
- All implementations follow consistent patterns
This commit is contained in:
russell@unturf.com 2026-01-17 10:25:20 -05:00
parent aa1d14289e
commit af995cdc1a
42 changed files with 9098 additions and 70 deletions

View file

@ -414,6 +414,26 @@ def cmd_session(args)
puts "#{YELLOW}(Interactive sessions require WebSocket - use un2 for full support)#{RESET}"
end
def cmd_languages(args)
public_key, secret_key = get_api_keys(args[:api_key]?)
result = api_request("/languages", public_key, secret_key)
languages = result["languages"]?.try(&.as_a?) || [] of JSON::Any
if args[:json]?.as?(Bool)
# Output as JSON array of language names
names = languages.map { |l| l["name"]?.try(&.as_s?) || "" }.reject(&.empty?)
puts names.to_json
else
# Output one language per line
languages.each do |lang|
if name = lang["name"]?.try(&.as_s?)
puts name
end
end
end
end
def cmd_key(args)
public_key, secret_key = get_api_keys(args[:api_key]?)
@ -508,6 +528,100 @@ def cmd_key(args)
end
end
def cmd_image(args)
public_key, secret_key = get_api_keys(args[:api_key]?)
if args[:list]?.as?(Bool)
result = api_request("/images", public_key, secret_key)
puts result.to_pretty_json
return
end
if info_id = args[:image_info]?.as?(String)
result = api_request("/images/#{info_id}", public_key, secret_key)
puts result.to_pretty_json
return
end
if del_id = args[:image_delete]?.as?(String)
api_request("/images/#{del_id}", public_key, secret_key, method: "DELETE")
puts "#{GREEN}Image deleted: #{del_id}#{RESET}"
return
end
if lock_id = args[:image_lock]?.as?(String)
payload = JSON.parse({}.to_json)
api_request("/images/#{lock_id}/lock", public_key, secret_key, method: "POST", data: payload)
puts "#{GREEN}Image locked: #{lock_id}#{RESET}"
return
end
if unlock_id = args[:image_unlock]?.as?(String)
payload = JSON.parse({}.to_json)
api_request("/images/#{unlock_id}/unlock", public_key, secret_key, method: "POST", data: payload)
puts "#{GREEN}Image unlocked: #{unlock_id}#{RESET}"
return
end
if publish_id = args[:image_publish]?.as?(String)
source_type = args[:image_source_type]?.as?(String)
if source_type.nil? || source_type.empty?
STDERR.puts "#{RED}Error: --publish requires --source-type (service or snapshot)#{RESET}"
exit 1
end
payload = JSON.parse({source_type: source_type, source_id: publish_id}.to_json)
if name = args[:image_name]?.as?(String)
payload.as_h["name"] = JSON::Any.new(name)
end
result = api_request("/images/publish", public_key, secret_key, method: "POST", data: payload)
puts "#{GREEN}Image published#{RESET}"
puts result.to_pretty_json
return
end
if visibility_id = args[:image_visibility_id]?.as?(String)
visibility = args[:image_visibility]?.as?(String)
if visibility.nil? || visibility.empty?
STDERR.puts "#{RED}Error: --visibility requires visibility mode (private, unlisted, public)#{RESET}"
exit 1
end
payload = JSON.parse({visibility: visibility}.to_json)
api_request("/images/#{visibility_id}/visibility", public_key, secret_key, method: "POST", data: payload)
puts "#{GREEN}Image visibility set to: #{visibility}#{RESET}"
return
end
if spawn_id = args[:image_spawn]?.as?(String)
payload = JSON.parse({}.to_json)
if name = args[:image_name]?.as?(String)
payload.as_h["name"] = JSON::Any.new(name)
end
if ports_str = args[:image_ports]?.as?(String)
ports = ports_str.split(',').map(&.to_i)
payload.as_h["ports"] = JSON.parse(ports.to_json)
end
result = api_request("/images/#{spawn_id}/spawn", public_key, secret_key, method: "POST", data: payload)
puts "#{GREEN}Service spawned from image#{RESET}"
puts result.to_pretty_json
return
end
if clone_id = args[:image_clone]?.as?(String)
payload = JSON.parse({}.to_json)
if name = args[:image_name]?.as?(String)
payload.as_h["name"] = JSON::Any.new(name)
end
result = api_request("/images/#{clone_id}/clone", public_key, secret_key, method: "POST", data: payload)
puts "#{GREEN}Image cloned#{RESET}"
puts result.to_pretty_json
return
end
# Default: list images
result = api_request("/images", public_key, secret_key)
puts result.to_pretty_json
end
def cmd_service(args)
# Handle env subcommand
if env_action = args[:env_action]?.as?(String)
@ -735,11 +849,24 @@ def main
svc_envs: [] of String,
svc_env_file: nil,
env_action: nil,
env_target: nil
env_target: nil,
json: false,
image_info: nil,
image_delete: nil,
image_lock: nil,
image_unlock: nil,
image_publish: nil,
image_source_type: nil,
image_visibility_id: nil,
image_visibility: nil,
image_spawn: nil,
image_clone: nil,
image_name: nil,
image_ports: nil
} of Symbol => (String | Array(String) | Bool | Nil)
parser = OptionParser.new do |opts|
opts.banner = "Usage: un.cr [options] <source_file>\n un.cr session [options]\n un.cr service [options]\n un.cr service env <action> <service_id> [options]\n un.cr key [options]\n\nService env commands:\n env status <id> Show vault status\n env set <id> Set vault (-e KEY=VALUE or --env-file FILE)\n env export <id> Export vault contents\n env delete <id> Delete vault"
opts.banner = "Usage: un.cr [options] <source_file>\n un.cr languages [--json]\n un.cr session [options]\n un.cr service [options]\n un.cr service env <action> <service_id> [options]\n un.cr key [options]\n\nService env commands:\n env status <id> Show vault status\n env set <id> Set vault (-e KEY=VALUE or --env-file FILE)\n env export <id> Export vault contents\n env delete <id> Delete vault"
opts.on("-k API_KEY", "--api-key=API_KEY", "API key") { |k| args[:api_key] = k }
opts.on("-n NETWORK", "--network=NETWORK", "Network mode") { |n| args[:network] = n }
@ -771,6 +898,7 @@ def main
opts.on("--bootstrap-file=FILE", "Upload local file as bootstrap script") { |f| args[:bootstrap_file] = f }
opts.on("--env-file=FILE", "Load env vars from file (for vault)") { |f| args[:svc_env_file] = f }
opts.on("--extend", "Open browser to extend/renew key") { args[:extend] = true }
opts.on("--json", "Output as JSON array (for languages command)") { args[:json] = true }
opts.unknown_args do |before, after|
if before.size > 0
@ -800,6 +928,99 @@ def main
end
when "key"
args[:command] = "key"
when "languages"
args[:command] = "languages"
when "image"
args[:command] = "image"
# Parse image subcommand options
i = 1
while i < before.size
case before[i]
when "--list", "-l"
args[:list] = true
i += 1
when "--info"
if i + 1 < before.size
args[:image_info] = before[i + 1]
i += 2
else
i += 1
end
when "--delete"
if i + 1 < before.size
args[:image_delete] = before[i + 1]
i += 2
else
i += 1
end
when "--lock"
if i + 1 < before.size
args[:image_lock] = before[i + 1]
i += 2
else
i += 1
end
when "--unlock"
if i + 1 < before.size
args[:image_unlock] = before[i + 1]
i += 2
else
i += 1
end
when "--publish"
if i + 1 < before.size
args[:image_publish] = before[i + 1]
i += 2
else
i += 1
end
when "--source-type"
if i + 1 < before.size
args[:image_source_type] = before[i + 1]
i += 2
else
i += 1
end
when "--visibility"
if i + 2 < before.size
args[:image_visibility_id] = before[i + 1]
args[:image_visibility] = before[i + 2]
i += 3
else
i += 1
end
when "--spawn"
if i + 1 < before.size
args[:image_spawn] = before[i + 1]
i += 2
else
i += 1
end
when "--clone"
if i + 1 < before.size
args[:image_clone] = before[i + 1]
i += 2
else
i += 1
end
when "--name"
if i + 1 < before.size
args[:image_name] = before[i + 1]
i += 2
else
i += 1
end
when "--ports"
if i + 1 < before.size
args[:image_ports] = before[i + 1]
i += 2
else
i += 1
end
else
i += 1
end
end
else
if before[0].starts_with?("-")
STDERR.puts "#{RED}Unknown option: #{before[0]}#{RESET}"
@ -820,6 +1041,10 @@ def main
cmd_service(args)
elsif args[:command] == "key"
cmd_key(args)
elsif args[:command] == "languages"
cmd_languages(args)
elsif args[:command] == "image"
cmd_image(args)
elsif args[:source_file]
cmd_execute(args)
else