kyper 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/kyper +6 -0
- data/lib/kyper_cli/cli.rb +71 -0
- data/lib/kyper_cli/client.rb +139 -0
- data/lib/kyper_cli/commands/env.rb +38 -0
- data/lib/kyper_cli/commands/helpers.rb +22 -0
- data/lib/kyper_cli/commands/init.rb +317 -0
- data/lib/kyper_cli/commands/login.rb +83 -0
- data/lib/kyper_cli/commands/logs.rb +91 -0
- data/lib/kyper_cli/commands/push.rb +120 -0
- data/lib/kyper_cli/commands/retry_build.rb +42 -0
- data/lib/kyper_cli/commands/status.rb +33 -0
- data/lib/kyper_cli/commands/validate.rb +174 -0
- data/lib/kyper_cli/commands/versions.rb +45 -0
- data/lib/kyper_cli/config.rb +32 -0
- data/lib/kyper_cli/version.rb +3 -0
- data/lib/kyper_cli.rb +19 -0
- metadata +139 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 48a5c2c1884d61917d62514a4cdedab308dce0c15cab578d5b9ca1d34d80b823
|
|
4
|
+
data.tar.gz: 1a6814b989f4237596c2848fd366b4f92d2a9c6be71e73c0b9fd9d0bb6bada11
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4fee94e7ae2d97f3376a4e58904de70c527522ce7aa147bf917f3700f23b0f35ad8f01ca9bf4ea754425f15cd2c9e670b18da846e32fd56d08c10ba770d711b9
|
|
7
|
+
data.tar.gz: 5da228cff5fc62b9ea4ace2597f650b1e757a11ffa61391ee348d1a270ba6900d5b1aea2c4578cdbb219765413e5085484a0649f6bf3d9336af0d56a95d37ecf
|
data/exe/kyper
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module KyperCli
|
|
2
|
+
class CLI < Thor
|
|
3
|
+
include Thor::Actions
|
|
4
|
+
|
|
5
|
+
package_name "kyper"
|
|
6
|
+
|
|
7
|
+
desc "login", "Authenticate with your Kyper account"
|
|
8
|
+
def login
|
|
9
|
+
Commands::Login.new.login
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
desc "init", "Create a kyper.yml in the current directory"
|
|
13
|
+
def init
|
|
14
|
+
Commands::Init.new.init
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc "push", "Validate, package, and submit the current version for review"
|
|
18
|
+
def push
|
|
19
|
+
Commands::Push.new.push
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
desc "validate", "Validate kyper.yml without pushing"
|
|
23
|
+
def validate
|
|
24
|
+
passed = Commands::Validate.new.validate
|
|
25
|
+
exit 1 unless passed
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
desc "status", "Show the review status of your app"
|
|
29
|
+
def status
|
|
30
|
+
Commands::Status.new.status
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
desc "logs", "Show the build log for the latest version"
|
|
34
|
+
def logs
|
|
35
|
+
Commands::Logs.new.logs
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc "retry", "Retry a failed build"
|
|
39
|
+
def retry
|
|
40
|
+
Commands::RetryBuild.new.retry_build
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
desc "whoami", "Show the currently authenticated user"
|
|
44
|
+
def whoami
|
|
45
|
+
token = Config.api_token
|
|
46
|
+
unless token
|
|
47
|
+
say "Not logged in. Run `kyper login`.", :red
|
|
48
|
+
exit 1
|
|
49
|
+
end
|
|
50
|
+
user = Client.new.me
|
|
51
|
+
say "#{user["email"]} (#{user["role"]})"
|
|
52
|
+
rescue KyperCli::Error => e
|
|
53
|
+
say "Error: #{e.message}", :red
|
|
54
|
+
exit 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
desc "env SUBCOMMAND", "Manage environment variables declared by your app"
|
|
58
|
+
subcommand "env", Commands::Env
|
|
59
|
+
|
|
60
|
+
desc "versions SUBCOMMAND", "Manage app versions"
|
|
61
|
+
subcommand "versions", Commands::Versions
|
|
62
|
+
|
|
63
|
+
map %w[--version -v] => :version
|
|
64
|
+
desc "version", "Print the kyper CLI version"
|
|
65
|
+
def version
|
|
66
|
+
say KyperCli::VERSION
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.exit_on_failure? = true
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
require "faraday/multipart"
|
|
2
|
+
|
|
3
|
+
module KyperCli
|
|
4
|
+
class Client
|
|
5
|
+
def self.new_unauthenticated
|
|
6
|
+
new(nil)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(token = Config.api_token)
|
|
10
|
+
@token = token
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def me
|
|
14
|
+
get("/api/v1/me")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_app(params)
|
|
18
|
+
post("/api/v1/apps", app: params)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def push_version(slug, kyper_yml_content, zip_path: nil)
|
|
22
|
+
if zip_path
|
|
23
|
+
response = multipart_connection.post("/api/v1/apps/#{slug}/versions") do |req|
|
|
24
|
+
req.headers["Authorization"] = "Bearer #{@token}"
|
|
25
|
+
req.body = {
|
|
26
|
+
source_zip: Faraday::Multipart::FilePart.new(zip_path, "application/zip"),
|
|
27
|
+
kyper_yml: kyper_yml_content
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
handle(response)
|
|
31
|
+
else
|
|
32
|
+
post("/api/v1/apps/#{slug}/versions", { kyper_yml: kyper_yml_content })
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def app_status(slug)
|
|
37
|
+
get("/api/v1/apps/#{slug}/status")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def app_env(slug)
|
|
41
|
+
get("/api/v1/apps/#{slug}/env")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# cursor: byte offset of last-seen log content (0 = from start)
|
|
45
|
+
def version_build_log(version_id, cursor: 0)
|
|
46
|
+
get("/api/v1/versions/#{version_id}/build_log?cursor=#{cursor}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def version_retry(version_id)
|
|
50
|
+
post("/api/v1/versions/#{version_id}/retry")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def version_withdraw(version_id)
|
|
54
|
+
delete("/api/v1/versions/#{version_id}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Device auth — unauthenticated calls used by `kyper login`
|
|
58
|
+
def request_device_code
|
|
59
|
+
post_unauthenticated("/api/v1/device/authorize")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def poll_device_token(code)
|
|
63
|
+
get_unauthenticated("/api/v1/device/token?code=#{code}")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def get(path)
|
|
69
|
+
response = connection.get(path)
|
|
70
|
+
handle(response)
|
|
71
|
+
rescue Faraday::Error => e
|
|
72
|
+
raise KyperCli::Error, "Cannot reach Kyper — check your connection (#{e.message})"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def post(path, body = {})
|
|
76
|
+
response = connection.post(path) do |req|
|
|
77
|
+
req.headers["Content-Type"] = "application/json"
|
|
78
|
+
req.body = body.to_json
|
|
79
|
+
end
|
|
80
|
+
handle(response)
|
|
81
|
+
rescue Faraday::Error => e
|
|
82
|
+
raise KyperCli::Error, "Cannot reach Kyper — check your connection (#{e.message})"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def delete(path)
|
|
86
|
+
response = connection.delete(path)
|
|
87
|
+
handle(response)
|
|
88
|
+
rescue Faraday::Error => e
|
|
89
|
+
raise KyperCli::Error, "Cannot reach Kyper — check your connection (#{e.message})"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def get_unauthenticated(path)
|
|
93
|
+
response = unauthenticated_connection.get(path)
|
|
94
|
+
handle(response)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def post_unauthenticated(path, body = {})
|
|
98
|
+
response = unauthenticated_connection.post(path) do |req|
|
|
99
|
+
req.headers["Content-Type"] = "application/json"
|
|
100
|
+
req.body = body.to_json
|
|
101
|
+
end
|
|
102
|
+
handle(response)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def handle(response)
|
|
106
|
+
body = JSON.parse(response.body) rescue response.body
|
|
107
|
+
raise Error, body["error"] || body.to_s if response.status >= 400
|
|
108
|
+
body
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def connection
|
|
112
|
+
@connection ||= Faraday.new(url: Config.api_host) do |f|
|
|
113
|
+
f.headers["Authorization"] = "Bearer #{@token}" if @token
|
|
114
|
+
f.headers["Accept"] = "application/json"
|
|
115
|
+
f.request :url_encoded
|
|
116
|
+
f.adapter Faraday.default_adapter
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def multipart_connection
|
|
121
|
+
@multipart_connection ||= Faraday.new(url: Config.api_host) do |f|
|
|
122
|
+
f.headers["Authorization"] = "Bearer #{@token}" if @token
|
|
123
|
+
f.headers["Accept"] = "application/json"
|
|
124
|
+
f.request :multipart
|
|
125
|
+
f.adapter Faraday.default_adapter
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def unauthenticated_connection
|
|
130
|
+
@unauthenticated_connection ||= Faraday.new(url: Config.api_host) do |f|
|
|
131
|
+
f.headers["Accept"] = "application/json"
|
|
132
|
+
f.request :url_encoded
|
|
133
|
+
f.adapter Faraday.default_adapter
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
Error = Class.new(StandardError)
|
|
139
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module KyperCli
|
|
2
|
+
module Commands
|
|
3
|
+
class Env < Thor
|
|
4
|
+
include Helpers
|
|
5
|
+
|
|
6
|
+
desc "list", "List environment variables declared by your app"
|
|
7
|
+
def list
|
|
8
|
+
# Try local kyper.yml first (works offline)
|
|
9
|
+
yml = Config.kyper_yml
|
|
10
|
+
env_keys = yml&.dig("env")
|
|
11
|
+
|
|
12
|
+
if env_keys.nil? && !Config.api_token.to_s.empty?
|
|
13
|
+
# Fall back to API if kyper.yml has no env section but we're logged in
|
|
14
|
+
slug = to_slug(yml["name"]) if yml
|
|
15
|
+
env_keys = Client.new.app_env(slug)["env"] if slug
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
env_keys = Array(env_keys)
|
|
19
|
+
|
|
20
|
+
say ""
|
|
21
|
+
if env_keys.empty?
|
|
22
|
+
say " No environment variables declared.", :yellow
|
|
23
|
+
say " Add an \"env:\" section to kyper.yml to declare required env vars."
|
|
24
|
+
else
|
|
25
|
+
say " Environment variables:"
|
|
26
|
+
say ""
|
|
27
|
+
env_keys.each { |key| say " #{key}" }
|
|
28
|
+
say ""
|
|
29
|
+
say " Consumers set these values when configuring their deployment.", :cyan
|
|
30
|
+
end
|
|
31
|
+
say ""
|
|
32
|
+
rescue KyperCli::Error => e
|
|
33
|
+
say "Error: #{e.message}", :red
|
|
34
|
+
exit 1
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module KyperCli
|
|
2
|
+
module Commands
|
|
3
|
+
module Helpers
|
|
4
|
+
def require_auth!
|
|
5
|
+
return if !Config.api_token.to_s.empty?
|
|
6
|
+
say "Error: Not logged in. Run `kyper login` first.", :red
|
|
7
|
+
exit 1
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def load_kyper_yml!
|
|
11
|
+
yml = Config.kyper_yml
|
|
12
|
+
return yml if yml
|
|
13
|
+
say "Error: No kyper.yml found. Run `kyper init` to create one.", :red
|
|
14
|
+
exit 1
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_slug(name)
|
|
18
|
+
name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
require "json"
|
|
3
|
+
require "tty-prompt"
|
|
4
|
+
|
|
5
|
+
module KyperCli
|
|
6
|
+
module Commands
|
|
7
|
+
class Init < Thor
|
|
8
|
+
KNOWN_DEPS = %w[postgres mysql redis elasticsearch opensearch].freeze
|
|
9
|
+
DB_DEPS = %w[postgres mysql].freeze
|
|
10
|
+
|
|
11
|
+
STACK_DEPLOY_HOOKS = {
|
|
12
|
+
rails: "bundle exec rails db:migrate",
|
|
13
|
+
django: "python manage.py migrate",
|
|
14
|
+
prisma: "npx prisma migrate deploy"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
IMAGE_TO_DEP = {
|
|
18
|
+
"postgres" => "postgres",
|
|
19
|
+
"mysql" => "mysql",
|
|
20
|
+
"redis" => "redis",
|
|
21
|
+
"elasticsearch" => "elasticsearch",
|
|
22
|
+
"opensearch" => "opensearch"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
CATEGORIES = {
|
|
26
|
+
"developer_tools" => "Developer Tools",
|
|
27
|
+
"productivity" => "Productivity",
|
|
28
|
+
"finance" => "Finance",
|
|
29
|
+
"health" => "Health",
|
|
30
|
+
"media" => "Media",
|
|
31
|
+
"education" => "Education",
|
|
32
|
+
"business_operations" => "Business Operations",
|
|
33
|
+
"data_analytics" => "Data & Analytics",
|
|
34
|
+
"gaming" => "Gaming"
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
TEMPLATE = <<~YAML
|
|
38
|
+
name: "%<name>s"
|
|
39
|
+
version: "0.1.0"
|
|
40
|
+
category: %<category>s
|
|
41
|
+
description: "A short description of your app"
|
|
42
|
+
|
|
43
|
+
docker:
|
|
44
|
+
dockerfile: "./Dockerfile" # Kyper builds from source — no registry account needed
|
|
45
|
+
|
|
46
|
+
# Define your process types. "web" is required and must bind to $PORT.
|
|
47
|
+
processes:
|
|
48
|
+
%<processes>s
|
|
49
|
+
# Managed backing services. Allowed values: #{KNOWN_DEPS.join(", ")}
|
|
50
|
+
deps:%<deps>s
|
|
51
|
+
%<hooks>s%<healthcheck>s%<pricing>s
|
|
52
|
+
resources:
|
|
53
|
+
min_memory_mb: 512
|
|
54
|
+
min_cpu: 1
|
|
55
|
+
YAML
|
|
56
|
+
|
|
57
|
+
desc "init", "Create a kyper.yml in the current directory"
|
|
58
|
+
def init
|
|
59
|
+
if File.exist?(Config::KYPER_FILE)
|
|
60
|
+
say " kyper.yml already exists. Edit it directly.", :yellow
|
|
61
|
+
exit 0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
prompt = TTY::Prompt.new(interrupt: :exit)
|
|
65
|
+
|
|
66
|
+
say ""
|
|
67
|
+
say " Kyper — New App Setup"
|
|
68
|
+
say " " + ("─" * 30)
|
|
69
|
+
say ""
|
|
70
|
+
|
|
71
|
+
# App name
|
|
72
|
+
default_name = File.basename(Dir.pwd)
|
|
73
|
+
name = prompt.ask(" App name:", default: default_name) { |q| q.required(true) }.strip
|
|
74
|
+
|
|
75
|
+
# Category (arrow-key selection)
|
|
76
|
+
category = prompt.select(" Category:", CATEGORIES.invert, per_page: CATEGORIES.size, cycle: true)
|
|
77
|
+
|
|
78
|
+
# Detect processes from Procfile
|
|
79
|
+
processes = detect_processes
|
|
80
|
+
if processes.any? && processes != { "web" => "bundle exec puma -C config/puma.rb" }
|
|
81
|
+
say ""
|
|
82
|
+
say " Auto-detected from Procfile:", :cyan
|
|
83
|
+
processes.each { |k, v| say " ✔ #{k}: #{v}", :cyan }
|
|
84
|
+
keep = prompt.yes?(" Use these processes?", default: true)
|
|
85
|
+
processes = { "web" => "bundle exec puma -C config/puma.rb" } unless keep
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Detect deps from docker-compose
|
|
89
|
+
deps = detect_deps
|
|
90
|
+
if deps.any?
|
|
91
|
+
say ""
|
|
92
|
+
say " Auto-detected from docker-compose:", :cyan
|
|
93
|
+
deps.each { |d| say " ✔ #{d}", :cyan }
|
|
94
|
+
keep = prompt.yes?(" Include these dependencies?", default: true)
|
|
95
|
+
deps = [] unless keep
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Suggest dep versions from lockfiles
|
|
99
|
+
dep_versions = suggest_dep_versions(deps)
|
|
100
|
+
if dep_versions.any?
|
|
101
|
+
say ""
|
|
102
|
+
say " Suggested dep versions (from project lockfiles):", :cyan
|
|
103
|
+
dep_versions.each { |dep, ver| say " ✔ #{dep}: \"#{ver}\"", :cyan }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Deploy hooks (only prompted when a DB dep is present)
|
|
107
|
+
hooks_block = prompt_hooks(prompt, deps)
|
|
108
|
+
|
|
109
|
+
# Health check path
|
|
110
|
+
healthcheck_block = prompt_healthcheck(prompt)
|
|
111
|
+
|
|
112
|
+
# Pricing
|
|
113
|
+
say ""
|
|
114
|
+
one_time_raw = prompt.ask(" One-time price in USD (leave blank to skip):") do |q|
|
|
115
|
+
q.convert(:float?) { |v| v.to_s.strip.empty? ? nil : v.to_f }
|
|
116
|
+
end
|
|
117
|
+
sub_raw = prompt.ask(" Monthly subscription price in USD (leave blank to skip):") do |q|
|
|
118
|
+
q.convert(:float?) { |v| v.to_s.strip.empty? ? nil : v.to_f }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
pricing_lines = build_pricing_block(one_time_raw, sub_raw)
|
|
122
|
+
|
|
123
|
+
content = format(TEMPLATE,
|
|
124
|
+
name: name,
|
|
125
|
+
category: category,
|
|
126
|
+
processes: format_processes(processes),
|
|
127
|
+
deps: format_deps(deps, dep_versions),
|
|
128
|
+
hooks: hooks_block,
|
|
129
|
+
healthcheck: healthcheck_block,
|
|
130
|
+
pricing: pricing_lines)
|
|
131
|
+
|
|
132
|
+
# Preview before writing
|
|
133
|
+
say ""
|
|
134
|
+
say " Generated kyper.yml preview:", :cyan
|
|
135
|
+
say " " + ("─" * 40)
|
|
136
|
+
content.each_line { |line| say " #{line}" }
|
|
137
|
+
say " " + ("─" * 40)
|
|
138
|
+
say ""
|
|
139
|
+
|
|
140
|
+
write = prompt.yes?(" Write kyper.yml?", default: true)
|
|
141
|
+
unless write
|
|
142
|
+
say " Aborted — nothing written.", :yellow
|
|
143
|
+
say ""
|
|
144
|
+
exit 0
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
File.write(Config::KYPER_FILE, content)
|
|
148
|
+
|
|
149
|
+
say ""
|
|
150
|
+
say " ✔ kyper.yml created!", :green
|
|
151
|
+
say ""
|
|
152
|
+
say " Next steps:", :cyan
|
|
153
|
+
say " 1. Add a Dockerfile if you don't have one"
|
|
154
|
+
say " 2. Run `kyper validate` to check your config"
|
|
155
|
+
say " 3. Run `kyper push` to submit for review"
|
|
156
|
+
say ""
|
|
157
|
+
rescue TTY::Reader::InputInterrupt
|
|
158
|
+
say ""
|
|
159
|
+
say " Aborted.", :yellow
|
|
160
|
+
say ""
|
|
161
|
+
exit 0
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def build_pricing_block(one_time, subscription)
|
|
167
|
+
return "pricing:\n one_time: 49.00 # USD — omit to disable\n # subscription: 12.00 # USD/month — omit to disable\n" if one_time.nil? && subscription.nil?
|
|
168
|
+
|
|
169
|
+
lines = [ "pricing:" ]
|
|
170
|
+
lines << " one_time: #{one_time}" if one_time
|
|
171
|
+
lines << " subscription: #{subscription}" if subscription
|
|
172
|
+
lines.join("\n") + "\n"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def detect_processes
|
|
176
|
+
return { "web" => "bundle exec puma -C config/puma.rb" } unless File.exist?("Procfile")
|
|
177
|
+
|
|
178
|
+
processes = {}
|
|
179
|
+
File.each_line("Procfile") do |line|
|
|
180
|
+
line.chomp!
|
|
181
|
+
next if line.strip.empty? || line.start_with?("#")
|
|
182
|
+
|
|
183
|
+
name, _, command = line.partition(":")
|
|
184
|
+
name = name.strip
|
|
185
|
+
command = command.strip
|
|
186
|
+
processes[name] = command if !name.empty? && !command.empty?
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
processes["web"] ||= "bundle exec puma -C config/puma.rb"
|
|
190
|
+
processes
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def detect_deps
|
|
194
|
+
compose_file = %w[docker-compose.yml docker-compose.yaml compose.yml compose.yaml].find { |f| File.exist?(f) }
|
|
195
|
+
return [] unless compose_file
|
|
196
|
+
|
|
197
|
+
compose = YAML.safe_load(File.read(compose_file), permitted_classes: []) || {}
|
|
198
|
+
services = compose["services"] || {}
|
|
199
|
+
|
|
200
|
+
detected = []
|
|
201
|
+
services.each_value do |svc|
|
|
202
|
+
next unless svc.is_a?(Hash)
|
|
203
|
+
|
|
204
|
+
image = svc["image"].to_s.downcase
|
|
205
|
+
IMAGE_TO_DEP.each do |fragment, dep|
|
|
206
|
+
detected << dep if image.include?(fragment) && !detected.include?(dep)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
detected
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def format_processes(processes)
|
|
213
|
+
processes.map { |name, cmd| " #{name}: \"#{cmd}\"" }.join("\n")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def format_deps(deps, versions = {})
|
|
217
|
+
return "\n # - postgres\n # - redis" if deps.empty?
|
|
218
|
+
|
|
219
|
+
"\n" + deps.map { |d|
|
|
220
|
+
ver = versions[d]
|
|
221
|
+
ver ? " - #{d}: \"#{ver}\"" : " - #{d}"
|
|
222
|
+
}.join("\n")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Suggests dep major version pins based on detected project lockfiles.
|
|
226
|
+
# Returns a hash of {dep_name => version_string} for deps with clear signals.
|
|
227
|
+
def suggest_dep_versions(deps)
|
|
228
|
+
versions = {}
|
|
229
|
+
has_gemfile_lock = File.exist?("Gemfile.lock")
|
|
230
|
+
|
|
231
|
+
deps.each do |dep|
|
|
232
|
+
case dep
|
|
233
|
+
when "postgres"
|
|
234
|
+
# Rails (Gemfile.lock) and Node+Prisma apps default to Postgres 16
|
|
235
|
+
versions["postgres"] = "16" if has_gemfile_lock || prisma_project?
|
|
236
|
+
when "mysql"
|
|
237
|
+
versions["mysql"] = "8"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
versions
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Prompts for a deploy hook command when the app has a database dep.
|
|
245
|
+
# Returns a formatted YAML block string (empty string if no hook configured).
|
|
246
|
+
def prompt_hooks(prompt, deps)
|
|
247
|
+
return "" unless (deps & DB_DEPS).any?
|
|
248
|
+
|
|
249
|
+
say ""
|
|
250
|
+
needs_hook = prompt.yes?(
|
|
251
|
+
" Does your app need a deploy command (e.g. database migrations)?",
|
|
252
|
+
default: true
|
|
253
|
+
)
|
|
254
|
+
return "" unless needs_hook
|
|
255
|
+
|
|
256
|
+
stack = detect_stack
|
|
257
|
+
suggest = STACK_DEPLOY_HOOKS[stack]
|
|
258
|
+
|
|
259
|
+
command = if suggest
|
|
260
|
+
say " Auto-detected: #{suggest}", :cyan
|
|
261
|
+
use_it = prompt.yes?(" Use this deploy command?", default: true)
|
|
262
|
+
use_it ? suggest : prompt.ask(" Deploy command:", required: true)&.strip
|
|
263
|
+
else
|
|
264
|
+
prompt.ask(" Deploy command (e.g. bundle exec rails db:migrate):", required: true)&.strip
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
return "" if command.to_s.empty?
|
|
268
|
+
|
|
269
|
+
"hooks:\n on_deploy: \"#{command}\"\n on_update: \"#{command}\"\n\n"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Detects the likely application stack from files in the working directory.
|
|
273
|
+
# Prompts for a health check path. Returns an empty string when the developer
|
|
274
|
+
# accepts the default (/up) to keep the YAML minimal.
|
|
275
|
+
def prompt_healthcheck(prompt)
|
|
276
|
+
suggested = stack_health_path
|
|
277
|
+
say ""
|
|
278
|
+
path = prompt.ask(
|
|
279
|
+
" Health check path:",
|
|
280
|
+
default: suggested
|
|
281
|
+
) { |q| q.required(true) }.strip
|
|
282
|
+
|
|
283
|
+
return "" if path == "/up"
|
|
284
|
+
|
|
285
|
+
"healthcheck:\n path: \"#{path}\"\n\n"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Returns the conventional health path for the detected stack.
|
|
289
|
+
def stack_health_path
|
|
290
|
+
stack = detect_stack
|
|
291
|
+
case stack
|
|
292
|
+
when :django then "/health/"
|
|
293
|
+
when :rails then "/up"
|
|
294
|
+
else
|
|
295
|
+
# Node/Express/Fastify and unknown stacks default to /health
|
|
296
|
+
File.exist?("package.json") ? "/health" : "/up"
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def detect_stack
|
|
301
|
+
return :prisma if prisma_project?
|
|
302
|
+
return :django if File.exist?("manage.py")
|
|
303
|
+
return :rails if File.exist?("Gemfile")
|
|
304
|
+
|
|
305
|
+
nil
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def prisma_project?
|
|
309
|
+
return false unless File.exist?("package.json")
|
|
310
|
+
|
|
311
|
+
json = JSON.parse(File.read("package.json")) rescue {}
|
|
312
|
+
!json.dig("dependencies", "@prisma/client").nil? ||
|
|
313
|
+
!json.dig("devDependencies", "prisma").nil?
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module KyperCli
|
|
2
|
+
module Commands
|
|
3
|
+
class Login < Thor
|
|
4
|
+
desc "login", "Authenticate with your Kyper account via browser"
|
|
5
|
+
def login
|
|
6
|
+
client = Client.new_unauthenticated
|
|
7
|
+
|
|
8
|
+
spinner = TTY::Spinner.new("[:spinner] Requesting authorization code…", format: :dots)
|
|
9
|
+
spinner.auto_spin
|
|
10
|
+
|
|
11
|
+
grant = client.request_device_code
|
|
12
|
+
code = grant["code"]
|
|
13
|
+
uri = grant["verification_uri"]
|
|
14
|
+
|
|
15
|
+
spinner.success("Done")
|
|
16
|
+
|
|
17
|
+
say ""
|
|
18
|
+
say "Open this URL in your browser to authorize the CLI:", :cyan
|
|
19
|
+
say " #{uri}", :bold
|
|
20
|
+
say ""
|
|
21
|
+
|
|
22
|
+
open_browser(uri)
|
|
23
|
+
|
|
24
|
+
say "Waiting for authorization", :cyan
|
|
25
|
+
poll_spinner = TTY::Spinner.new("[:spinner] Polling…", format: :dots)
|
|
26
|
+
poll_spinner.auto_spin
|
|
27
|
+
|
|
28
|
+
deadline = Time.now + 300 # 5 minutes
|
|
29
|
+
token = nil
|
|
30
|
+
|
|
31
|
+
loop do
|
|
32
|
+
if Time.now > deadline
|
|
33
|
+
poll_spinner.error("Timed out")
|
|
34
|
+
say "Authorization timed out. Please run `kyper login` again.", :red
|
|
35
|
+
exit 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
result = client.poll_device_token(code)
|
|
40
|
+
if result["api_token"]
|
|
41
|
+
token = result["api_token"]
|
|
42
|
+
break
|
|
43
|
+
end
|
|
44
|
+
rescue KyperCli::Error => e
|
|
45
|
+
if e.message.include?("not found") || e.message.include?("expired")
|
|
46
|
+
poll_spinner.error("Grant expired")
|
|
47
|
+
say "Authorization expired. Please run `kyper login` again.", :red
|
|
48
|
+
exit 1
|
|
49
|
+
end
|
|
50
|
+
# Other errors — keep polling
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
sleep 2
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
poll_spinner.success("Authorized")
|
|
57
|
+
|
|
58
|
+
Config.save("api_token" => token)
|
|
59
|
+
|
|
60
|
+
# Verify the token works
|
|
61
|
+
user = Client.new(token).me
|
|
62
|
+
say "Logged in as #{user["email"]} (#{user["role"]})", :green
|
|
63
|
+
rescue KyperCli::Error => e
|
|
64
|
+
say "Error: #{e.message}", :red
|
|
65
|
+
exit 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
no_commands do
|
|
71
|
+
def open_browser(uri)
|
|
72
|
+
if RUBY_PLATFORM =~ /darwin/
|
|
73
|
+
system("open", uri)
|
|
74
|
+
elsif RUBY_PLATFORM =~ /linux/
|
|
75
|
+
system("xdg-open", uri)
|
|
76
|
+
end
|
|
77
|
+
rescue StandardError
|
|
78
|
+
# Silently fail — user can copy-paste the URL
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|