deploio-cli 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4464fa0c5bfb5ca034fc5b7936d2b3d856224068fb984bea2bf03018b1e33841
4
+ data.tar.gz: 8b9a0dc13c1262ebc81b8bee5e4aac9d9d47e70fb2850a6259ded636a40ea3af
5
+ SHA512:
6
+ metadata.gz: 61678961554d647509a198b88510862d178774814e2fe094aa42e891ed655f079a8216b9663addd75da1861a2d24d7d9c0419ec6abb50bed139cdbab1176dec3
7
+ data.tar.gz: 9fcedee868c9a4621b4e0441618267074dc6963cb7ff0f8e7d00565b9f2e4f01c8f926e38e1d8ec5029c4775f975cd687e40b13f7b6e35141c2c20d814841d18
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.8
data/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # deploio-cli (deploio / depl)
2
+
3
+ A CLI for [Deploio](https://www.deplo.io/) that wraps [`nctl`](https://github.com/ninech/nctl) commands with a simpler interface.
4
+
5
+ ## Requirements
6
+
7
+ - Ruby 3.3+
8
+ - nctl version 1.10.0 or higher
9
+
10
+ ## Installation
11
+
12
+ ### From source (development)
13
+
14
+ ```bash
15
+ git clone https://github.com/renuo/deploio-cli.git
16
+ cd deploio-cli
17
+ bundle install
18
+ bundle exec bin/deploio --help
19
+ ```
20
+
21
+ ### As a gem (coming soon)
22
+
23
+ ```bash
24
+ gem install deploio-cli
25
+ ```
26
+
27
+ ## Shell Completion
28
+
29
+ Enable autocompletion by adding this to your `~/.zshrc` or `~/.bashrc`, or whatever you use:
30
+
31
+ ```bash
32
+ eval "$(deploio completion)"
33
+ ```
34
+
35
+ After setup, completions work for:
36
+ - Commands: `deploio <TAB>` → shows all commands and subcommands
37
+ - Options: `deploio logs --<TAB>` → shows --tail, --lines, --app, etc.
38
+ - Apps (dynamic): `deploio logs --app <TAB>` → fetches available apps from server
39
+
40
+ ## Configuration
41
+
42
+ ### App naming convention
43
+
44
+ Apps are referenced using `<project>-<app>` format, where:
45
+ - `project` is your Deploio project name (e.g., `deploio-landing-page`)
46
+ - `app` is the environment/app name (e.g., `develop`, `production`)
47
+
48
+ Example: `deploio-landing-page-develop`
49
+
50
+ ### Automatic app detection
51
+
52
+ When you run a command without `--app`, the CLI will automatically detect the app by matching your git remote URL against nctl apps:
53
+
54
+ ```bash
55
+ cd ~/projects/deploio-landing-page
56
+ deploio logs --tail # Automatically detects app from git remote
57
+ ```
58
+
59
+ If multiple apps match (e.g., develop and production), you'll be prompted to select one.
60
+
61
+ ## Usage
62
+
63
+ ```
64
+ deploio - CLI for Deploio (wraps nctl)
65
+
66
+ AUTHENTICATION
67
+ deploio auth:login Authenticate with nctl
68
+ deploio auth:logout Log out
69
+ deploio auth:whoami Show current user and organization
70
+ deploio login Shortcut for auth:login
71
+
72
+ APPS
73
+ deploio apps List all apps
74
+ deploio apps:info -a APP Show app details
75
+
76
+ LOGS
77
+ deploio logs -a APP Show recent logs
78
+ deploio logs -a APP --tail Stream logs continuously
79
+ deploio logs -a APP -n 200 Show last N lines
80
+
81
+ EXECUTION
82
+ deploio exec -a APP -- CMD Run command in app container
83
+ deploio run -a APP -- CMD Alias for exec
84
+
85
+ OTHER
86
+ deploio completion Generate shell completion script
87
+ deploio version Show version
88
+
89
+ FLAGS
90
+ -a, --app APP App in <project>-<app> format
91
+ -o, --org ORG Organization
92
+ --dry-run Print commands without executing
93
+ --no-color Disable colored output
94
+ ```
95
+
96
+ ## Examples
97
+
98
+ ### Authentication
99
+
100
+ ```bash
101
+ # Login to nctl
102
+ deploio login
103
+
104
+ # Check current user
105
+ deploio auth:whoami
106
+ ```
107
+
108
+ ### Working with apps
109
+
110
+ ```bash
111
+ # List all apps
112
+ deploio apps
113
+
114
+ # Show app info
115
+ deploio apps:info -a myproject-staging
116
+
117
+ ```
118
+
119
+ ### Logs and execution
120
+
121
+ ```bash
122
+ # View logs
123
+ deploio logs -a deploio-landing-page-develop
124
+
125
+ # Stream logs
126
+ deploio logs -a deploio-landing-page-develop --tail
127
+
128
+ # Run a command
129
+ deploio exec -a deploio-landing-page-develop -- rails console
130
+
131
+ # With git remote matching (auto-detected)
132
+ cd ~/projects/deploio-landing-page
133
+ deploio logs --tail
134
+ deploio exec -- rails console
135
+ ```
136
+
137
+ ## Development
138
+
139
+ ### Running tests
140
+
141
+ ```bash
142
+ bundle exec rake test
143
+ ```
144
+
145
+ ### Building the gem
146
+
147
+ ```bash
148
+ gem build deploio-cli.gemspec
149
+ ```
150
+
151
+ ### Testing commands
152
+
153
+ The best way to test the commands in the shell is to temporarly set:
154
+
155
+ ```shell
156
+ export PATH="$PWD/bin:$PATH"
157
+ ```
158
+
159
+ to have the `deploio` command binded to the current one, and
160
+
161
+ ```shell
162
+ eval "$(deploio completion)"
163
+ ```
164
+
165
+ to refresh the autocompletion options.
166
+
167
+ ## License
168
+
169
+ MIT
170
+
171
+ ## Copyright
172
+
173
+ Renuo AG
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ task default: :test
data/bin/deploio ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
5
+
6
+ require 'deploio'
7
+
8
+ $stdout.sync = true
9
+
10
+ begin
11
+ Deploio::CLI.start(ARGV)
12
+ rescue Deploio::Error => e
13
+ Deploio::Output.error(e.message)
14
+ exit 1
15
+ rescue Interrupt
16
+ puts "\nInterrupted"
17
+ exit 130
18
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/deploio/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "deploio-cli"
7
+ spec.version = Deploio::VERSION
8
+ spec.authors = ["Renuo AG"]
9
+ spec.email = ["info@renuo.ch"]
10
+
11
+ spec.summary = "CLI for Deploio"
12
+ spec.description = "A Ruby CLI that provides an interface for managing Deploio applications."
13
+ spec.homepage = "https://github.com/renuo/deploio-cli"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.0.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+
21
+ spec.files = Dir.chdir(__dir__) do
22
+ `git ls-files -z`.split("\x0").reject do |f|
23
+ (File.expand_path(f) == __FILE__) ||
24
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
25
+ end
26
+ end
27
+ spec.bindir = "bin"
28
+ spec.executables = ["deploio"]
29
+ spec.require_paths = ["lib"]
30
+
31
+ spec.add_dependency "thor", "~> 1.3"
32
+ spec.add_dependency "tty-table", "~> 0.12"
33
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "did_you_mean"
4
+
5
+ # AppRef represents a reference to a deploio application.
6
+ module Deploio
7
+ class AppRef
8
+ attr_reader :project_name, :app_name
9
+
10
+ # The input is given in the format "<project>-<app>"
11
+ def initialize(input, available_apps: {})
12
+ @input = input.to_s
13
+ parse_from_available_apps(available_apps)
14
+ end
15
+
16
+ def full_name
17
+ "#{project_name}-#{app_name}"
18
+ end
19
+
20
+ def to_s
21
+ full_name
22
+ end
23
+
24
+ def ==(other)
25
+ return false unless other.is_a?(AppRef)
26
+
27
+ project_name == other.project_name && app_name == other.app_name
28
+ end
29
+
30
+ private
31
+
32
+ def parse_from_available_apps(available_apps)
33
+ if available_apps.key?(@input)
34
+ match = available_apps[@input]
35
+ @project_name = match[:project_name]
36
+ @app_name = match[:app_name]
37
+ return
38
+ end
39
+
40
+ # If available_apps provided but no match, raise error with suggestions
41
+ raise_not_found_error(@input, available_apps.keys) unless available_apps.empty?
42
+
43
+ raise_not_found_error(@input, [])
44
+ end
45
+
46
+ def raise_not_found_error(input, available_app_names)
47
+ message = "App not found: '#{input}'"
48
+
49
+ suggestions = suggest_similar(input, available_app_names)
50
+ unless suggestions.empty?
51
+ message += "\n\nDid you mean?"
52
+ suggestions.each { |s| message += "\n #{s}" }
53
+ end
54
+
55
+ message += "\n\nRun 'deploio apps' to see available apps."
56
+
57
+ raise Deploio::AppNotFoundError, message
58
+ end
59
+
60
+ def suggest_similar(input, dictionary)
61
+ return [] if dictionary.empty?
62
+
63
+ spell_checker = DidYouMean::SpellChecker.new(dictionary: dictionary)
64
+ spell_checker.correct(input)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deploio
4
+ class AppResolver
5
+ attr_reader :nctl, :git_remote_url, :current_org
6
+
7
+ def initialize(nctl_client:)
8
+ @nctl = nctl_client
9
+ @git_remote_url = Utils.detect_git_remote
10
+ @current_org = @nctl.current_org
11
+ end
12
+
13
+ def resolve(app_name: nil)
14
+ # 1. Explicit --app flag
15
+ if app_name
16
+ return AppRef.new(app_name, available_apps: available_apps_hash)
17
+ end
18
+
19
+ # 2. Match git remote against nctl apps
20
+ if git_remote_url
21
+ matches = find_apps_by_git_remote(git_remote_url)
22
+
23
+ case matches.size
24
+ when 0
25
+ raise Deploio::AppNotFoundError,
26
+ "No deploio apps found matching git remote: #{git_remote_url}\n" \
27
+ "Use --app to specify the app explicitly.\n" \
28
+ "Run 'deploio apps' to see available apps."
29
+ when 1
30
+ return build_app_ref_from_match(matches.first)
31
+ else
32
+ app_names = matches.map { |m| format_match(m) }
33
+ raise Deploio::Error,
34
+ "Multiple apps found for this repo. Use --app to specify which one:\n" \
35
+ "#{app_names.map { |name| " #{name}" }.join("\n")}"
36
+ end
37
+ end
38
+
39
+ raise Deploio::Error,
40
+ "No app specified. Use --app <project-app> or run from a git repo with a matching remote."
41
+ end
42
+
43
+ # Returns hash mapping app names -> {project_name:, app_name:}
44
+ # Supports both full names (org-project-app) and short names (project-app)
45
+ def available_apps_hash
46
+ @available_apps_hash ||= begin
47
+ hash = {}
48
+ current_org = @nctl.current_org
49
+ @nctl.get_all_apps.each do |app|
50
+ metadata = app["metadata"] || {}
51
+ project_name = metadata["namespace"] || ""
52
+ app_name = metadata["name"]
53
+ full_name = "#{project_name}-#{app_name}"
54
+ hash[full_name] = {project_name: project_name, app_name: app_name}
55
+
56
+ # Also index by short name (without org prefix) for convenience
57
+ if current_org && project_name.start_with?("#{current_org}-")
58
+ project = project_name.delete_prefix("#{current_org}-")
59
+ short_name = "#{project}-#{app_name}"
60
+ hash[short_name] ||= {project_name: project_name, app_name: app_name}
61
+ end
62
+ end
63
+ hash
64
+ end
65
+ rescue
66
+ {}
67
+ end
68
+
69
+ def short_name_for(namespace, app_name)
70
+ org = current_org
71
+ if org && namespace.start_with?("#{org}-")
72
+ project = namespace.delete_prefix("#{org}-")
73
+ "#{project}-#{app_name}"
74
+ else
75
+ "#{namespace}-#{app_name}"
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def find_apps_by_git_remote(git_url)
82
+ normalized_url = normalize_git_url(git_url)
83
+ @nctl.get_all_apps.select do |app|
84
+ app_git_url = app.dig("spec", "forProvider", "git", "url")
85
+ normalize_git_url(app_git_url) == normalized_url
86
+ end
87
+ end
88
+
89
+ def normalize_git_url(url)
90
+ return nil if url.nil?
91
+
92
+ url.strip
93
+ .sub(/\.git$/, "")
94
+ .sub(%r{^https://github\.com/}, "git@github.com:")
95
+ .downcase
96
+ end
97
+
98
+ def build_app_ref_from_match(match)
99
+ metadata = match["metadata"] || {}
100
+ namespace = metadata["namespace"] || ""
101
+ name = metadata["name"]
102
+ AppRef.new("#{namespace}-#{name}", available_apps: available_apps_hash)
103
+ end
104
+
105
+ def format_match(match)
106
+ metadata = match["metadata"] || {}
107
+ namespace = metadata["namespace"] || ""
108
+ name = metadata["name"]
109
+ short_name_for(namespace, name)
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require_relative "commands/auth"
5
+ require_relative "commands/apps"
6
+ require_relative "commands/orgs"
7
+ require_relative "completion_generator"
8
+
9
+ module Deploio
10
+ class CLI < Thor
11
+ include SharedOptions
12
+
13
+ desc "version", "Show version"
14
+ def version
15
+ puts "deploio-cli #{Deploio::VERSION}"
16
+ end
17
+ map %w[-v --version] => :version
18
+
19
+ desc "completion", "Generate shell completion script"
20
+ method_option :shell, aliases: "-s", type: :string, default: "zsh",
21
+ desc: "Shell type (zsh, bash, fish)"
22
+ def completion
23
+ case options[:shell].downcase
24
+ when "zsh"
25
+ puts CompletionGenerator.new.generate
26
+ when "bash", "fish"
27
+ Output.error("#{options[:shell]} completion is not yet supported. Only zsh is available.")
28
+ exit 1
29
+ else
30
+ Output.error("Unknown shell: #{options[:shell]}. Supported: zsh")
31
+ exit 1
32
+ end
33
+ end
34
+
35
+ desc "auth COMMAND", "Authentication commands"
36
+ subcommand "auth", Commands::Auth
37
+
38
+ desc "apps COMMAND", "Apps management commands"
39
+ subcommand "apps", Commands::Apps
40
+ desc "orgs COMMAND", "Organization management commands"
41
+ subcommand "orgs", Commands::Orgs
42
+
43
+ # Shortcut for auth:login
44
+ desc "login", "Authenticate with nctl (alias for auth:login)"
45
+ def login
46
+ Commands::Auth.start(["login"] + build_option_args)
47
+ end
48
+
49
+ # Shortcut for auth:whoami
50
+ desc "whoami", "Show current user (alias for auth:whoami)"
51
+ def whoami
52
+ Commands::Auth.start(["whoami"] + build_option_args)
53
+ end
54
+
55
+ # Shortcut for auth:logout
56
+ desc "logout", "Log out from nctl (alias for auth:logout)"
57
+ def logout
58
+ Commands::Auth.start(["logout"] + build_option_args)
59
+ end
60
+
61
+ # Logs command
62
+ desc "logs", "Show logs for an app"
63
+ method_option :tail, aliases: "-t", type: :boolean, default: false, desc: "Stream logs continuously"
64
+ method_option :lines, aliases: "-n", type: :numeric, default: 100, desc: "Number of lines to show"
65
+ def logs
66
+ setup_options
67
+ app_ref = resolve_app
68
+ @nctl.logs(app_ref, tail: options[:tail], lines: options[:lines])
69
+ end
70
+
71
+ # Exec command
72
+ desc "exec [-- COMMAND]", "Run command in app container"
73
+ def exec(*args)
74
+ setup_options
75
+ app_ref = resolve_app
76
+ if args.empty?
77
+ Output.error("No command specified. Usage: deploio exec -a APP -- COMMAND")
78
+ exit 1
79
+ end
80
+ @nctl.exec_command(app_ref, args)
81
+ end
82
+ map "run" => :exec
83
+
84
+ private
85
+
86
+ def build_option_args
87
+ args = []
88
+ args << "--dry-run" if options[:dry_run]
89
+ args << "--no-color" if options[:no_color]
90
+ args << "--app" << options[:app] if options[:app]
91
+ args << "--org" << options[:org] if options[:org]
92
+ args
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deploio
4
+ module Commands
5
+ class Apps < Thor
6
+ include SharedOptions
7
+
8
+ namespace "apps"
9
+
10
+ class_option :json, type: :boolean, default: false, desc: "Output as JSON"
11
+
12
+ default_task :list
13
+
14
+ desc "list", "List all apps"
15
+ def list
16
+ setup_options
17
+ raw_apps = @nctl.get_all_apps
18
+
19
+ if options[:json]
20
+ puts JSON.pretty_generate(raw_apps)
21
+ return
22
+ end
23
+
24
+ if raw_apps.empty?
25
+ Output.warning("No apps found") unless merged_options[:dry_run]
26
+ return
27
+ end
28
+
29
+ resolver = AppResolver.new(nctl_client: @nctl)
30
+
31
+ rows = raw_apps.map do |app|
32
+ metadata = app["metadata"] || {}
33
+ spec = app["spec"] || {}
34
+ for_provider = spec["forProvider"] || {}
35
+ git = for_provider["git"] || {}
36
+ config = for_provider["config"] || {}
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(config["size"], default: "micro"),
44
+ presence(git["revision"])
45
+ ]
46
+ end
47
+
48
+ Output.table(rows, headers: %w[APP PROJECT SIZE REVISION])
49
+ end
50
+
51
+ desc "info", "Show app details"
52
+ def info
53
+ setup_options
54
+ app_ref = resolve_app
55
+ data = @nctl.get_app(app_ref)
56
+
57
+ if options[:json]
58
+ puts JSON.pretty_generate(data)
59
+ return
60
+ end
61
+
62
+ display_app_info(data, app_ref)
63
+ end
64
+
65
+ private
66
+
67
+ def display_app_info(data, app_ref)
68
+ metadata = data["metadata"] || {}
69
+ spec = data["spec"] || {}
70
+ status = data["status"] || {}
71
+ for_provider = spec["forProvider"] || {}
72
+ git = for_provider["git"] || {}
73
+ config = for_provider["config"] || {}
74
+ build_env = for_provider["buildEnv"] || []
75
+ at_provider = status["atProvider"] || {}
76
+
77
+ Output.header("App: #{app_ref.full_name}")
78
+ puts
79
+
80
+ Output.header("General")
81
+ Output.table([
82
+ ["Name", presence(metadata["name"])],
83
+ ["Project", presence(metadata["namespace"])],
84
+ ["Size", presence(config["size"], default: "micro")],
85
+ ["Replicas", presence(for_provider["replicas"], default: "1")],
86
+ ["Port", presence(config["port"], default: "8080")]
87
+ ])
88
+
89
+ puts
90
+
91
+ Output.header("Status")
92
+ conditions = status["conditions"] || []
93
+ ready_condition = conditions.find { |c| c["type"] == "Ready" }
94
+ synced_condition = conditions.find { |c| c["type"] == "Synced" }
95
+
96
+ Output.table([
97
+ ["Ready", presence(ready_condition&.dig("status"))],
98
+ ["Synced", presence(synced_condition&.dig("status"))],
99
+ ["Default URL", presence(at_provider["defaultURL"])],
100
+ ["Latest Build", presence(at_provider["latestBuild"])],
101
+ ["Latest Release", presence(at_provider["latestRelease"])]
102
+ ])
103
+
104
+ puts
105
+
106
+ hosts = for_provider["hosts"] || []
107
+ if hosts.any?
108
+ Output.header("Hosts")
109
+ Output.table(hosts.map { |h| [presence(h)] })
110
+ puts
111
+ end
112
+
113
+ Output.header("Git")
114
+ Output.table([
115
+ ["Repository", presence(git["url"])],
116
+ ["Revision", presence(git["revision"])],
117
+ ["Sub Path", presence(git["subPath"])]
118
+ ])
119
+ puts
120
+
121
+ if build_env.any? || for_provider["dockerfilePath"]
122
+ Output.header("Build")
123
+ rows = []
124
+ rows << ["Dockerfile", presence(for_provider["dockerfilePath"])] if for_provider["dockerfilePath"]
125
+ build_env.each do |env|
126
+ rows << [env["name"], presence(env["value"], max_length: 60)]
127
+ end
128
+ Output.table(rows, headers: %w[SETTING VALUE]) if rows.any?
129
+ puts
130
+ end
131
+ end
132
+
133
+ def presence(value, default: "-", max_length: nil)
134
+ return default if value.nil? || value.to_s.empty?
135
+
136
+ str = value.to_s
137
+ (max_length && str.length > max_length) ? "#{str[0, max_length - 3]}..." : str
138
+ end
139
+
140
+ def project_from_namespace(namespace, current_org)
141
+ if current_org && namespace.start_with?("#{current_org}-")
142
+ namespace.delete_prefix("#{current_org}-")
143
+ else
144
+ namespace
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Deploio
4
+ module Commands
5
+ class Auth < Thor
6
+ include SharedOptions
7
+
8
+ namespace "auth"
9
+
10
+ desc "login", "Authenticate with nctl"
11
+ def login
12
+ setup_options
13
+ @nctl.auth_login
14
+ end
15
+
16
+ desc "logout", "Log out from nctl"
17
+ def logout
18
+ setup_options
19
+ @nctl.auth_logout
20
+ Output.success("Logged out successfully")
21
+ end
22
+
23
+ desc "whoami", "Show current user and organization"
24
+ def whoami
25
+ setup_options
26
+ @nctl.auth_whoami
27
+ end
28
+ end
29
+ end
30
+ end