deploio-cli 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ef7a08d44a816dd88331bf7e007a536ef399b10afbf05229bef8e1e3137629e
4
- data.tar.gz: d042f89dc44e64df49d599f1291f88151af45461132156af5c36486fa6408baa
3
+ metadata.gz: 5dcf01de3150cb22e539daf36c3b166063427b1545c2bdf65bac575fea8b463c
4
+ data.tar.gz: 505202b6194b9248f039178c45b23c54089cb1887fb38dbfef37dafc6ad2b43e
5
5
  SHA512:
6
- metadata.gz: 603238b92b6b23a32bf92f81a0a89a4cc0edbf8daad40519f1f165c095629ccb5f83775f01f0aae67541f6177f845ebc9797584fdcdb977425fa2307c2d8f98e
7
- data.tar.gz: 2ac16607435cc562bcf3d3e94066002315bb06f198122fe6d088ee25782f9153106ab065e217f34a86d5aa99a4cb2d7d154876eabcc958266f2e74cc6771ef09
6
+ metadata.gz: b8fc759d2107cf92663ff45bc05c8bc6ccdd15f038a4d34e35941324571d6264e69fedd7ae7e179cedabb3029232982f4afd7a8b887af7e20d8bb712d581060c
7
+ data.tar.gz: 92916faa78a8645f47738bb749b872aa83e22852727c594498a26d642279d21db5acda922f103cc0e305ceea9e9eecc1353e401890e71bf80850b679ce30b2d0
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.4.1 - 2026-11-04
4
+
5
+ - Remove the duplicate `homepage_uri` gem metadata that made `gem build` warn
6
+
7
+ ## 0.4.0 - 2026-11-04
8
+
9
+ - Add `mysql` commands, including MySQL database backups ([#5](https://github.com/renuo/deploio-cli/pull/5))
10
+ - Add bash completions ([#7](https://github.com/renuo/deploio-cli/pull/7))
11
+ - Replace the fork installer with `bin/setup`, so the checkout runs from any directory
12
+ ([#8](https://github.com/renuo/deploio-cli/pull/8))
data/README.md CHANGED
@@ -15,10 +15,12 @@ A CLI for [Deploio](https://www.deplo.io/) that wraps [`nctl`](https://github.co
15
15
  ```bash
16
16
  git clone https://github.com/renuo/deploio-cli.git
17
17
  cd deploio-cli
18
- bundle install
19
- bundle exec bin/deploio --help
18
+ bin/setup
20
19
  ```
21
20
 
21
+ `bin/setup` installs the dependencies and puts `deploio` and `depl` binstubs into `~/.local/bin` (override with `INSTALL_DIR`), so this checkout runs from any directory.
22
+ The binstubs are pinned to the Ruby that ran `bin/setup`; run it again after changing the Ruby version.
23
+
22
24
  ### As a gem
23
25
 
24
26
  ```bash
@@ -27,10 +29,14 @@ gem install deploio-cli
27
29
 
28
30
  ## Shell Completion
29
31
 
30
- Enable autocompletion by adding this to your `~/.zshrc` or `~/.bashrc`, or whatever you use:
32
+ Enable autocompletion by adding the line for your shell to its rc file:
31
33
 
32
34
  ```bash
35
+ # ~/.zshrc
33
36
  eval "$(deploio completion)"
37
+
38
+ # ~/.bashrc
39
+ eval "$(deploio completion --shell bash)"
34
40
  ```
35
41
 
36
42
  After setup, completions work for:
@@ -108,7 +114,8 @@ EXECUTION
108
114
  deploio run -a APP -- CMD Alias for exec
109
115
 
110
116
  OTHER
111
- deploio completion Generate shell completion script
117
+ deploio completion Generate zsh completion script
118
+ deploio completion -s bash Generate bash completion script
112
119
  deploio version Show version
113
120
 
114
121
  FLAGS
@@ -211,31 +218,35 @@ deploio exec -- rails console
211
218
  ### Running tests
212
219
 
213
220
  ```bash
214
- bundle exec rake test
221
+ bin/check
222
+ bin/fastcheck
215
223
  ```
216
224
 
217
- ### Building the gem
225
+ ### Release
218
226
 
219
- ```bash
220
- gem build deploio-cli.gemspec
221
- ```
227
+ 1. Bump the version in `lib/deploio/version.rb`, run `bundle install`, update `CHANGELOG.md` and commit to `main`.
228
+ 2. Tag the commit with the bare version and push the tag:
222
229
 
223
- ### Testing commands
230
+ ```bash
231
+ git tag -a 1.2.3 -m 1.2.3
232
+ git push origin 1.2.3
233
+ ```
224
234
 
225
- The best way to test the commands in the shell is to temporarly set:
235
+ 3. Create a GitHub release for the tag with the changelog section as notes.
226
236
 
227
- ```shell
228
- export PATH="$PWD/bin:$PATH"
229
- ```
237
+ Semaphore runs the tests on the tag and, when they pass, promotes the `release` pipeline, which pushes the gem to
238
+ [rubygems.org](https://rubygems.org). It fails when the tag does not match the gem version.
239
+
240
+ ### Testing commands
230
241
 
231
- to have the `deploio` command binded to the current one, and
242
+ After `bin/setup`, `deploio` runs this checkout, so changes take effect immediately.
243
+ After adding or changing commands or options, refresh the autocompletion options:
232
244
 
233
245
  ```shell
234
- eval "$(deploio completion)"
246
+ eval "$(deploio completion)" # zsh
247
+ eval "$(deploio completion --shell bash)" # bash
235
248
  ```
236
249
 
237
- to refresh the autocompletion options.
238
-
239
250
  ## License
240
251
 
241
252
  MIT
data/Rakefile CHANGED
@@ -3,6 +3,8 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "rake/testtask"
5
5
 
6
+ Rake::Task["release"].clear
7
+
6
8
  Rake::TestTask.new(:test) do |t|
7
9
  t.libs << "test"
8
10
  t.libs << "lib"
data/deploio-cli.gemspec CHANGED
@@ -14,7 +14,6 @@ Gem::Specification.new do |spec|
14
14
  spec.license = "MIT"
15
15
  spec.required_ruby_version = ">= 3.0.0"
16
16
 
17
- spec.metadata["homepage_uri"] = spec.homepage
18
17
  spec.metadata["source_code_uri"] = spec.homepage
19
18
  spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
19
 
@@ -23,12 +22,16 @@ Gem::Specification.new do |spec|
23
22
 
24
23
  eval "$(deploio completion)"
25
24
 
25
+ or this to your ~/.bashrc:
26
+
27
+ eval "$(deploio completion --shell bash)"
28
+
26
29
  MSG
27
30
 
28
31
  spec.files = Dir.chdir(__dir__) do
29
32
  `git ls-files -z`.split("\x0").reject do |f|
30
33
  (File.expand_path(f) == __FILE__) ||
31
- f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
34
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github .semaphore appveyor Gemfile])
32
35
  end
33
36
  end
34
37
  spec.bindir = "bin"
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "completion_generator"
4
+
5
+ module Deploio
6
+ # Generates bash completion script from Thor command metadata.
7
+ # Completers are shell commands printing the candidates.
8
+ #
9
+ # @example With custom option completers
10
+ # BashCompletionGenerator.new(MyCLI, option_completers: {
11
+ # 'env' => 'echo production staging development'
12
+ # }).generate
13
+ #
14
+ class BashCompletionGenerator < CompletionGenerator
15
+ TEMPLATE_PATH = File.expand_path("templates/completion.bash.erb", __dir__)
16
+
17
+ ROOT_OPTIONS = {version: Thor::Option.new(:version, type: :boolean, aliases: "-v")}.freeze
18
+
19
+ # A command path ("" is the root, "pg:backups" a nested subcommand)
20
+ # with the candidates that can follow it.
21
+ Context = Struct.new(:path, :commands, :options, :positional, :passthrough, keyword_init: true)
22
+
23
+ private
24
+
25
+ def default_option_completers
26
+ {
27
+ "app" => "_#{program_name}_apps_list",
28
+ "size" => "echo micro mini standard",
29
+ "project" => "_#{program_name}_projects_list"
30
+ }
31
+ end
32
+
33
+ def default_positional_completers
34
+ {
35
+ "orgs:set" => "_#{program_name}_orgs_list",
36
+ "pg:info" => "_#{program_name}_pg_databases_list",
37
+ "pg:backups:capture" => "_#{program_name}_pg_databases_list",
38
+ "pg:backups:list" => "_#{program_name}_pg_databases_list",
39
+ "pg:backups:download" => "_#{program_name}_pg_databases_list"
40
+ }
41
+ end
42
+
43
+ def contexts
44
+ @contexts ||= [
45
+ Context.new(path: "", commands: main_commands.map(&:first), options: ROOT_OPTIONS),
46
+ *direct_commands.map { |name, options| leaf_context(name, all_options(options)) },
47
+ *passthrough_commands.map { |name, options| leaf_context(name, all_options(options), passthrough: true) },
48
+ *subcommands.flat_map do |name, commands, class_options, default_task, klass|
49
+ group_contexts(name, commands, class_options, default_task, klass.subcommand_classes)
50
+ end,
51
+ *nested_subcommands.flat_map do |name, commands, class_options|
52
+ group_contexts(name, commands, class_options)
53
+ end
54
+ ]
55
+ end
56
+
57
+ def group_contexts(name, commands, class_options, default_task = nil, nested = {})
58
+ default_options = commands.find { |cmd_name, _, _| cmd_name == default_task }&.last || {}
59
+ group = Context.new(path: name, commands: commands.map(&:first),
60
+ options: all_options(default_options, class_options))
61
+
62
+ leaves = commands.reject { |cmd_name, _, _| nested.key?(cmd_name) }.map do |cmd_name, _, options|
63
+ leaf_context("#{name}:#{cmd_name}", all_options(options, class_options))
64
+ end
65
+
66
+ [group, *leaves]
67
+ end
68
+
69
+ def leaf_context(path, options, passthrough: false)
70
+ Context.new(path: path, commands: [], options: options,
71
+ positional: positional_completers[path], passthrough: passthrough)
72
+ end
73
+
74
+ def all_options(method_options, class_options = {})
75
+ cli_class_options.merge(class_options).merge(method_options)
76
+ end
77
+
78
+ def flags(context)
79
+ context.options.flat_map { |name, opt| option_flags(name, opt) }.uniq
80
+ end
81
+
82
+ def value_flags(context)
83
+ context.options.reject { |_, opt| opt.type == :boolean }.flat_map { |name, opt| option_flags(name, opt) }.uniq
84
+ end
85
+
86
+ def option_value_completers
87
+ contexts.flat_map { |context| context.options.to_a }.uniq(&:first).filter_map do |name, opt|
88
+ completer = option_completers[name.to_s]
89
+ [option_flags(name, opt), completer] if completer
90
+ end
91
+ end
92
+
93
+ def option_flags(name, opt)
94
+ ["--#{name.to_s.tr("_", "-")}", *opt.aliases]
95
+ end
96
+ end
97
+ end
data/lib/deploio/cli.rb CHANGED
@@ -7,9 +7,13 @@ require_relative "commands/builds"
7
7
  require_relative "commands/orgs"
8
8
  require_relative "commands/projects"
9
9
  require_relative "commands/services"
10
+ require_relative "helpers/backups_helper"
10
11
  require_relative "commands/postgresql_backups"
11
12
  require_relative "commands/postgresql"
13
+ require_relative "commands/mysql_backups"
14
+ require_relative "commands/mysql"
12
15
  require_relative "completion_generator"
16
+ require_relative "bash_completion_generator"
13
17
 
14
18
  module Deploio
15
19
  class CLI < Thor
@@ -28,11 +32,13 @@ module Deploio
28
32
  case options[:shell].downcase
29
33
  when "zsh"
30
34
  puts CompletionGenerator.new.generate
31
- when "bash", "fish"
32
- Output.error("#{options[:shell]} completion is not yet supported. Only zsh is available.")
35
+ when "bash"
36
+ puts BashCompletionGenerator.new.generate
37
+ when "fish"
38
+ Output.error("#{options[:shell]} completion is not yet supported. Only zsh and bash are available.")
33
39
  exit 1
34
40
  else
35
- Output.error("Unknown shell: #{options[:shell]}. Supported: zsh")
41
+ Output.error("Unknown shell: #{options[:shell]}. Supported: zsh, bash")
36
42
  exit 1
37
43
  end
38
44
  end
@@ -57,6 +63,9 @@ module Deploio
57
63
  desc "pg COMMAND", "PostgreSQL database management commands"
58
64
  subcommand "pg", Commands::PostgreSQL
59
65
 
66
+ desc "mysql COMMAND", "MySQL database management commands"
67
+ subcommand "mysql", Commands::MySQL
68
+
60
69
  # Shortcut for auth:login
61
70
  desc "login", "Authenticate with nctl (alias for auth:login)"
62
71
  def login
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deploio
4
+ module Commands
5
+ class MySQL < Thor
6
+ include SharedOptions
7
+
8
+ namespace "mysql"
9
+
10
+ class_option :json, type: :boolean, default: false, desc: "Output as JSON"
11
+
12
+ default_task :list
13
+
14
+ desc "list", "List all MySQL databases"
15
+ def list
16
+ setup_options
17
+ raw_dbs = @nctl.get_all_mysql_databases
18
+
19
+ if options[:json]
20
+ puts JSON.pretty_generate(raw_dbs)
21
+ return
22
+ end
23
+
24
+ if raw_dbs.empty?
25
+ Output.warning("No MySQL databases found") unless merged_options[:dry_run]
26
+ return
27
+ end
28
+
29
+ resolver = MysqlDatabaseResolver.new(nctl_client: @nctl)
30
+
31
+ rows = raw_dbs.map do |mysql_database|
32
+ kind = mysql_database["kind"] || ""
33
+ metadata = mysql_database["metadata"] || {}
34
+ spec = mysql_database["spec"] || {}
35
+ for_provider = spec["forProvider"] || {}
36
+ version = for_provider["version"]
37
+ namespace = metadata["namespace"] || ""
38
+ name = metadata["name"] || ""
39
+
40
+ [
41
+ resolver.short_name_for(namespace, name),
42
+ project_from_namespace(namespace, resolver.current_org),
43
+ presence(kind, default: "-"),
44
+ presence(version, default: "?")
45
+ ]
46
+ end
47
+
48
+ Output.table(rows, headers: ["NAME", "PROJECT", "KIND", "VERSION"])
49
+ end
50
+
51
+ desc "info NAME", "Show MySQL database details"
52
+ def info(name)
53
+ setup_options
54
+ resolver = MysqlDatabaseResolver.new(nctl_client: @nctl)
55
+ db_ref = resolver.resolve(database_name: name)
56
+ data = @nctl.get_mysql_database(db_ref)
57
+
58
+ if options[:json]
59
+ puts JSON.pretty_generate(data)
60
+ return
61
+ end
62
+
63
+ kind = data["kind"]
64
+ metadata = data["metadata"] || {}
65
+ spec = data["spec"] || {}
66
+ for_provider = spec["forProvider"] || {}
67
+ status = data["status"] || {}
68
+ at_provider = status["atProvider"] || {}
69
+
70
+ Output.header("MySQL Database: #{db_ref.full_name}")
71
+ puts
72
+
73
+ Output.header("General")
74
+ Output.table(
75
+ [
76
+ ["Name", presence(metadata["name"])],
77
+ ["Project", presence(metadata["namespace"])],
78
+ ["Kind", presence(kind, default: "-")],
79
+ ["Version", presence(for_provider["version"], default: "?")],
80
+ ["FQDN", presence(at_provider["fqdn"])],
81
+ ["Size", presence(at_provider["size"], default: "-")]
82
+ ]
83
+ )
84
+
85
+ puts
86
+
87
+ Output.header("Status")
88
+ conditions = status["conditions"] || []
89
+ ready_condition = conditions.find { |c| c["type"] == "Ready" }
90
+ synced_condition = conditions.find { |c| c["type"] == "Synced" }
91
+
92
+ Output.table(
93
+ [
94
+ ["Ready", presence(ready_condition&.dig("status"))],
95
+ ["Synced", presence(synced_condition&.dig("status"))]
96
+ ]
97
+ )
98
+
99
+ if for_provider["allowedCIDRs"].is_a?(Array)
100
+ puts
101
+
102
+ Output.header("Access")
103
+ Output.table(
104
+ [
105
+ ["Allowed CIDRs", for_provider["allowedCIDRs"].join(", ")]
106
+ ]
107
+ )
108
+
109
+ puts
110
+
111
+ Output.header("SSH Keys")
112
+ for_provider["sshKeys"].each do |key|
113
+ puts "- #{key}"
114
+ end
115
+ end
116
+ rescue Deploio::Error => e
117
+ Output.error(e.message)
118
+ exit 1
119
+ end
120
+
121
+ desc "backups COMMAND", "Manage MySQL database backups"
122
+ subcommand "backups", Commands::MySQLBackups
123
+
124
+ private
125
+
126
+ def presence(value, default: "-")
127
+ (value.nil? || value.to_s.empty?) ? default : value
128
+ end
129
+
130
+ def project_from_namespace(namespace, current_org)
131
+ if current_org && namespace.start_with?("#{current_org}-")
132
+ namespace.delete_prefix("#{current_org}-")
133
+ else
134
+ namespace
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,77 @@
1
+ module Deploio
2
+ module Commands
3
+ class MySQLBackups < Thor
4
+ include SharedOptions
5
+ include BackupsHelper
6
+
7
+ namespace "mysql:backups"
8
+
9
+ DEDICATED_KIND = "MySQL"
10
+ ECONOMY_KIND = "MySQLDatabase"
11
+
12
+ desc "capture NAME", "Capture a new backup for the specified MySQL database"
13
+ def capture(name)
14
+ backup_service_for(name).capture
15
+ rescue Deploio::Error => e
16
+ Output.error(e.message)
17
+ exit 1
18
+ end
19
+
20
+ desc "list NAME", "List the available backups for the specified MySQL database"
21
+ def list(name)
22
+ backups = backup_service_for(name).backups
23
+ if backups.empty?
24
+ Output.warning("No backups found for '#{name}'")
25
+ return
26
+ end
27
+
28
+ rows = backups.map do |backup|
29
+ [format_time(backup["ModTime"]), format_size(backup["Size"]), backup["Name"]]
30
+ end
31
+ Output.table(rows, headers: ["DATE", "SIZE", "NAME"])
32
+ rescue Deploio::Error => e
33
+ Output.error(e.message)
34
+ exit 1
35
+ end
36
+
37
+ desc "download NAME [--output destination_path]", "Download the latest backup for the specified MySQL database instance"
38
+ method_option :output, type: :string, desc: "Output file path (defaults to current directory with auto-generated name)"
39
+ method_option :db_name, type: :string, desc: "If there are multiple DBs, specify which one to download the backup for", default: nil
40
+ def download(name)
41
+ service = backup_service_for(name)
42
+ destination = merged_options[:output] || service.default_destination
43
+ backup = service.download(destination: destination, db_name: merged_options[:db_name])
44
+
45
+ # Only the economy tier knows when its backup was taken.
46
+ if backup
47
+ Output.success("Downloaded backup from #{format_time(backup["ModTime"])} to #{destination}")
48
+ else
49
+ Output.success("Downloaded backup to #{destination}")
50
+ end
51
+ rescue Deploio::Error => e
52
+ Output.error(e.message)
53
+ exit 1
54
+ end
55
+
56
+ private
57
+
58
+ def backup_service_for(name)
59
+ setup_options
60
+ resolver = MysqlDatabaseResolver.new(nctl_client: @nctl)
61
+ db_ref = resolver.resolve(database_name: name)
62
+ data = @nctl.get_mysql_database(db_ref)
63
+ raise Deploio::Error, "Could not read database '#{db_ref.full_name}'." if data.nil?
64
+
65
+ case data["kind"]
66
+ when DEDICATED_KIND
67
+ MysqlBackupService.new(data: data, name: name, dry_run: @nctl.dry_run)
68
+ when ECONOMY_KIND
69
+ MysqlDatabaseBackupService.new(db_ref: db_ref, data: data, nctl_client: @nctl, name: name)
70
+ else
71
+ raise Deploio::UnsupportedBackupOperationError,
72
+ "Backups are not supported for databases of kind '#{data["kind"]}'."
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -1,9 +1,8 @@
1
- require "time"
2
-
3
1
  module Deploio
4
2
  module Commands
5
3
  class PostgreSQLBackups < Thor
6
4
  include SharedOptions
5
+ include BackupsHelper
7
6
 
8
7
  namespace "pg:backups"
9
8
 
@@ -73,24 +72,6 @@ module Deploio
73
72
  "Backups are not supported for databases of kind '#{data["kind"]}'."
74
73
  end
75
74
  end
76
-
77
- def format_time(value)
78
- Time.parse(value.to_s).localtime.strftime("%Y-%m-%d %H:%M")
79
- rescue ArgumentError, TypeError
80
- value.to_s
81
- end
82
-
83
- def format_size(bytes)
84
- bytes = bytes.to_i
85
- units = ["B", "KiB", "MiB", "GiB", "TiB"]
86
- index = 0
87
- size = bytes.to_f
88
- while size >= 1024 && index < units.size - 1
89
- size /= 1024
90
- index += 1
91
- end
92
- (index.zero? ? "#{bytes} B" : format("%.1f %s", size, units[index]))
93
- end
94
75
  end
95
76
  end
96
77
  end
@@ -37,7 +37,7 @@ module Deploio
37
37
  end
38
38
 
39
39
  def generate
40
- template = File.read(TEMPLATE_PATH)
40
+ template = File.read(self.class::TEMPLATE_PATH)
41
41
  ERB.new(template, trim_mode: "-").result(binding)
42
42
  end
43
43
 
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Deploio
6
+ module BackupsHelper
7
+ def format_size(bytes)
8
+ bytes = bytes.to_i
9
+ units = ["B", "KiB", "MiB", "GiB", "TiB"]
10
+ index = 0
11
+ size = bytes.to_f
12
+ while size >= 1024 && index < units.size - 1
13
+ size /= 1024
14
+ index += 1
15
+ end
16
+ (index.zero? ? "#{bytes} B" : format("%.1f %s", size, units[index]))
17
+ end
18
+
19
+ def format_time(value)
20
+ Time.parse(value.to_s).localtime.strftime("%Y-%m-%d %H:%M")
21
+ rescue ArgumentError, TypeError
22
+ value.to_s
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deploio
4
+ class MysqlBackupService
5
+ DEFAULT_EXTENSION = ".zst"
6
+
7
+ # @param name [String] the name the user typed, used for hints in messages
8
+ def initialize(data:, name: nil, dry_run: false)
9
+ @data = data || {}
10
+ @name = name
11
+ @dry_run = dry_run
12
+ end
13
+
14
+ def default_destination = "./#{@name}-latest-backup"
15
+
16
+ def backups
17
+ raise Deploio::UnsupportedBackupOperationError,
18
+ "Listing backups is not yet supported for dedicated MySQL instances; Feel free to implement it!\n" \
19
+ "Use 'deploio mysql backups download #{@name}' to fetch it."
20
+ end
21
+
22
+ def capture
23
+ cmd = ["ssh", "dbadmin@#{fqdn}", "sudo", "nine-mysql-backup"]
24
+ Output.command(cmd.join(" "))
25
+ system(*cmd) unless @dry_run
26
+ end
27
+
28
+ def download(destination:, db_name: nil)
29
+ name = resolve_db_name(db_name)
30
+
31
+ cmd = [
32
+ "rsync",
33
+ "-av",
34
+ "dbadmin@#{fqdn}:~/backup/mysql/latest/customer/#{name}/",
35
+ destination
36
+ ]
37
+
38
+ Output.command(cmd.join(" "))
39
+ unless @dry_run || system(*cmd)
40
+ raise Deploio::Error, "Failed to download MySQL backup for database '#{name}'."
41
+ end
42
+
43
+ nil
44
+ end
45
+
46
+ private
47
+
48
+ def resolve_db_name(db_name)
49
+ if databases.empty?
50
+ raise Deploio::Error,
51
+ "No databases found in MySQL instance; cannot download backup."
52
+ elsif databases.size > 1 && db_name.nil?
53
+ raise Deploio::Error,
54
+ "Multiple databases found in MySQL instance\n" \
55
+ "Databases: #{databases.join(", ")}\n" \
56
+ "Please specify the database name using the --db_name option."
57
+ end
58
+
59
+ db_name || databases.first
60
+ end
61
+
62
+ def databases
63
+ @databases ||= (
64
+ @data.dig("status", "atProvider", "databases")&.keys || []
65
+ ).reject { |db| db.strip.empty? }
66
+ end
67
+
68
+ def fqdn
69
+ value = @data.dig("status", "atProvider", "fqdn")
70
+
71
+ if value.nil? || value.empty?
72
+ raise Deploio::Error,
73
+ "Database FQDN not found; cannot reach the database server."
74
+ end
75
+
76
+ value
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deploio
4
+ class MysqlDatabaseBackupService
5
+ DEFAULT_EXTENSION = ".sql.zst"
6
+ BACKUP_SCHEDULE_LABEL = "DatabaseBackupSchedule"
7
+
8
+ def initialize(
9
+ db_ref:,
10
+ data:,
11
+ nctl_client:,
12
+ name: nil,
13
+ rclone_client_factory: nil
14
+ )
15
+ @db_ref = db_ref
16
+ @data = data || {}
17
+ @nctl = nctl_client
18
+ @name = name || db_ref.full_name
19
+ @rclone_client_factory = rclone_client_factory || method(:build_rclone_client)
20
+ end
21
+
22
+ def default_destination
23
+ "./#{@name}-latest-backup#{DEFAULT_EXTENSION}"
24
+ end
25
+
26
+ # Nine takes these backups on a schedule and there is no way to trigger one.
27
+ def capture
28
+ raise Deploio::UnsupportedBackupOperationError,
29
+ "'#{@db_ref.full_name}' is an economy-tier database. Those are backed up automatically " \
30
+ "on their configured schedule and cannot be captured manually.\n" \
31
+ "Use 'deploio mysql backups list #{@name}' to see the available backups."
32
+ end
33
+
34
+ def backups
35
+ @backups ||= begin
36
+ entries = rclone.list(bucket_name)
37
+ entries = entries.select { |entry| own_backup?(entry["Name"].to_s) }
38
+ entries.sort_by { |entry| entry["ModTime"].to_s }.reverse
39
+ end
40
+ end
41
+
42
+ def download(destination:, db_name: nil)
43
+ backup = backups.first
44
+
45
+ unless backup
46
+ raise Deploio::Error,
47
+ "No backups found for '#{@db_ref.full_name}' in bucket '#{bucket_name}'."
48
+ end
49
+
50
+ rclone.download(bucket_name, backup["Name"], destination)
51
+ backup
52
+ end
53
+
54
+ private
55
+
56
+ def own_backup?(object_name)
57
+ return true if instance_name.empty?
58
+
59
+ object_name.start_with?("MySQLDatabase-#{instance_name}-")
60
+ end
61
+
62
+ def instance_name
63
+ @instance_name ||= @data.dig("status", "atProvider", "name").to_s
64
+ end
65
+
66
+ def bucket_name
67
+ bucket.dig("metadata", "name")
68
+ end
69
+
70
+ # The database resource holds no reference to its backup bucket.
71
+ # Therefore, match the bucket by name for now.
72
+ def bucket
73
+ @bucket ||= begin
74
+ candidates = @nctl
75
+ .get_services_by_type("bucket", project: @db_ref.project_name)
76
+ .select { |candidate| backup_bucket_for_database?(candidate) }
77
+
78
+ if candidates.empty?
79
+ raise Deploio::Error,
80
+ "No backup bucket found for '#{@db_ref.full_name}'. " \
81
+ "Check that backups are enabled for this database " \
82
+ "(spec.forProvider.backupSchedule)."
83
+ end
84
+
85
+ candidates.first
86
+ end
87
+ end
88
+
89
+ def backup_bucket_for_database?(bucket)
90
+ metadata = bucket["metadata"] || {}
91
+ labels = metadata["labels"] || {}
92
+
93
+ return false unless labels["nine.ch/controllerKind"] == BACKUP_SCHEDULE_LABEL
94
+
95
+ metadata["name"].to_s.match?(
96
+ /\Amysqldatabase-#{Regexp.escape(@db_ref.database_name)}-[0-9a-f]{7}\z/
97
+ )
98
+ end
99
+
100
+ def rclone
101
+ @rclone ||= @rclone_client_factory.call
102
+ end
103
+
104
+ def build_rclone_client
105
+ endpoint = bucket.dig("status", "atProvider", "endpoint")
106
+
107
+ if endpoint.nil? || endpoint.to_s.empty?
108
+ raise Deploio::Error,
109
+ "Backup bucket '#{bucket_name}' has no endpoint; cannot access backups."
110
+ end
111
+
112
+ client = RcloneClient.new(
113
+ endpoint: "https://#{endpoint}",
114
+ access_key: @nctl.get_bucket_user_access_key(
115
+ bucket_name,
116
+ project: @db_ref.project_name
117
+ ),
118
+ secret_key: @nctl.get_bucket_user_secret_key(
119
+ bucket_name,
120
+ project: @db_ref.project_name
121
+ ),
122
+ dry_run: @nctl.dry_run
123
+ )
124
+
125
+ client.check_requirements unless @nctl.dry_run
126
+ client
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "did_you_mean"
4
+
5
+ module Deploio
6
+ class MysqlDatabaseRef
7
+ attr_reader :project_name, :database_name
8
+
9
+ def initialize(input, available_databases: {})
10
+ @input = input.to_s
11
+ if available_databases.key?(@input)
12
+ match = available_databases[@input]
13
+ @project_name = match[:project_name]
14
+ @database_name = match[:database_name]
15
+ else
16
+ raise_not_found_error(@input, available_databases.keys)
17
+ end
18
+ end
19
+
20
+ def full_name = "#{project_name}-#{database_name}"
21
+ def to_s = full_name
22
+
23
+ def ==(other)
24
+ other.is_a?(self.class) && project_name == other.project_name && database_name == other.database_name
25
+ end
26
+
27
+ private
28
+
29
+ def raise_not_found_error(input, available_database_names)
30
+ message = "Database not found: '#{input}'"
31
+ suggestions = DidYouMean::SpellChecker.new(dictionary: available_database_names).correct(input) unless available_database_names.empty?
32
+ unless suggestions.nil? || suggestions.empty?
33
+ message += "\n\nDid you mean?"
34
+ suggestions.each { |suggestion| message += "\n #{suggestion}" }
35
+ end
36
+ message += "\n\nRun 'deploio mysql' to see available MySQL databases."
37
+ raise Deploio::MysqlDatabaseNotFoundError, message
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deploio
4
+ class MysqlDatabaseResolver
5
+ attr_reader :nctl, :current_org
6
+
7
+ def initialize(nctl_client:)
8
+ @nctl = nctl_client
9
+ @database_ref = MysqlDatabaseRef
10
+ @fetch_databases = -> { @nctl.get_all_mysql_databases }
11
+ @current_org = @nctl.current_org
12
+ end
13
+
14
+ def resolve(database_name: nil)
15
+ if database_name
16
+ return @database_ref.new(
17
+ database_name,
18
+ available_databases: available_databases_hash
19
+ )
20
+ end
21
+
22
+ raise Deploio::Error, "No database specified"
23
+ end
24
+
25
+ # Returns hash mapping database names -> { project_name:, database_name: }
26
+ def available_databases_hash
27
+ @available_databases_hash ||= begin
28
+ hash = {}
29
+ current_org = @nctl.current_org
30
+
31
+ @fetch_databases.call.each do |database|
32
+ metadata = database["metadata"] || {}
33
+ project_name = metadata["namespace"] || ""
34
+ database_name = metadata["name"]
35
+ full_name = "#{project_name}-#{database_name}"
36
+
37
+ hash[full_name] = {
38
+ project_name: project_name,
39
+ database_name: database_name
40
+ }
41
+
42
+ # Also index by short name (without org prefix) for convenience
43
+ if current_org && project_name.start_with?("#{current_org}-")
44
+ project = project_name.delete_prefix("#{current_org}-")
45
+ short_name = "#{project}-#{database_name}"
46
+
47
+ hash[short_name] ||= {
48
+ project_name: project_name,
49
+ database_name: database_name
50
+ }
51
+ end
52
+ end
53
+
54
+ hash
55
+ end
56
+ rescue
57
+ {}
58
+ end
59
+
60
+ def short_name_for(namespace, database_name)
61
+ org = current_org
62
+
63
+ if org && namespace.start_with?("#{org}-")
64
+ project = namespace.delete_prefix("#{org}-")
65
+ "#{project}-#{database_name}"
66
+ else
67
+ "#{namespace}-#{database_name}"
68
+ end
69
+ end
70
+ end
71
+ end
@@ -100,6 +100,42 @@ module Deploio
100
100
  nil
101
101
  end
102
102
 
103
+ def get_all_mysql_databases
104
+ output_dedicated_dbs = capture("get", "mysql", "-A", "-o", "json")
105
+ output_shared_dbs = capture("get", "mysqldatabase", "-A", "-o", "json")
106
+ if (output_dedicated_dbs.nil? || output_dedicated_dbs.empty?) &&
107
+ (output_shared_dbs.nil? || output_shared_dbs.empty?)
108
+ return []
109
+ end
110
+
111
+ [
112
+ *JSON.parse(output_dedicated_dbs),
113
+ *JSON.parse(output_shared_dbs)
114
+ ]
115
+ rescue JSON::ParserError
116
+ []
117
+ end
118
+
119
+ def get_mysql_database(db_ref)
120
+ output = begin
121
+ capture("get", "mysql", db_ref.database_name,
122
+ "--project", db_ref.project_name, "-o", "json")
123
+ rescue Deploio::NctlError
124
+ nil
125
+ end
126
+
127
+ if output.nil? || output.empty?
128
+ output = capture("get", "mysqldatabase", db_ref.database_name,
129
+ "--project", db_ref.project_name, "-o", "json")
130
+ end
131
+
132
+ return nil if output.nil? || output.empty?
133
+
134
+ JSON.parse(output)
135
+ rescue JSON::ParserError
136
+ nil
137
+ end
138
+
103
139
  def get_apps_by_project(project)
104
140
  output = capture("get", "apps", "--project", project, "-o", "json")
105
141
  return [] if output.nil? || output.empty?
@@ -0,0 +1,140 @@
1
+ # Bash completion for <%= program_name %>-cli
2
+ # Auto-generated from Thor command definitions
3
+ # Enable with: eval "$(<%= program_name %> completion --shell bash)"
4
+
5
+ # Dynamic completion for organizations
6
+ _<%= program_name %>_orgs_list() {
7
+ <%= program_name %> orgs --json 2>/dev/null | ruby -rjson -e '
8
+ data = JSON.parse(STDIN.read) rescue []
9
+ data.each { |o| puts o["name"] }
10
+ ' 2>/dev/null
11
+ }
12
+
13
+ # Dynamic completion for projects
14
+ _<%= program_name %>_projects_list() {
15
+ <%= program_name %> projects --json 2>/dev/null | ruby -rjson -e '
16
+ data = JSON.parse(STDIN.read) rescue []
17
+ orgs_json = `<%= program_name %> orgs --json 2>/dev/null` rescue "[]"
18
+ orgs = JSON.parse(orgs_json) rescue []
19
+ current_org = orgs.find { |o| o["current"] }&.fetch("name", nil)
20
+
21
+ data.each do |p|
22
+ name = p.dig("metadata", "name") || ""
23
+ short_name = current_org && name.start_with?("#{current_org}-") ? name.delete_prefix("#{current_org}-") : name
24
+ puts short_name
25
+ end
26
+ ' 2>/dev/null
27
+ }
28
+
29
+ # Dynamic completion for apps
30
+ _<%= program_name %>_apps_list() {
31
+ <%= program_name %> apps --json 2>/dev/null | ruby -rjson -e '
32
+ data = JSON.parse(STDIN.read) rescue []
33
+ orgs_json = `<%= program_name %> orgs --json 2>/dev/null` rescue "[]"
34
+ orgs = JSON.parse(orgs_json) rescue []
35
+ current_org = orgs.find { |o| o["current"] }&.fetch("name", nil)
36
+
37
+ data.each do |a|
38
+ ns = a.dig("metadata", "namespace") || ""
39
+ name = a.dig("metadata", "name") || ""
40
+ project = current_org && ns.start_with?("#{current_org}-") ? ns.delete_prefix("#{current_org}-") : ns
41
+ puts "#{project}-#{name}"
42
+ end
43
+ ' 2>/dev/null
44
+ }
45
+
46
+ # Dynamic completion for PostgreSQL databases
47
+ _<%= program_name %>_pg_databases_list() {
48
+ <%= program_name %> pg list --json 2>/dev/null | ruby -rjson -e '
49
+ data = JSON.parse(STDIN.read) rescue []
50
+ orgs_json = `<%= program_name %> orgs --json 2>/dev/null` rescue "[]"
51
+ orgs = JSON.parse(orgs_json) rescue []
52
+ current_org = orgs.find { |o| o["current"] }&.fetch("name", nil)
53
+
54
+ data.each do |db|
55
+ ns = db.dig("metadata", "namespace") || ""
56
+ name = db.dig("metadata", "name") || ""
57
+ project = current_org && ns.start_with?("#{current_org}-") ? ns.delete_prefix("#{current_org}-") : ns
58
+ puts "#{project}-#{name}"
59
+ end
60
+ ' 2>/dev/null
61
+ }
62
+
63
+ # Sets the candidates following a command path in the caller's scope
64
+ _<%= program_name %>_context() {
65
+ commands="" flags="" value_flags="" positional="" passthrough=""
66
+ case "$1" in
67
+ <% contexts.each do |context| -%>
68
+ "<%= context.path %>")
69
+ <% unless context.commands.empty? -%>
70
+ commands="<%= context.commands.join(" ") %>"
71
+ <% end -%>
72
+ flags="<%= flags(context).join(" ") %>"
73
+ <% unless value_flags(context).empty? -%>
74
+ value_flags="<%= value_flags(context).join(" ") %>"
75
+ <% end -%>
76
+ <% if context.positional -%>
77
+ positional="<%= context.positional %>"
78
+ <% end -%>
79
+ <% if context.passthrough -%>
80
+ passthrough=1
81
+ <% end -%>
82
+ ;;
83
+ <% end -%>
84
+ esac
85
+ }
86
+
87
+ _<%= program_name %>_option_values() {
88
+ case "$1" in
89
+ <% option_value_completers.each do |flags, completer| -%>
90
+ <%= flags.join("|") %>) <%= completer %> ;;
91
+ <% end -%>
92
+ esac
93
+ }
94
+
95
+ _<%= program_name %>_includes() {
96
+ [[ -n "$2" && " $1 " == *" $2 "* ]]
97
+ }
98
+
99
+ # Main completion function
100
+ _<%= program_name %>() {
101
+ local cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}"
102
+ local cmd="" args=0 word i
103
+ local commands flags value_flags positional passthrough
104
+ COMPREPLY=()
105
+
106
+ _<%= program_name %>_context ""
107
+ for ((i = 1; i < COMP_CWORD; i++)); do
108
+ word="${COMP_WORDS[i]}"
109
+ if [[ "$word" == "--" && -n "$passthrough" ]]; then
110
+ if ((i + 1 == COMP_CWORD)); then
111
+ COMPREPLY=($(compgen -c -- "$cur"))
112
+ else
113
+ COMPREPLY=($(compgen -f -- "$cur"))
114
+ fi
115
+ return
116
+ elif _<%= program_name %>_includes "$value_flags" "$word"; then
117
+ ((++i))
118
+ elif [[ "$word" == -* ]]; then
119
+ continue
120
+ elif ((args == 0)) && _<%= program_name %>_includes "$commands" "$word"; then
121
+ cmd="${cmd:+$cmd:}$word"
122
+ _<%= program_name %>_context "$cmd"
123
+ else
124
+ ((++args))
125
+ fi
126
+ done
127
+
128
+ if _<%= program_name %>_includes "$value_flags" "$prev"; then
129
+ COMPREPLY=($(compgen -W "$(_<%= program_name %>_option_values "$prev")" -- "$cur"))
130
+ elif [[ "$cur" == -* ]]; then
131
+ COMPREPLY=($(compgen -W "$flags" -- "$cur"))
132
+ elif ((args == 0)) && [[ -n "$commands" ]]; then
133
+ COMPREPLY=($(compgen -W "$commands" -- "$cur"))
134
+ elif ((args == 0)) && [[ -n "$positional" ]]; then
135
+ COMPREPLY=($(compgen -W "$($positional)" -- "$cur"))
136
+ fi
137
+ }
138
+
139
+ # Register the completion function
140
+ complete -F _<%= program_name %> <%= program_name %> depl
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Deploio
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.1"
5
5
  end
data/lib/deploio.rb CHANGED
@@ -7,12 +7,16 @@ require_relative "deploio/utils"
7
7
  require_relative "deploio/output"
8
8
  require_relative "deploio/app_ref"
9
9
  require_relative "deploio/pg_database_ref"
10
+ require_relative "deploio/mysql_database_ref"
10
11
  require_relative "deploio/nctl_client"
11
12
  require_relative "deploio/rclone_client"
12
13
  require_relative "deploio/app_resolver"
13
14
  require_relative "deploio/pg_database_resolver"
14
15
  require_relative "deploio/postgres_backup_service"
15
16
  require_relative "deploio/postgres_database_backup_service"
17
+ require_relative "deploio/mysql_database_resolver"
18
+ require_relative "deploio/mysql_backup_service"
19
+ require_relative "deploio/mysql_database_backup_service"
16
20
  require_relative "deploio/price_fetcher"
17
21
  require_relative "deploio/shared_options"
18
22
  require_relative "deploio/cli"
@@ -21,6 +25,7 @@ module Deploio
21
25
  class Error < StandardError; end
22
26
  class AppNotFoundError < Error; end
23
27
  class PgDatabaseNotFoundError < Error; end
28
+ class MysqlDatabaseNotFoundError < Error; end
24
29
  class NctlError < Error; end
25
30
  class RcloneError < Error; end
26
31
  class UnsupportedBackupOperationError < Error; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: deploio-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Renuo AG
@@ -46,6 +46,7 @@ extensions: []
46
46
  extra_rdoc_files: []
47
47
  files:
48
48
  - ".ruby-version"
49
+ - CHANGELOG.md
49
50
  - README.md
50
51
  - Rakefile
51
52
  - bin/deploio
@@ -53,16 +54,24 @@ files:
53
54
  - lib/deploio.rb
54
55
  - lib/deploio/app_ref.rb
55
56
  - lib/deploio/app_resolver.rb
57
+ - lib/deploio/bash_completion_generator.rb
56
58
  - lib/deploio/cli.rb
57
59
  - lib/deploio/commands/apps.rb
58
60
  - lib/deploio/commands/auth.rb
59
61
  - lib/deploio/commands/builds.rb
62
+ - lib/deploio/commands/mysql.rb
63
+ - lib/deploio/commands/mysql_backups.rb
60
64
  - lib/deploio/commands/orgs.rb
61
65
  - lib/deploio/commands/postgresql.rb
62
66
  - lib/deploio/commands/postgresql_backups.rb
63
67
  - lib/deploio/commands/projects.rb
64
68
  - lib/deploio/commands/services.rb
65
69
  - lib/deploio/completion_generator.rb
70
+ - lib/deploio/helpers/backups_helper.rb
71
+ - lib/deploio/mysql_backup_service.rb
72
+ - lib/deploio/mysql_database_backup_service.rb
73
+ - lib/deploio/mysql_database_ref.rb
74
+ - lib/deploio/mysql_database_resolver.rb
66
75
  - lib/deploio/nctl_client.rb
67
76
  - lib/deploio/output.rb
68
77
  - lib/deploio/pg_database_ref.rb
@@ -72,15 +81,14 @@ files:
72
81
  - lib/deploio/price_fetcher.rb
73
82
  - lib/deploio/rclone_client.rb
74
83
  - lib/deploio/shared_options.rb
84
+ - lib/deploio/templates/completion.bash.erb
75
85
  - lib/deploio/templates/completion.zsh.erb
76
86
  - lib/deploio/utils.rb
77
87
  - lib/deploio/version.rb
78
- - setup
79
88
  homepage: https://github.com/renuo/deploio-cli
80
89
  licenses:
81
90
  - MIT
82
91
  metadata:
83
- homepage_uri: https://github.com/renuo/deploio-cli
84
92
  source_code_uri: https://github.com/renuo/deploio-cli
85
93
  changelog_uri: https://github.com/renuo/deploio-cli/blob/main/CHANGELOG.md
86
94
  post_install_message: |+
@@ -88,6 +96,10 @@ post_install_message: |+
88
96
 
89
97
  eval "$(deploio completion)"
90
98
 
99
+ or this to your ~/.bashrc:
100
+
101
+ eval "$(deploio completion --shell bash)"
102
+
91
103
  rdoc_options: []
92
104
  require_paths:
93
105
  - lib
@@ -102,7 +114,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
102
114
  - !ruby/object:Gem::Version
103
115
  version: '0'
104
116
  requirements: []
105
- rubygems_version: 3.6.9
117
+ rubygems_version: 4.0.2
106
118
  specification_version: 4
107
119
  summary: CLI for Deploio
108
120
  test_files: []
data/setup DELETED
@@ -1,115 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
- APP_NAME=${APP_NAME:-deploio}
4
- INSTALL_DIR=${INSTALL_DIR:-"${HOME}/.local/bin"}
5
- RAW_BASE=${RAW_BASE:-"https://raw.githubusercontent.com/CuddlyBunion341/deploio-cli/main"}
6
- CLI_URL="${RAW_BASE}/deploio"
7
- MAN_URL="${RAW_BASE}/deploio.1"
8
- COMPLETION_URL="${RAW_BASE}/_deploio"
9
- COLOR_RESET='\033[0m'
10
- COLOR_GREEN='\033[0;32m'
11
- COLOR_YELLOW='\033[0;33m'
12
- COLOR_CYAN='\033[0;36m'
13
- COLOR_RED='\033[0;31m'
14
- print() { printf "%b\n" "$*"; }
15
- info() { print "${COLOR_CYAN}$*${COLOR_RESET}"; }
16
- success() { print "${COLOR_GREEN}$*${COLOR_RESET}"; }
17
- warn() { print "${COLOR_YELLOW}$*${COLOR_RESET}"; }
18
- err() { print "${COLOR_RED}$*${COLOR_RESET}" 1>&2; }
19
- have_cmd() { command -v "$1" >/dev/null 2>&1; }
20
- fetch() {
21
- local url="$1" dest="$2"
22
- if have_cmd curl; then
23
- curl -fsSL "$url" -o "$dest"
24
- elif have_cmd wget; then
25
- wget -qO "$dest" "$url"
26
- else
27
- err "Need curl or wget to download: $url"
28
- return 1
29
- fi
30
- }
31
- main() {
32
- info "Installing ${APP_NAME} (deplo.io app CLI)"
33
- mkdir -p "$INSTALL_DIR"
34
- tmp_file="$(mktemp -t ${APP_NAME}.XXXXXX)"
35
- tmp_man="$(mktemp -t ${APP_NAME}-man.XXXXXX)"
36
- tmp_completion="$(mktemp -t ${APP_NAME}-completion.XXXXXX)"
37
- trap 'rm -f "$tmp_file" "$tmp_man" "$tmp_completion"' EXIT
38
-
39
- info "Downloading CLI from: $CLI_URL"
40
- if ! fetch "$CLI_URL" "$tmp_file"; then
41
- err "Failed to download ${APP_NAME} from $CLI_URL"
42
- exit 1
43
- fi
44
-
45
- info "Downloading man page from: $MAN_URL"
46
- if ! fetch "$MAN_URL" "$tmp_man"; then
47
- warn "Failed to download man page from $MAN_URL (continuing without man page)"
48
- fi
49
-
50
- info "Downloading zsh completion from: $COMPLETION_URL"
51
- if ! fetch "$COMPLETION_URL" "$tmp_completion"; then
52
- warn "Failed to download completion script from $COMPLETION_URL (continuing without completions)"
53
- fi
54
-
55
- install_path="${INSTALL_DIR}/${APP_NAME}"
56
- mv "$tmp_file" "$install_path"
57
- chmod +x "$install_path"
58
-
59
- # Install man page if download succeeded
60
- if [[ -s "$tmp_man" ]]; then
61
- man_dir="${HOME}/.local/share/man/man1"
62
- mkdir -p "$man_dir"
63
- mv "$tmp_man" "${man_dir}/${APP_NAME}.1"
64
- success "Installed man page to ${man_dir}/${APP_NAME}.1"
65
- fi
66
-
67
- # Install zsh completion if download succeeded
68
- if [[ -s "$tmp_completion" ]]; then
69
- completion_dir="${HOME}/.local/share/zsh/site-functions"
70
- mkdir -p "$completion_dir"
71
- mv "$tmp_completion" "${completion_dir}/_${APP_NAME}"
72
- success "Installed zsh completion to ${completion_dir}/_${APP_NAME}"
73
- fi
74
-
75
- # Create shorthand symlink 'depl'
76
- (cd "$INSTALL_DIR" && ln -sf "$APP_NAME" depl)
77
- success "Installed ${APP_NAME} to ${install_path}"
78
- case ":${PATH}:" in
79
- *":${INSTALL_DIR}:"*) ;;
80
- *)
81
- warn "${INSTALL_DIR} is not on your PATH. Add this to your ~/.zshrc:"
82
- echo ""
83
- echo " export PATH=\"$INSTALL_DIR:\$PATH\""
84
- echo ""
85
- echo "Then reload your shell:"
86
- echo " source ~/.zshrc"
87
- ;;
88
- esac
89
-
90
- # Check if man page directory is in MANPATH
91
- man_dir="${HOME}/.local/share/man"
92
- if [[ -f "${man_dir}/man1/${APP_NAME}.1" ]] && [[ ":${MANPATH}:" != *":${man_dir}:"* ]]; then
93
- warn "Man page installed but ${man_dir} is not in MANPATH."
94
- echo "Add this to your ~/.zshrc to access man pages:"
95
- echo ""
96
- echo " export MANPATH=\"${man_dir}:\$MANPATH\""
97
- echo ""
98
- fi
99
-
100
- # Check if completion directory is in fpath
101
- completion_dir="${HOME}/.local/share/zsh/site-functions"
102
- if [[ -f "${completion_dir}/_${APP_NAME}" ]]; then
103
- echo "Zsh completion installed. To enable it, add this to your ~/.zshrc:"
104
- echo ""
105
- echo " fpath=(\"${completion_dir}\" \$fpath)"
106
- echo " autoload -Uz compinit && compinit"
107
- echo ""
108
- echo "Then restart your shell or run: source ~/.zshrc"
109
- fi
110
- echo ""
111
- info "Test the installation:"
112
- echo " ${APP_NAME} --dry-run list"
113
- echo " man ${APP_NAME}"
114
- }
115
- main "$@"