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.
@@ -0,0 +1,91 @@
1
+ module KyperCli
2
+ module Commands
3
+ class Logs < Thor
4
+ include Helpers
5
+
6
+ POLL_INTERVAL = 2 # seconds between log-polling requests
7
+
8
+ no_commands do
9
+ # Streams build log to stdout via cursor-based polling.
10
+ # Returns the final status string (e.g. "in_review", "build_failed").
11
+ # Called by Push and RetryBuild commands to stream log in real time.
12
+ def tail_log(version_id, from: 0)
13
+ client = Client.new
14
+ cursor = from
15
+ printed = false
16
+ last_data = nil
17
+
18
+ deadline = Time.now + (30 * 60) # 30 minute max
19
+ loop do
20
+ if Time.now > deadline
21
+ say "\nTimed out waiting for build to complete (30 min). Check status with `kyper versions`.", :red
22
+ exit 1
23
+ end
24
+
25
+ last_data = client.version_build_log(version_id, cursor: cursor)
26
+ chunk = last_data["log"].to_s
27
+ cursor = last_data["cursor"].to_i
28
+
29
+ if chunk.length.positive?
30
+ print chunk
31
+ $stdout.flush
32
+ printed = true
33
+ end
34
+
35
+ break if last_data["complete"]
36
+ sleep POLL_INTERVAL
37
+ end
38
+
39
+ puts "" if printed
40
+ say " " + ("─" * 56)
41
+
42
+ final_status = last_data["status"]
43
+ case final_status
44
+ when "in_review"
45
+ say " ✔ Build complete! Submitted for review.", :green
46
+ when "build_failed"
47
+ say " ✗ Build failed.", :red
48
+ say " Run `kyper retry` to try again.", :yellow
49
+ when "published"
50
+ say " ✔ Published.", :green
51
+ else
52
+ say " Status: #{final_status}"
53
+ end
54
+ say ""
55
+
56
+ final_status
57
+ end
58
+ end
59
+
60
+ desc "logs", "Show the build log for the latest version of your app"
61
+ def logs
62
+ require_auth!
63
+ yml = load_kyper_yml!
64
+ slug = to_slug(yml["name"])
65
+
66
+ status_data = Client.new.app_status(slug)
67
+ version_data = status_data["latest_version"]
68
+
69
+ unless version_data
70
+ say "No versions pushed yet.", :yellow
71
+ exit 0
72
+ end
73
+
74
+ version_id = version_data["id"]
75
+ unless version_id
76
+ say "Build log unavailable (update your Kyper CLI).", :yellow
77
+ exit 0
78
+ end
79
+
80
+ say ""
81
+ say " Build log — #{status_data["app"]} v#{version_data["version"]} (#{version_data["status"]})"
82
+ say " " + ("─" * 56)
83
+
84
+ tail_log(version_id, from: 0)
85
+ rescue KyperCli::Error => e
86
+ say "Error: #{e.message}", :red
87
+ exit 1
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,120 @@
1
+ require "tty-spinner"
2
+
3
+ module KyperCli
4
+ module Commands
5
+ class Push < Thor
6
+ include Helpers
7
+
8
+ desc "push", "Validate, package, and submit the current version to Kyper for review"
9
+ def push
10
+ require_auth!
11
+
12
+ unless File.exist?(Config::KYPER_FILE)
13
+ say "Error: No kyper.yml found. Run `kyper init` to create one.", :red
14
+ exit 1
15
+ end
16
+
17
+ raw_yml = File.read(Config::KYPER_FILE)
18
+ yml = YAML.safe_load(raw_yml)
19
+ slug = to_slug(yml["name"])
20
+
21
+ say ""
22
+ say " Validating kyper.yml..."
23
+ validator = Commands::Validate.new
24
+ unless validator.validate_yml(yml)
25
+ say ""
26
+ say " Fix the errors above, then run `kyper push` again.", :red
27
+ say ""
28
+ exit 1
29
+ end
30
+ say ""
31
+
32
+ zip_path = nil
33
+ version_id = nil
34
+ client = Client.new
35
+
36
+ begin
37
+ zip_path = build_zip_with_spinner!
38
+ version_id = upload_with_spinner!(client, slug, yml, raw_yml, zip_path)
39
+ ensure
40
+ File.delete(zip_path) if zip_path && File.exist?(zip_path)
41
+ end
42
+
43
+ say ""
44
+ say " Following build log... (Ctrl+C to stop)"
45
+ say " " + ("─" * 56)
46
+ final_status = Commands::Logs.new.tail_log(version_id, from: 0)
47
+
48
+ offer_retry(version_id, client, final_status)
49
+ rescue KyperCli::Error => e
50
+ say ""
51
+ say " Error: #{e.message}", :red
52
+ say ""
53
+ exit 1
54
+ end
55
+
56
+ private
57
+
58
+ def build_zip_with_spinner!
59
+ zip_path = File.join(Dir.tmpdir, "kyper_push_#{Process.pid}.zip")
60
+ spinner = new_spinner("Building archive")
61
+ ignore = load_kyperignore
62
+ exclude_args = (ignore + [ ".git/*", "*.log", "tmp/*" ]).flat_map { |p| [ "--exclude", p ] }
63
+
64
+ ok = system("zip", "-r", zip_path, ".", *exclude_args, "-q")
65
+ unless ok
66
+ spinner.error("Failed to build archive")
67
+ raise KyperCli::Error, "zip command failed — is `zip` installed?"
68
+ end
69
+
70
+ size_mb = (File.size(zip_path).to_f / (1024 * 1024)).round(1)
71
+ spinner.success("#{size_mb} MB packed")
72
+ zip_path
73
+ end
74
+
75
+ def upload_with_spinner!(client, slug, yml, raw_yml, zip_path)
76
+ spinner = new_spinner("Uploading")
77
+ begin
78
+ ensure_app_exists!(client, slug, yml)
79
+ result = client.push_version(slug, raw_yml, zip_path: zip_path)
80
+ version_id = result["id"]
81
+ spinner.success("Uploaded — build queued (version ##{version_id})")
82
+ version_id
83
+ rescue KyperCli::Error
84
+ spinner.error("Upload failed")
85
+ raise
86
+ end
87
+ end
88
+
89
+ def ensure_app_exists!(client, slug, yml)
90
+ client.app_status(slug)
91
+ rescue KyperCli::Error
92
+ client.create_app(name: yml["name"], slug: slug)
93
+ end
94
+
95
+ def offer_retry(version_id, client, final_status)
96
+ return unless final_status == "build_failed"
97
+
98
+ say ""
99
+ return unless TTY::Prompt.new.yes?(" Retry build?")
100
+
101
+ say ""
102
+ client.version_retry(version_id)
103
+ say " ✔ Build queued.", :green
104
+ say ""
105
+ say " Following build log..."
106
+ say " " + ("─" * 56)
107
+ Commands::Logs.new.tail_log(version_id, from: 0)
108
+ end
109
+
110
+ def new_spinner(label)
111
+ TTY::Spinner.new(" [:spinner] #{label}...", format: :dots, success_mark: "✔", error_mark: "✗").tap(&:auto_spin)
112
+ end
113
+
114
+ def load_kyperignore
115
+ return [] unless File.exist?(".kyperignore")
116
+ File.readlines(".kyperignore").map(&:strip).reject { |l| l.empty? || l.start_with?("#") }
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,42 @@
1
+ module KyperCli
2
+ module Commands
3
+ class RetryBuild < Thor
4
+ include Helpers
5
+
6
+ desc "retry", "Retry a failed build for the current app"
7
+ def retry_build
8
+ require_auth!
9
+ yml = load_kyper_yml!
10
+ slug = to_slug(yml["name"])
11
+
12
+ status_data = Client.new.app_status(slug)
13
+ version_data = status_data["latest_version"]
14
+
15
+ unless version_data
16
+ say "No versions pushed yet.", :yellow
17
+ exit 0
18
+ end
19
+
20
+ unless version_data["status"] == "build_failed"
21
+ say "Latest version (#{version_data["version"]}) is not in a failed state — status: #{version_data["status"]}.", :yellow
22
+ exit 0
23
+ end
24
+
25
+ version_id = version_data["id"]
26
+ say ""
27
+ say " Retrying build for #{status_data["app"]} v#{version_data["version"]}..."
28
+
29
+ Client.new.version_retry(version_id)
30
+ say " ✔ Build queued.", :green
31
+ say ""
32
+
33
+ say " Following build log... (Ctrl+C to stop)"
34
+ say " " + ("─" * 56)
35
+ Commands::Logs.new.tail_log(version_id, from: 0)
36
+ rescue KyperCli::Error => e
37
+ say "Error: #{e.message}", :red
38
+ exit 1
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,33 @@
1
+ module KyperCli
2
+ module Commands
3
+ class Status < Thor
4
+ include Helpers
5
+
6
+ desc "status", "Show the review status of your app"
7
+ def status
8
+ require_auth!
9
+ yml = load_kyper_yml!
10
+
11
+ slug = to_slug(yml["name"])
12
+
13
+ begin
14
+ data = Client.new.app_status(slug)
15
+ say ""
16
+ say "#{data["app"]} — #{data["status"].upcase}"
17
+
18
+ if (v = data["latest_version"])
19
+ say " Latest version : #{v["version"]}"
20
+ say " Review status : #{v["status"]}"
21
+ say " Notes : #{v["review_notes"]}" if !v["review_notes"].to_s.empty?
22
+ else
23
+ say " No versions pushed yet."
24
+ end
25
+ say ""
26
+ rescue KyperCli::Error => e
27
+ say "Error: #{e.message}", :red
28
+ exit 1
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,174 @@
1
+ require "yaml"
2
+
3
+ module KyperCli
4
+ module Commands
5
+ class Validate < Thor
6
+ SEMVER_RE = /\A\d+\.\d+\.\d+\z/
7
+
8
+ CATEGORIES = %w[
9
+ developer_tools productivity finance health media
10
+ education business_operations data_analytics gaming
11
+ ].freeze
12
+
13
+ KNOWN_DEPS = %w[postgres mysql redis elasticsearch opensearch].freeze
14
+ DB_DEPS = %w[postgres mysql].freeze
15
+
16
+ ALLOWED_DEP_VERSIONS = {
17
+ "postgres" => %w[14 15 16],
18
+ "mysql" => %w[8],
19
+ "redis" => %w[6 7],
20
+ "elasticsearch" => %w[8],
21
+ "opensearch" => %w[2]
22
+ }.freeze
23
+
24
+ desc "validate", "Validate kyper.yml before pushing"
25
+ def validate
26
+ yml = load_kyper_yml!
27
+ errors, warnings = run_checks(yml)
28
+
29
+ if errors.empty?
30
+ if warnings.any?
31
+ say ""
32
+ warnings.each { |w| say " ⚠ #{w}", :yellow }
33
+ end
34
+ say ""
35
+ say " All checks passed — ready to push.", :green
36
+ say ""
37
+ true
38
+ else
39
+ say ""
40
+ errors.each { |e| say " #{e}", :red }
41
+ say ""
42
+ false
43
+ end
44
+ end
45
+
46
+ no_commands do
47
+ # Called by Push command — returns true/false without re-printing header.
48
+ def validate_yml(yml)
49
+ errors, _warnings = run_checks(yml)
50
+ errors.empty?
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def run_checks(yml)
57
+ errors = []
58
+ warnings = []
59
+
60
+ check("name present", yml["name"].to_s.strip.length.positive?) { errors << "name is required" }
61
+ check("name ≤ 100 chars", yml["name"].to_s.length <= 100) { errors << "name must be 100 characters or fewer" }
62
+
63
+ v = yml["version"].to_s
64
+ check("version present", v.length.positive?) { errors << "version is required" }
65
+ check("version is semver", !v.empty? && SEMVER_RE.match?(v)) { errors << "version must be semver (e.g. 1.0.0)" }
66
+
67
+ cat = yml["category"].to_s
68
+ check("category present", cat.length.positive?) { errors << "category is required" }
69
+ if cat.length.positive? && !CATEGORIES.include?(cat)
70
+ say_check(false, "category valid")
71
+ errors << "category must be one of: #{CATEGORIES.join(", ")}"
72
+ end
73
+
74
+ docker = yml["docker"] || {}
75
+ if docker["image"].to_s.length.positive?
76
+ say_check(false, "docker section")
77
+ errors << "docker.image is not supported — provide a dockerfile path (see ADR-001)"
78
+ else
79
+ df = docker["dockerfile"].to_s
80
+ check("docker.dockerfile set", df.length.positive?) { errors << "docker.dockerfile is required" }
81
+ if df.length.positive?
82
+ check("Dockerfile exists at #{df}", File.exist?(df)) { errors << "Dockerfile not found at #{df}" }
83
+ end
84
+ end
85
+
86
+ procs = yml["processes"]
87
+ if procs
88
+ has_web = procs.is_a?(Hash) && (procs.key?("web") || procs.key?(:web))
89
+ check("processes.web defined", has_web) { errors << "processes must include a \"web\" key" }
90
+ else
91
+ say_check(false, "processes.web defined")
92
+ errors << "processes section is required (must include at least a \"web\" key)"
93
+ end
94
+
95
+ deps = yml["deps"]
96
+ if deps.is_a?(Array) && deps.any?
97
+ # Support plain strings ("postgres"), version hashes ({"postgres" => "15"}),
98
+ # and version+storage hashes ({"postgres" => "15", "storage_gb" => 50}).
99
+ dep_names = deps.map do |d|
100
+ d.is_a?(Hash) ? (d.keys.map(&:to_s) - %w[storage_gb]).first.to_s : d.to_s
101
+ end
102
+ unknown = dep_names - KNOWN_DEPS
103
+ if unknown.any?
104
+ say_check(false, "deps valid")
105
+ errors << "unknown deps: #{unknown.join(", ")}. Allowed: #{KNOWN_DEPS.join(", ")}"
106
+ else
107
+ deps.each do |d|
108
+ next unless d.is_a?(Hash)
109
+ name = (d.keys.map(&:to_s) - %w[storage_gb]).first.to_s
110
+ version = (d[name] || d[name.to_sym]).to_s
111
+ next if version.empty?
112
+ allowed = ALLOWED_DEP_VERSIONS.fetch(name, [])
113
+ unless allowed.include?(version)
114
+ say_check(false, "#{name} version")
115
+ errors << "#{name}: version \"#{version}\" is not supported. Allowed: #{allowed.join(", ")}"
116
+ end
117
+ end
118
+
119
+ dep_labels = deps.map do |d|
120
+ if d.is_a?(Hash)
121
+ name = (d.keys.map(&:to_s) - %w[storage_gb]).first.to_s
122
+ ver = d[name] || d[name.to_sym]
123
+ gb = d["storage_gb"] || d[:storage_gb]
124
+ [ name, ver && "v#{ver}", gb && "#{gb}GB" ].compact.join(":")
125
+ else
126
+ d.to_s
127
+ end
128
+ end
129
+ say_check(true, "deps: #{dep_labels.join(", ")}")
130
+ end
131
+
132
+ # Warn if a DB dep is present but no on_deploy hook is configured.
133
+ if (dep_names & DB_DEPS).any?
134
+ on_deploy = (yml["hooks"] || {})["on_deploy"].to_s.strip
135
+ if on_deploy.empty?
136
+ say_check(false, "hooks.on_deploy (warning)")
137
+ warnings << "hooks.on_deploy is not set — apps with a database dep typically need " \
138
+ "a migration command (e.g. 'bundle exec rails db:migrate')"
139
+ else
140
+ say_check(true, "hooks.on_deploy: #{on_deploy}")
141
+ end
142
+ end
143
+ end
144
+
145
+ [ errors, warnings ]
146
+ end
147
+
148
+ # Runs a boolean check, prints ✔/✗ with label, yields error message block on failure.
149
+ def check(label, condition, &block)
150
+ if condition
151
+ say_check(true, label)
152
+ else
153
+ say_check(false, label)
154
+ block.call
155
+ end
156
+ end
157
+
158
+ def say_check(passed, label)
159
+ mark = passed ? "✔" : "✗"
160
+ color = passed ? :green : :red
161
+ say " #{mark} #{label}", color
162
+ end
163
+
164
+ def load_kyper_yml!
165
+ yml = Config.kyper_yml
166
+ unless yml
167
+ say " ✗ kyper.yml not found — run `kyper init` to create one.", :red
168
+ exit 1
169
+ end
170
+ yml
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,45 @@
1
+ module KyperCli
2
+ module Commands
3
+ class Versions < Thor
4
+ include Helpers
5
+
6
+ desc "withdraw VERSION", "Withdraw the latest version from review (must not be published or building)"
7
+ def withdraw(version_string)
8
+ require_auth!
9
+ yml = load_kyper_yml!
10
+ slug = to_slug(yml["name"])
11
+
12
+ status_data = Client.new.app_status(slug)
13
+ version_data = status_data["latest_version"]
14
+
15
+ if version_data.nil? || version_data["version"] != version_string
16
+ say "Error: Version #{version_string} not found (only the latest version can be withdrawn).", :red
17
+ exit 1
18
+ end
19
+
20
+ if %w[published building].include?(version_data["status"])
21
+ say "Cannot withdraw a #{version_data["status"]} version.", :red
22
+ exit 1
23
+ end
24
+
25
+ say ""
26
+ say " Withdraw #{status_data["app"]} v#{version_string}?", :yellow
27
+ say " This will remove it from the review queue and cannot be undone."
28
+ say ""
29
+
30
+ unless TTY::Prompt.new.yes?(" Confirm withdraw?", default: false)
31
+ say " Aborted.", :yellow
32
+ exit 0
33
+ end
34
+
35
+ Client.new.version_withdraw(version_data["id"])
36
+ say ""
37
+ say " ✔ Version #{version_string} withdrawn.", :green
38
+ say ""
39
+ rescue KyperCli::Error => e
40
+ say "Error: #{e.message}", :red
41
+ exit 1
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,32 @@
1
+ module KyperCli
2
+ module Config
3
+ CONFIG_DIR = File.join(Dir.home, ".kyper")
4
+ CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
5
+ KYPER_FILE = "kyper.yml"
6
+
7
+ def self.api_host
8
+ ENV.fetch("KYPER_HOST", "https://kyper.shop")
9
+ end
10
+
11
+ def self.load
12
+ return {} unless File.exist?(CONFIG_FILE)
13
+ @config ||= YAML.safe_load(File.read(CONFIG_FILE)) || {}
14
+ end
15
+
16
+ def self.api_token
17
+ load["api_token"]
18
+ end
19
+
20
+ def self.save(data)
21
+ @config = nil
22
+ FileUtils.mkdir_p(CONFIG_DIR)
23
+ File.write(CONFIG_FILE, data.to_yaml)
24
+ FileUtils.chmod(0o600, CONFIG_FILE)
25
+ end
26
+
27
+ def self.kyper_yml
28
+ return nil unless File.exist?(KYPER_FILE)
29
+ YAML.safe_load(File.read(KYPER_FILE))
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,3 @@
1
+ module KyperCli
2
+ VERSION = "0.1.0"
3
+ end
data/lib/kyper_cli.rb ADDED
@@ -0,0 +1,19 @@
1
+ require "thor"
2
+ require "faraday"
3
+ require "yaml"
4
+ require "json"
5
+
6
+ require_relative "kyper_cli/version"
7
+ require_relative "kyper_cli/config"
8
+ require_relative "kyper_cli/client"
9
+ require_relative "kyper_cli/commands/helpers"
10
+ require_relative "kyper_cli/commands/login"
11
+ require_relative "kyper_cli/commands/validate"
12
+ require_relative "kyper_cli/commands/init"
13
+ require_relative "kyper_cli/commands/push"
14
+ require_relative "kyper_cli/commands/status"
15
+ require_relative "kyper_cli/commands/logs"
16
+ require_relative "kyper_cli/commands/retry_build"
17
+ require_relative "kyper_cli/commands/env"
18
+ require_relative "kyper_cli/commands/versions"
19
+ require_relative "kyper_cli/cli"