kdep 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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/exe/kdep +3 -0
  4. data/lib/kdep/cli.rb +72 -0
  5. data/lib/kdep/cluster_health.rb +44 -0
  6. data/lib/kdep/commands/apply.rb +147 -0
  7. data/lib/kdep/commands/build.rb +103 -0
  8. data/lib/kdep/commands/bump.rb +190 -0
  9. data/lib/kdep/commands/diff.rb +133 -0
  10. data/lib/kdep/commands/eject.rb +97 -0
  11. data/lib/kdep/commands/init.rb +99 -0
  12. data/lib/kdep/commands/log.rb +145 -0
  13. data/lib/kdep/commands/push.rb +94 -0
  14. data/lib/kdep/commands/render.rb +130 -0
  15. data/lib/kdep/commands/restart.rb +109 -0
  16. data/lib/kdep/commands/scale.rb +136 -0
  17. data/lib/kdep/commands/secrets.rb +223 -0
  18. data/lib/kdep/commands/sh.rb +117 -0
  19. data/lib/kdep/commands/status.rb +187 -0
  20. data/lib/kdep/config.rb +69 -0
  21. data/lib/kdep/context_guard.rb +23 -0
  22. data/lib/kdep/dashboard/health_panel.rb +53 -0
  23. data/lib/kdep/dashboard/layout.rb +40 -0
  24. data/lib/kdep/dashboard/log_panel.rb +54 -0
  25. data/lib/kdep/dashboard/panel.rb +135 -0
  26. data/lib/kdep/dashboard/resources_panel.rb +91 -0
  27. data/lib/kdep/dashboard/rollout_panel.rb +75 -0
  28. data/lib/kdep/dashboard/screen.rb +29 -0
  29. data/lib/kdep/dashboard.rb +258 -0
  30. data/lib/kdep/defaults.rb +21 -0
  31. data/lib/kdep/discovery.rb +29 -0
  32. data/lib/kdep/docker.rb +29 -0
  33. data/lib/kdep/kubectl.rb +41 -0
  34. data/lib/kdep/preset.rb +33 -0
  35. data/lib/kdep/registry.rb +94 -0
  36. data/lib/kdep/renderer.rb +53 -0
  37. data/lib/kdep/template_context.rb +22 -0
  38. data/lib/kdep/ui.rb +48 -0
  39. data/lib/kdep/validator.rb +73 -0
  40. data/lib/kdep/version.rb +3 -0
  41. data/lib/kdep/version_tagger.rb +27 -0
  42. data/lib/kdep/writer.rb +26 -0
  43. data/lib/kdep.rb +49 -0
  44. data/templates/init/app.yml.erb +49 -0
  45. data/templates/init/gitignore +3 -0
  46. data/templates/init/secrets.yml +7 -0
  47. data/templates/presets/cronjob +4 -0
  48. data/templates/presets/job +4 -0
  49. data/templates/presets/web +7 -0
  50. data/templates/presets/worker +4 -0
  51. data/templates/resources/configmap.yml.erb +7 -0
  52. data/templates/resources/cronjob.yml.erb +42 -0
  53. data/templates/resources/deployment.yml.erb +71 -0
  54. data/templates/resources/ingress.yml.erb +47 -0
  55. data/templates/resources/job.yml.erb +35 -0
  56. data/templates/resources/secret.yml.erb +15 -0
  57. data/templates/resources/service.yml.erb +13 -0
  58. metadata +142 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 701a91c45b5e7323cfedb6347f98a37e53ba7b53e49a5b64804eee71cd7acca5
4
+ data.tar.gz: 85354c4ac4e4dd599f39a9547ef03403286d61ad6411da1863c4b2eacdc96652
5
+ SHA512:
6
+ metadata.gz: 290bb09e66a9bc9ecf06e4b4ad248fd292f3560ee7b9e290d3c3186525b541337cf1a0a7d365df4b6fc547c2e54419dbec62a1dc5fa476ced0573f662c461959
7
+ data.tar.gz: e3a51471ee31d298e1a8849893576e0744de593a9b7ad761bef06ac90814553e3c29249102ab0cf85d52a79a0b2dc76a2bd86e0221fcbf3bf84112a3bf5065d1
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Leadfy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/exe/kdep ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+ require "kdep"
3
+ Kdep::CLI.new(ARGV).run
data/lib/kdep/cli.rb ADDED
@@ -0,0 +1,72 @@
1
+ require "optparse"
2
+
3
+ module Kdep
4
+ class CLI
5
+ COMMANDS = {
6
+ "render" => Commands::Render,
7
+ "init" => Commands::Init,
8
+ "eject" => Commands::Eject,
9
+ "apply" => Commands::Apply,
10
+ "diff" => Commands::Diff,
11
+ "status" => Commands::Status,
12
+ "build" => Commands::Build,
13
+ "push" => Commands::Push,
14
+ "bump" => Commands::Bump,
15
+ "log" => Commands::Log,
16
+ "sh" => Commands::Sh,
17
+ "secrets" => Commands::Secrets,
18
+ "restart" => Commands::Restart,
19
+ "scale" => Commands::Scale,
20
+ }.freeze
21
+
22
+ def initialize(argv)
23
+ @argv = argv.dup
24
+ @global_options = {}
25
+ end
26
+
27
+ def run
28
+ global_parser.order!(@argv, into: @global_options)
29
+ command_name = @argv.shift
30
+
31
+ if command_name.nil?
32
+ puts global_parser
33
+ exit 0
34
+ end
35
+
36
+ unless COMMANDS.key?(command_name)
37
+ $stderr.puts "Unknown command: #{command_name}"
38
+ puts global_parser
39
+ exit 1
40
+ end
41
+
42
+ command_class = COMMANDS[command_name]
43
+ command_options = {}
44
+ command_class.option_parser.parse!(@argv, into: command_options)
45
+ command_class.new(
46
+ global_options: @global_options,
47
+ command_options: command_options,
48
+ args: @argv
49
+ ).execute
50
+ end
51
+
52
+ private
53
+
54
+ def global_parser
55
+ @global_parser ||= OptionParser.new do |opts|
56
+ opts.banner = "Usage: kdep [options] <command> [command-options] [args...]"
57
+ opts.separator ""
58
+ opts.separator "Commands: #{COMMANDS.keys.join(', ')}"
59
+ opts.separator ""
60
+ opts.separator "Options:"
61
+ opts.on("-v", "--version", "Show version") do
62
+ puts Kdep::VERSION
63
+ exit 0
64
+ end
65
+ opts.on("-h", "--help", "Show help") do
66
+ puts opts
67
+ exit 0
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,44 @@
1
+ module Kdep
2
+ class ClusterHealth
3
+ CPU_WARN = 80
4
+ MEMORY_WARN = 85
5
+
6
+ def check
7
+ begin
8
+ output = Kdep::Kubectl.run("top", "nodes", "--no-headers")
9
+ rescue Kdep::Kubectl::Error
10
+ return ["Cluster health metrics unavailable (metrics-server may not be installed)"]
11
+ end
12
+
13
+ warnings = []
14
+
15
+ output.each_line do |line|
16
+ parts = line.strip.split(/\s+/)
17
+ next if parts.length < 5
18
+
19
+ name = parts[0]
20
+ cpu_percent = parts[2].sub("%", "").to_i
21
+ mem_percent = parts[4].sub("%", "").to_i
22
+
23
+ if cpu_percent > CPU_WARN
24
+ warnings << "Node #{name}: CPU at #{cpu_percent}% (threshold: #{CPU_WARN}%)"
25
+ end
26
+
27
+ if mem_percent > MEMORY_WARN
28
+ warnings << "Node #{name}: Memory at #{mem_percent}% (threshold: #{MEMORY_WARN}%)"
29
+ end
30
+ end
31
+
32
+ warnings
33
+ end
34
+
35
+ def report(ui)
36
+ warnings = check
37
+ if warnings.empty?
38
+ ui.success("Cluster health: OK")
39
+ else
40
+ warnings.each { |w| ui.warn(w) }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,147 @@
1
+ require "optparse"
2
+
3
+ module Kdep
4
+ module Commands
5
+ class Apply
6
+ def self.option_parser
7
+ OptionParser.new do |opts|
8
+ opts.banner = "Usage: kdep apply [deploy] [env]"
9
+ opts.separator ""
10
+ opts.separator "Renders and applies Kubernetes manifests to the cluster."
11
+ opts.separator ""
12
+ opts.on("--dry-run", "Show what would be applied without applying")
13
+ end
14
+ end
15
+
16
+ def initialize(global_options:, command_options:, args:)
17
+ @global_options = global_options
18
+ @command_options = command_options
19
+ @args = args
20
+ @ui = Kdep::UI.new(color: false)
21
+ end
22
+
23
+ def execute
24
+ deploy_name = @args[0]
25
+ env = @args[1]
26
+
27
+ # Discover kdep/ directory
28
+ discovery = Kdep::Discovery.new
29
+ kdep_dir = discovery.find_kdep_dir
30
+
31
+ unless kdep_dir
32
+ @ui.error("No kdep/ directory found")
33
+ exit 1
34
+ end
35
+
36
+ # Resolve deploy directory
37
+ deploy_dir = resolve_deploy_dir(kdep_dir, deploy_name, discovery)
38
+ unless deploy_dir
39
+ exit 1
40
+ end
41
+
42
+ # Load config
43
+ config = Kdep::Config.new(deploy_dir, env).load
44
+
45
+ # Validate context before any cluster operation
46
+ begin
47
+ Kdep::ContextGuard.new(config["context"]).validate!
48
+ rescue Kdep::Kubectl::Error => e
49
+ @ui.error(e.message)
50
+ exit 1
51
+ end
52
+
53
+ # Load preset resources and render
54
+ preset = Kdep::Preset.new(config["preset"], deploy_dir)
55
+ resources = preset.resources
56
+
57
+ repo_root = File.dirname(kdep_dir)
58
+ output_dir = File.join(repo_root, ".rendered")
59
+
60
+ writer = Kdep::Writer.new(output_dir)
61
+ writer.clean
62
+ renderer = Kdep::Renderer.new(config, deploy_dir)
63
+
64
+ resources.each_with_index do |resource_name, idx|
65
+ index = idx + 1
66
+ begin
67
+ content = renderer.render_resource(resource_name)
68
+ rescue => e
69
+ @ui.error("#{resource_name}: #{e.message}")
70
+ next
71
+ end
72
+ writer.write(resource_name, content, index)
73
+ end
74
+
75
+ # Get sorted rendered files
76
+ rendered_files = Dir.glob(File.join(output_dir, "*.yml")).sort
77
+
78
+ if rendered_files.empty?
79
+ @ui.info("No rendered files to apply")
80
+ return
81
+ end
82
+
83
+ # Dry run mode
84
+ if @command_options[:"dry-run"]
85
+ rendered_files.each do |f|
86
+ @ui.info("Would apply: #{File.basename(f)}")
87
+ end
88
+ @ui.info("#{rendered_files.length} files would be applied (dry-run)")
89
+ return
90
+ end
91
+
92
+ # Apply each file in order
93
+ errors = []
94
+ applied = 0
95
+
96
+ namespace = config["namespace"]
97
+
98
+ rendered_files.each do |file_path|
99
+ begin
100
+ Kdep::Kubectl.apply(file_path, namespace: namespace)
101
+ @ui.info("applied: #{File.basename(file_path)}")
102
+ applied += 1
103
+ rescue Kdep::Kubectl::Error => e
104
+ errors << { "file" => File.basename(file_path), "error" => e.message }
105
+ @ui.error("#{File.basename(file_path)}: #{e.message}")
106
+ end
107
+ end
108
+
109
+ # Summary
110
+ if errors.empty?
111
+ @ui.info("#{applied} files applied, 0 errors")
112
+ else
113
+ @ui.info("#{applied} files applied, #{errors.length} errors")
114
+ exit 1
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def resolve_deploy_dir(kdep_dir, deploy_name, discovery)
121
+ if deploy_name
122
+ deploy_dir = File.join(kdep_dir, deploy_name)
123
+ unless File.directory?(deploy_dir)
124
+ @ui.error("Deploy target not found: #{deploy_name}")
125
+ deploys = discovery.find_deploys
126
+ if deploys.any?
127
+ @ui.info("Available deploys: #{deploys.join(', ')}")
128
+ end
129
+ return nil
130
+ end
131
+ deploy_dir
132
+ else
133
+ deploys = discovery.find_deploys
134
+ if deploys.length == 1
135
+ File.join(kdep_dir, deploys[0])
136
+ elsif deploys.length > 1
137
+ @ui.error("Multiple deploys found, specify one: #{deploys.join(', ')}")
138
+ nil
139
+ else
140
+ @ui.error("No deploy targets found in kdep/")
141
+ nil
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,103 @@
1
+ require "optparse"
2
+
3
+ module Kdep
4
+ module Commands
5
+ class Build
6
+ def self.option_parser
7
+ OptionParser.new do |opts|
8
+ opts.banner = "Usage: kdep build [deploy] [env]"
9
+ opts.separator ""
10
+ opts.separator "Builds a Docker image using the deploy configuration."
11
+ opts.separator ""
12
+ opts.on("--platform=PLATFORM", "Target platform (e.g., linux/amd64)")
13
+ end
14
+ end
15
+
16
+ def initialize(global_options:, command_options:, args:)
17
+ @global_options = global_options
18
+ @command_options = command_options
19
+ @args = args
20
+ @ui = Kdep::UI.new(color: false)
21
+ end
22
+
23
+ def execute
24
+ deploy_name = @args[0]
25
+ env = @args[1]
26
+
27
+ # Discover kdep/ directory
28
+ discovery = Kdep::Discovery.new
29
+ kdep_dir = discovery.find_kdep_dir
30
+
31
+ unless kdep_dir
32
+ @ui.error("No kdep/ directory found")
33
+ exit 1
34
+ end
35
+
36
+ # Resolve deploy directory
37
+ deploy_dir = resolve_deploy_dir(kdep_dir, deploy_name, discovery)
38
+ unless deploy_dir
39
+ exit 1
40
+ end
41
+
42
+ # Load config
43
+ config = Kdep::Config.new(deploy_dir, env).load
44
+
45
+ # Construct full image tag
46
+ image = config["image"] || config["name"]
47
+ tag = config["tag"] || "latest"
48
+ registry = config["registry"]
49
+
50
+ if registry && !registry.to_s.empty?
51
+ full_tag = "#{registry}/#{image}:#{tag}"
52
+ else
53
+ full_tag = "#{image}:#{tag}"
54
+ end
55
+
56
+ # Build
57
+ context_dir = File.dirname(kdep_dir)
58
+
59
+ begin
60
+ Kdep::Docker.build(
61
+ tag: full_tag,
62
+ context_dir: context_dir,
63
+ dockerfile: config["dockerfile"],
64
+ target: config["target"],
65
+ platform: @command_options[:platform] || config["platform"]
66
+ )
67
+ @ui.info("Built: #{full_tag}")
68
+ rescue Kdep::Docker::Error => e
69
+ @ui.error(e.message)
70
+ exit 1
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def resolve_deploy_dir(kdep_dir, deploy_name, discovery)
77
+ if deploy_name
78
+ deploy_dir = File.join(kdep_dir, deploy_name)
79
+ unless File.directory?(deploy_dir)
80
+ @ui.error("Deploy target not found: #{deploy_name}")
81
+ deploys = discovery.find_deploys
82
+ if deploys.any?
83
+ @ui.info("Available deploys: #{deploys.join(', ')}")
84
+ end
85
+ return nil
86
+ end
87
+ deploy_dir
88
+ else
89
+ deploys = discovery.find_deploys
90
+ if deploys.length == 1
91
+ File.join(kdep_dir, deploys[0])
92
+ elsif deploys.length > 1
93
+ @ui.error("Multiple deploys found, specify one: #{deploys.join(', ')}")
94
+ nil
95
+ else
96
+ @ui.error("No deploy targets found in kdep/")
97
+ nil
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,190 @@
1
+ require "optparse"
2
+
3
+ module Kdep
4
+ module Commands
5
+ class Bump
6
+ def self.option_parser
7
+ OptionParser.new do |opts|
8
+ opts.banner = "Usage: kdep bump [deploy] [env]"
9
+ opts.separator ""
10
+ opts.separator "Full pipeline: registry query -> version increment -> build -> push -> render -> apply."
11
+ opts.separator ""
12
+ opts.on("--skip-apply", "Build and push only, skip render+apply")
13
+ opts.on("--no-dashboard", "Skip TUI dashboard after deploy")
14
+ opts.on("--platform=PLATFORM", "Target platform (e.g., linux/amd64)")
15
+ end
16
+ end
17
+
18
+ def initialize(global_options:, command_options:, args:)
19
+ @global_options = global_options
20
+ @command_options = command_options
21
+ @args = args
22
+ @ui = Kdep::UI.new(color: false)
23
+ end
24
+
25
+ def execute
26
+ deploy_name = @args[0]
27
+ env = @args[1]
28
+
29
+ # Step 1: Discovery + config
30
+ discovery = Kdep::Discovery.new
31
+ kdep_dir = discovery.find_kdep_dir
32
+
33
+ unless kdep_dir
34
+ @ui.error("No kdep/ directory found")
35
+ exit 1
36
+ end
37
+
38
+ deploy_dir = resolve_deploy_dir(kdep_dir, deploy_name, discovery)
39
+ unless deploy_dir
40
+ exit 1
41
+ end
42
+
43
+ config = Kdep::Config.new(deploy_dir, env).load
44
+
45
+ # Step 2: Context guard (bump touches the cluster)
46
+ begin
47
+ Kdep::ContextGuard.new(config["context"]).validate!
48
+ rescue Kdep::Kubectl::Error => e
49
+ @ui.error(e.message)
50
+ exit 1
51
+ end
52
+
53
+ image = config["image"] || config["name"]
54
+ registry_url = config["registry"]
55
+ repo_root = File.dirname(kdep_dir)
56
+
57
+ begin
58
+ # Step 3: Query registry
59
+ registry = Kdep::Registry.new(registry_url)
60
+ tags = registry.list_tags(image)
61
+ @ui.info("Current tags: #{tags.length} found")
62
+
63
+ # Step 4: Increment version
64
+ next_version = Kdep::VersionTagger.next_tag(tags)
65
+ @ui.info("Next version: #{next_version}")
66
+
67
+ # Step 5: Build
68
+ if registry_url && !registry_url.to_s.empty?
69
+ full_tag = "#{registry_url}/#{image}:#{next_version}"
70
+ else
71
+ full_tag = "#{image}:#{next_version}"
72
+ end
73
+
74
+ Kdep::Docker.build(
75
+ tag: full_tag,
76
+ context_dir: repo_root,
77
+ dockerfile: config["dockerfile"],
78
+ target: config["target"],
79
+ platform: @command_options[:platform] || config["platform"]
80
+ )
81
+ @ui.info("Built: #{full_tag}")
82
+
83
+ # Step 6: Push
84
+ Kdep::Docker.push(full_tag)
85
+ @ui.info("Pushed: #{full_tag}")
86
+
87
+ # Step 7: Render + Apply (unless --skip-apply)
88
+ unless @command_options[:"skip-apply"]
89
+ config["tag"] = next_version
90
+
91
+ # Render manifests
92
+ preset = Kdep::Preset.new(config["preset"], deploy_dir)
93
+ resources = preset.resources
94
+ output_dir = File.join(repo_root, ".rendered")
95
+
96
+ writer = Kdep::Writer.new(output_dir)
97
+ writer.clean
98
+ renderer = Kdep::Renderer.new(config, deploy_dir)
99
+
100
+ resources.each_with_index do |resource_name, idx|
101
+ index = idx + 1
102
+ begin
103
+ content = renderer.render_resource(resource_name)
104
+ rescue => e
105
+ @ui.error("#{resource_name}: #{e.message}")
106
+ next
107
+ end
108
+ writer.write(resource_name, content, index)
109
+ end
110
+
111
+ # Apply
112
+ rendered_files = Dir.glob(File.join(output_dir, "*.yml")).sort
113
+ namespace = config["namespace"]
114
+ errors = []
115
+ applied = 0
116
+
117
+ rendered_files.each do |file_path|
118
+ begin
119
+ Kdep::Kubectl.apply(file_path, namespace: namespace)
120
+ @ui.info("applied: #{File.basename(file_path)}")
121
+ applied += 1
122
+ rescue Kdep::Kubectl::Error => e
123
+ errors << { "file" => File.basename(file_path), "error" => e.message }
124
+ @ui.error("#{File.basename(file_path)}: #{e.message}")
125
+ end
126
+ end
127
+
128
+ if errors.empty?
129
+ @ui.info("#{applied} files applied, 0 errors")
130
+
131
+ # Launch TUI dashboard for live monitoring
132
+ unless @command_options[:"no-dashboard"]
133
+ begin
134
+ require "kdep/dashboard"
135
+ dashboard = Kdep::Dashboard.new(
136
+ "name" => config["name"],
137
+ "namespace" => namespace,
138
+ "registry" => config["registry"],
139
+ "image" => image
140
+ )
141
+ dashboard.run
142
+ rescue LoadError, NameError
143
+ # Dashboard not available, skip silently
144
+ end
145
+ end
146
+ else
147
+ @ui.info("#{applied} files applied, #{errors.length} errors")
148
+ exit 1
149
+ end
150
+ end
151
+
152
+ rescue Kdep::Docker::Error => e
153
+ @ui.error(e.message)
154
+ exit 1
155
+ rescue Kdep::Registry::Error => e
156
+ @ui.error(e.message)
157
+ exit 1
158
+ end
159
+ end
160
+
161
+ private
162
+
163
+ def resolve_deploy_dir(kdep_dir, deploy_name, discovery)
164
+ if deploy_name
165
+ deploy_dir = File.join(kdep_dir, deploy_name)
166
+ unless File.directory?(deploy_dir)
167
+ @ui.error("Deploy target not found: #{deploy_name}")
168
+ deploys = discovery.find_deploys
169
+ if deploys.any?
170
+ @ui.info("Available deploys: #{deploys.join(', ')}")
171
+ end
172
+ return nil
173
+ end
174
+ deploy_dir
175
+ else
176
+ deploys = discovery.find_deploys
177
+ if deploys.length == 1
178
+ File.join(kdep_dir, deploys[0])
179
+ elsif deploys.length > 1
180
+ @ui.error("Multiple deploys found, specify one: #{deploys.join(', ')}")
181
+ nil
182
+ else
183
+ @ui.error("No deploy targets found in kdep/")
184
+ nil
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end