omaship 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 17a560c4acd3fcb2a3ddc1f2dc878d7fa3a3bfce501821cd9ad2a0aaf6b5a0f8
4
+ data.tar.gz: e07f76c6417e4714473a5a699c3b3169e4934d3d5b38df61ca742d17a25fa786
5
+ SHA512:
6
+ metadata.gz: 8d0724a6d3f68a050d3f24bb131c4ff17965f6d992d8555288afbadb5472a6f7116c3c196d5179258fbb8214a1057e9c2fac73fcb8d110a67b5c25243538f705
7
+ data.tar.gz: de10cb82315db0621c4b612477391c20344535de035652d9374bbac0f3e6b60f78a9d4f4e2e7b26871e3232d26b7325d4df1e5c8cc617f742ca9814c371fb165
data/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ ## [0.2.1](https://github.com/bloomedai/omaship/compare/omaship/v0.2.0...omaship/v0.2.1) (2026-03-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * 🐛 enable trusted publishing for cli gem publish ([9e5a9ff](https://github.com/bloomedai/omaship/commit/9e5a9ff1c529a89a323db4b71282deb787251a3c))
9
+
10
+ ## [0.2.0](https://github.com/bloomedai/omaship/compare/omaship-v0.1.0...omaship/v0.2.0) (2026-03-06)
11
+
12
+
13
+ ### Features
14
+
15
+ * ✨ ship cli auth mvp, consent controls, and provisioning hardening ([e8b2fd0](https://github.com/bloomedai/omaship/commit/e8b2fd0f9e06e746886a666e7f1a7bd247ac1e35))
16
+
17
+
18
+ ### Code Refactoring
19
+
20
+ * ♻️ migrate CLAUDE.md references to AGENTS.md across codebase ([1d2323c](https://github.com/bloomedai/omaship/commit/1d2323c0b20e5cc255ca71c412bb0ad30431e75a))
21
+ * ♻️ Rename Project → Ship + add Infrastructure model [skip preview] ([#201](https://github.com/bloomedai/omaship/issues/201)) ([6541804](https://github.com/bloomedai/omaship/commit/65418042f501f350ea14a57234108872222c93c5))
22
+
23
+
24
+ ### Documentation
25
+
26
+ * 📝 update readmes for local proxy fallback and deploy workflows ([1683d5e](https://github.com/bloomedai/omaship/commit/1683d5ed801428c75a49d82b5745d85e4493e365))
27
+
28
+ ## [0.1.0] - 2026-03-04
29
+
30
+ - Initial public CLI packaging as the `omaship` gem.
31
+ - Added Thor-based commands for `login`, `new`, `configure`, `deploy`, and `whoami`.
32
+ - Added credentials storage and API client support for `/api/v1/cli`.
data/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # Omaship CLI
2
+
3
+ ## Install
4
+
5
+ RubyGems:
6
+
7
+ ```bash
8
+ gem install omaship
9
+ ```
10
+
11
+ Homebrew:
12
+
13
+ ```bash
14
+ brew tap bloomedai/homebrew-tap
15
+ brew install omaship
16
+ ```
17
+
18
+ ## Quick Start
19
+
20
+ ```bash
21
+ omaship login --token <token> --host https://build.omaship.com
22
+ omaship list
23
+ omaship use <ship-ref>
24
+ omaship info
25
+ ```
26
+
27
+ Examples for `SHIP_REF` (from `omaship list` output):
28
+
29
+ ```bash
30
+ omaship use omaship/acme
31
+ omaship use 17
32
+ ```
33
+
34
+ ## Ship Selection
35
+
36
+ `info`, `configure`, and `deploy` resolve the target ship in this order:
37
+
38
+ 1. `--ship <ship-ref>`
39
+ 2. saved default from `omaship use <ship-ref>`
40
+ 3. if exactly one accessible ship exists: auto-select it and persist it as default
41
+ 4. otherwise: command fails and asks you to run `omaship list` + `omaship use ...`
42
+
43
+ Preferred ship reference format is full ship name (`org/repo`).
44
+ Numeric ids are also accepted.
45
+
46
+ ## Commands
47
+
48
+ - `omaship login --token <token>`
49
+ - `omaship whoami`
50
+ - `omaship list`
51
+ - `omaship use <ship-ref>`
52
+ - `omaship info [--ship <ship-ref>]` (`status` and `ship` are aliases)
53
+ - `omaship new <name>` (requires Full CLI access)
54
+ - `omaship configure --payments <provider> [--ship <ship-ref>]` (requires Full CLI access)
55
+ - `omaship deploy [--ship <ship-ref>]` (requires Full CLI access)
56
+ - `omaship complete <bash|zsh|fish>` (print shell completion script)
57
+ - `omaship logout`
58
+
59
+ ## Local Development Provisioning
60
+
61
+ When you run `omaship new <name>` against local Omaship development (`OMASHIP_HOST=http://localhost:3000`), provisioning first installs gems from `https://packages.omaship.com`.
62
+
63
+ If that registry endpoint is unavailable, Omaship automatically retries `bundle install` through the local package proxy mirror at `http://build.localhost:3000/packages`.
64
+
65
+ ## Shell Completion
66
+
67
+ Bash:
68
+
69
+ ```bash
70
+ mkdir -p ~/.local/share/bash-completion/completions
71
+ omaship complete bash > ~/.local/share/bash-completion/completions/omaship
72
+ ```
73
+
74
+ Zsh:
75
+
76
+ ```bash
77
+ mkdir -p ~/.zfunc
78
+ omaship complete zsh > ~/.zfunc/_omaship
79
+ ```
80
+
81
+ Then ensure `~/.zfunc` is in `fpath` and reload completions:
82
+
83
+ ```bash
84
+ autoload -Uz compinit
85
+ compinit
86
+ ```
87
+
88
+ Fish:
89
+
90
+ ```bash
91
+ mkdir -p ~/.config/fish/completions
92
+ omaship complete fish > ~/.config/fish/completions/omaship.fish
93
+ ```
94
+
95
+ ## Environment
96
+
97
+ - `OMASHIP_HOST` (default: `http://localhost:3000`)
98
+ - `OMASHIP_TOKEN` (optional alternative for `login`)
99
+
100
+ ## Credentials
101
+
102
+ - Stored at `~/.config/omaship/credentials.json`
103
+ - File mode is `0600`
104
+ - Includes host, token, user, and optional `default_ship`
105
+
106
+ ## Local Development
107
+
108
+ Run from repository root:
109
+
110
+ ```bash
111
+ mise exec ruby@4.0.1 -- env BUNDLE_GEMFILE=cli/Gemfile bundle exec ruby cli/bin/omaship -h
112
+ ```
113
+
114
+ Run all CLI tests:
115
+
116
+ ```bash
117
+ cd cli
118
+ mise exec ruby@4.0.1 -- bundle exec ruby -Itest -e 'Dir.glob("test/**/*_test.rb").sort.each { |f| require "./#{f}" }'
119
+ ```
120
+
121
+ ## Release Automation
122
+
123
+ CLI releases are created by `release-please` (`omaship/v*` tags).
124
+
125
+ Required repository secrets:
126
+
127
+ - `RELEASE_PLEASE_PAT`
128
+ - `HOMEBREW_TAP_GITHUB_TOKEN` (must have push access to the tap repository)
129
+
130
+ Optional repository variable:
131
+
132
+ - `HOMEBREW_TAP_REPOSITORY` (default: `bloomedai/homebrew-tap`)
133
+
134
+ RubyGems publish uses Trusted Publishing via GitHub OIDC.
135
+
136
+ Configure a trusted publisher for gem `omaship`:
137
+
138
+ - repository owner: `bloomedai`
139
+ - repository name: `omaship`
140
+ - workflow filename: `publish-gem.yml`
data/bin/omaship ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "omaship"
5
+
6
+ begin
7
+ Omaship::CLI.start(ARGV)
8
+ rescue StandardError => error
9
+ warn error.message
10
+ exit 1
11
+ end
@@ -0,0 +1,89 @@
1
+ require "faraday"
2
+ require "faraday/retry"
3
+ require "json"
4
+
5
+ module Omaship
6
+ class ApiClient
7
+ class Error < StandardError; end
8
+ class UnauthorizedError < Error; end
9
+ class PermissionDeniedError < Error; end
10
+ class NotFoundError < Error; end
11
+
12
+ def initialize(host:, token:)
13
+ @host = host.to_s
14
+ @token = token.to_s
15
+ end
16
+
17
+ def authenticate
18
+ get_json("/api/v1/cli/auth")
19
+ end
20
+
21
+ def list_ships
22
+ get_json("/api/v1/cli/ships").fetch("ships")
23
+ end
24
+
25
+ def create_ship(root_domain:, visibility: "private")
26
+ post_json("/api/v1/cli/ships", { ship: { root_domain: root_domain, visibility: visibility } })
27
+ end
28
+
29
+ def ship(ship_id:)
30
+ get_json("/api/v1/cli/ships/#{ship_id}")
31
+ end
32
+
33
+ def ship_logs(ship_id:)
34
+ get_json("/api/v1/cli/ships/#{ship_id}/logs")
35
+ end
36
+
37
+ def create_configuration(ship_id:, package:)
38
+ post_json("/api/v1/cli/ships/#{ship_id}/configurations", { configuration: { package: package } })
39
+ end
40
+
41
+ def create_deploy(ship_id:)
42
+ post_json("/api/v1/cli/ships/#{ship_id}/deploys", {})
43
+ end
44
+
45
+ def latest_deploy(ship_id:)
46
+ get_json("/api/v1/cli/ships/#{ship_id}/deploys/latest")
47
+ end
48
+
49
+ private
50
+ def get_json(path)
51
+ parse_response(connection.get(path))
52
+ end
53
+
54
+ def post_json(path, payload)
55
+ parse_response(connection.post(path) do |request|
56
+ request.body = JSON.dump(payload)
57
+ request.headers["Content-Type"] = "application/json"
58
+ end)
59
+ end
60
+
61
+ def parse_response(response)
62
+ case response.status
63
+ when 200..299
64
+ if response.body.to_s.empty?
65
+ {}
66
+ else
67
+ JSON.parse(response.body)
68
+ end
69
+ when 401
70
+ raise UnauthorizedError, "Unauthorized"
71
+ when 403
72
+ raise PermissionDeniedError, "Forbidden"
73
+ when 404
74
+ raise NotFoundError, "Not found"
75
+ else
76
+ raise Error, "Request failed (#{response.status}): #{response.body}"
77
+ end
78
+ end
79
+
80
+ def connection
81
+ @connection ||= Faraday.new(url: @host) do |faraday|
82
+ faraday.request :retry, max: 2, interval: 0.1
83
+ faraday.adapter Faraday.default_adapter
84
+ faraday.headers["Authorization"] = "Bearer #{@token}"
85
+ faraday.headers["Accept"] = "application/json"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,700 @@
1
+ require "thor"
2
+
3
+ module Omaship
4
+ class CLI < Thor
5
+ map "new" => :new_ship
6
+ map "status" => :info
7
+ map "ship" => :info
8
+
9
+ class_option :host, type: :string, desc: "API host"
10
+
11
+ def self.exit_on_failure?
12
+ true
13
+ end
14
+
15
+ def self.start(given_args = ARGV, config = {})
16
+ normalized_args = normalize_help_args(given_args)
17
+ super(normalized_args, config)
18
+ end
19
+
20
+ def self.normalize_help_args(given_args)
21
+ args = given_args.dup
22
+
23
+ if root_help_requested?(args)
24
+ []
25
+ else
26
+ help_flag_index = args.index("-h") || args.index("--help")
27
+ if help_flag_index
28
+ [ "help", args.first ]
29
+ else
30
+ args
31
+ end
32
+ end
33
+ end
34
+
35
+ def self.root_help_requested?(args)
36
+ args == [ "-h" ] || args == [ "--help" ] || args == [ "help" ]
37
+ end
38
+
39
+ def self.help(shell, subcommand = false)
40
+ if subcommand
41
+ super
42
+ else
43
+ shell.say "Usage:"
44
+ shell.say " omaship COMMAND [options]"
45
+ shell.say
46
+ shell.say "You must specify a command. The most common commands are:"
47
+ shell.say
48
+ shell.say " login Store API token credentials locally"
49
+ shell.say " whoami Show authenticated user"
50
+ shell.say " list List ships you can access"
51
+ shell.say " use Set default ship from `omaship list` (for example: `omaship use omaship/acme`)"
52
+ shell.say " info Show details for a ship (aliases: ship, status)"
53
+ shell.say " new Create and provision a new ship"
54
+ shell.say " configure Configure a ship (requires Full CLI access)"
55
+ shell.say " deploy Deploy a ship (requires Full CLI access)"
56
+ shell.say " complete Print shell completion script (bash, zsh, fish)"
57
+ shell.say " logout Remove local CLI credentials"
58
+ shell.say
59
+ shell.say "Global options:"
60
+ shell.say " --host=HOST API host"
61
+ shell.say
62
+ shell.say "All commands can be run with -h (or --help) for more information."
63
+ end
64
+ end
65
+
66
+ desc "login", "Store API token credentials locally"
67
+ method_option :token, type: :string, desc: "API token from omaship settings"
68
+ def login
69
+ token = options[:token] || ENV["OMASHIP_TOKEN"] || ask("API token:", echo: false)
70
+ if token.to_s.strip.empty?
71
+ raise Thor::Error, "Missing API token. Pass --token or set OMASHIP_TOKEN."
72
+ end
73
+
74
+ user_payload = build_api_client(token: token).authenticate.fetch("user")
75
+ credentials.write(
76
+ host: resolved_host,
77
+ token: token,
78
+ user: user_payload
79
+ )
80
+
81
+ say "Logged in as #{user_payload.fetch("email_address")}"
82
+ say "Credentials saved to #{credentials.path}"
83
+ rescue Omaship::ApiClient::UnauthorizedError
84
+ message = "Authentication failed. Check your API token. #{existing_credentials_status_message}"
85
+ raise Thor::Error, message
86
+ rescue Omaship::ApiClient::Error => error
87
+ raise Thor::Error, error.message
88
+ end
89
+
90
+ desc "logout", "Remove local CLI credentials"
91
+ def logout
92
+ credentials.clear
93
+ say "Logged out."
94
+ end
95
+
96
+ desc "whoami", "Show authenticated user"
97
+ def whoami
98
+ with_api_error_handling(command: :whoami) do
99
+ token = current_token
100
+ client = build_api_client(token: token)
101
+ auth_payload = client.authenticate
102
+ user_payload = auth_payload.fetch("user")
103
+ account_payload = auth_payload.fetch("account", {})
104
+ token_payload = auth_payload.fetch("token", {})
105
+ ship_count = account_payload.fetch("ship_count", 0).to_i
106
+ orgs = normalize_orgs(account_payload["orgs"])
107
+ access_level = token_payload.fetch("access_level", "unknown").to_s.strip
108
+ if access_level.empty?
109
+ access_level = "unknown"
110
+ end
111
+ default_ship_reference = credentials.default_ship.to_s.strip
112
+
113
+ say "User: #{user_payload.fetch("email_address")}"
114
+ say "Host: #{resolved_host}"
115
+ say "Access: #{access_level}"
116
+ say "Ships: #{ship_count}"
117
+ if ship_count.zero?
118
+ say "Orgs: none (no ships yet)"
119
+ elsif orgs.empty?
120
+ say "Orgs: unavailable"
121
+ else
122
+ say "Orgs: #{orgs.join(", ")}"
123
+ end
124
+
125
+ if default_ship_reference.empty?
126
+ say "Default Ship: -"
127
+ else
128
+ say "Default Ship: #{default_ship_reference}"
129
+ end
130
+ end
131
+ end
132
+
133
+ desc "list", "List ships you can access"
134
+ def list
135
+ with_api_error_handling(command: :list) do
136
+ token = current_token
137
+ ships = build_api_client(token: token).list_ships
138
+ default_ship_reference = credentials.default_ship.to_s.strip
139
+
140
+ if ships.empty?
141
+ say "No ships available yet."
142
+ else
143
+ say "Ships:"
144
+ ships.each do |ship|
145
+ marker = default_ship_match?(ship: ship, ship_reference: default_ship_reference) ? "*" : " "
146
+ app_url = ship["app_url"] || "-"
147
+ say "#{marker} #{ship.fetch("id")} #{ship.fetch("full_name")} #{ship.fetch("status")} #{app_url}"
148
+ end
149
+
150
+ if !default_ship_reference.empty?
151
+ say
152
+ say "* default ship: #{default_ship_reference}"
153
+ end
154
+ end
155
+ end
156
+ end
157
+
158
+ desc "use SHIP", "Set default ship from `omaship list`"
159
+ long_desc <<~DESC
160
+ Set the default ship used by commands when `--ship` is omitted.
161
+
162
+ Get available values with `omaship list`.
163
+ Preferred format is full ship name (`org/repo`), for example `omaship/acme`.
164
+ Numeric ship ids from `omaship list` also work.
165
+
166
+ Examples:
167
+ omaship use omaship/acme
168
+ omaship use 17
169
+ DESC
170
+ def use(ship_reference)
171
+ with_api_error_handling(command: :use) do
172
+ token = current_token
173
+ ship = find_ship_by_reference(token: token, ship_reference: ship_reference)
174
+ persist_default_ship(ship)
175
+
176
+ say "Default ship set to #{ship.fetch("full_name")}."
177
+ end
178
+ end
179
+
180
+ desc "info", "Show ship info"
181
+ method_option :ship, type: :string, desc: "Ship from `omaship list` (preferred: omaship/acme; id also works)"
182
+ def info
183
+ with_api_error_handling(command: :info) do
184
+ token = current_token
185
+ client = build_api_client(token: token)
186
+ resolved_ship = resolve_ship(token: token)
187
+ ship = client.ship(ship_id: resolved_ship.fetch("id")).fetch("ship")
188
+ deploy = client.latest_deploy(ship_id: ship.fetch("id")).fetch("deploy")
189
+ default_ship_reference = credentials.default_ship.to_s.strip
190
+
191
+ say "Ship: #{ship.fetch("full_name")}"
192
+ say "ID: #{ship.fetch("id")}"
193
+ say "Status: #{ship.fetch("status")}"
194
+ say "App URL: #{ship["app_url"] || "-"}"
195
+ say "Repo URL: #{ship["repo_url"] || "-"}"
196
+ say "Default Ship: #{default_ship_reference.empty? ? "-" : default_ship_reference}"
197
+ say "Last Deploy: #{deploy_status_summary(deploy)}"
198
+ if deploy["run_number"]
199
+ say "Run: ##{deploy.fetch("run_number")}"
200
+ end
201
+ if deploy["started_at"]
202
+ say "Started At: #{deploy.fetch("started_at")}"
203
+ end
204
+ end
205
+ end
206
+
207
+ desc "new NAME", "Create and provision a new ship"
208
+ method_option :domain, type: :string, desc: "Root domain (defaults to NAME.com)"
209
+ def new_ship(name)
210
+ with_api_error_handling(command: :new_ship) do
211
+ token = current_token
212
+ root_domain = options[:domain] || "#{name}.com"
213
+ payload = build_api_client(token: token).create_ship(root_domain: root_domain)
214
+ ship = payload.fetch("ship")
215
+
216
+ progress_renderer.step("Setting up your codebase")
217
+ final_ship = poll_until_terminal(ship_id: ship.fetch("id"), token: token)
218
+
219
+ if final_ship.fetch("status") == "live"
220
+ progress_renderer.step("Ready. Customers can sign up and pay at #{final_ship.fetch("root_domain")}")
221
+ else
222
+ message = final_ship["error_message"] || "Provisioning failed"
223
+ raise Thor::Error, message
224
+ end
225
+ end
226
+ end
227
+
228
+ desc "configure --payments PROVIDER", "Configure a ship"
229
+ method_option :payments, type: :string, required: true
230
+ method_option :ship, type: :string, desc: "Ship from `omaship list` (preferred: omaship/acme; id also works)"
231
+ def configure
232
+ with_api_error_handling(command: :configure) do
233
+ payments_provider = options[:payments].to_s.strip.downcase
234
+
235
+ token = current_token
236
+ ship = resolve_ship(token: token)
237
+ build_api_client(token: token).create_configuration(ship_id: ship.fetch("id"), package: payments_provider)
238
+
239
+ progress_renderer.step("Configuration queued.")
240
+
241
+ poll_until_configuration_complete(ship_id: ship.fetch("id"), token: token)
242
+ progress_renderer.step("Configuration applied.")
243
+ end
244
+ end
245
+
246
+ desc "deploy", "Deploy a ship (requires Full CLI access)"
247
+ method_option :ship, type: :string, desc: "Ship from `omaship list` (preferred: omaship/acme; id also works)"
248
+ def deploy
249
+ with_api_error_handling(command: :deploy) do
250
+ token = current_token
251
+ ship = resolve_ship_for_deploy(token: token)
252
+ ship_name = ship.fetch("full_name", "##{ship.fetch("id")}")
253
+
254
+ progress_renderer.step("Target ship: #{ship_name}.")
255
+
256
+ build_api_client(token: token).create_deploy(ship_id: ship.fetch("id"))
257
+ progress_renderer.step("Deploy requested.")
258
+
259
+ poll_until_deploy_finished(ship_id: ship.fetch("id"), token: token)
260
+ end
261
+ end
262
+
263
+ desc "complete SHELL", "Print shell completion script (bash, zsh, fish)"
264
+ long_desc <<~DESC
265
+ Print a shell completion script to stdout.
266
+
267
+ Supported shells:
268
+ bash
269
+ zsh
270
+ fish
271
+
272
+ Examples:
273
+ omaship complete bash
274
+ omaship complete zsh
275
+ omaship complete fish
276
+ DESC
277
+ def complete(shell_name)
278
+ normalized_shell = shell_name.to_s.strip.downcase
279
+ script = completion_script_for(shell: normalized_shell)
280
+ say script
281
+ end
282
+
283
+ private
284
+ def completion_script_for(shell:)
285
+ if shell == "bash"
286
+ bash_completion_script
287
+ elsif shell == "zsh"
288
+ zsh_completion_script
289
+ elsif shell == "fish"
290
+ fish_completion_script
291
+ else
292
+ raise Thor::Error, "Unsupported shell `#{shell}`. Supported shells: bash, zsh, fish."
293
+ end
294
+ end
295
+
296
+ def bash_completion_script
297
+ <<~BASH
298
+ _omaship() {
299
+ local cur prev command
300
+ COMPREPLY=()
301
+ cur="${COMP_WORDS[COMP_CWORD]}"
302
+ prev=""
303
+ command="${COMP_WORDS[1]}"
304
+
305
+ if [[ ${COMP_CWORD} -gt 0 ]]; then
306
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
307
+ fi
308
+
309
+ if [[ ${COMP_CWORD} -eq 1 ]]; then
310
+ COMPREPLY=( $(compgen -W "login whoami list use info status ship new configure deploy logout complete help" -- "${cur}") )
311
+ return 0
312
+ fi
313
+
314
+ if [[ "${prev}" == "complete" ]]; then
315
+ COMPREPLY=( $(compgen -W "bash zsh fish" -- "${cur}") )
316
+ return 0
317
+ fi
318
+
319
+ if [[ "${prev}" == "--payments" ]]; then
320
+ COMPREPLY=( $(compgen -W "stripe" -- "${cur}") )
321
+ return 0
322
+ fi
323
+
324
+ if [[ "${prev}" == "--ship" ]]; then
325
+ COMPREPLY=( $(compgen -W "$(omaship list 2>/dev/null | awk '/^[* ] [0-9]+ / { print $3 }')" -- "${cur}") )
326
+ return 0
327
+ fi
328
+
329
+ if [[ "${command}" == "use" && ${COMP_CWORD} -eq 2 ]]; then
330
+ COMPREPLY=( $(compgen -W "$(omaship list 2>/dev/null | awk '/^[* ] [0-9]+ / { print $3 }')" -- "${cur}") )
331
+ return 0
332
+ fi
333
+
334
+ case "${command}" in
335
+ login)
336
+ COMPREPLY=( $(compgen -W "--token --host -h --help" -- "${cur}") )
337
+ ;;
338
+ whoami|list|logout)
339
+ COMPREPLY=( $(compgen -W "--host -h --help" -- "${cur}") )
340
+ ;;
341
+ use)
342
+ COMPREPLY=( $(compgen -W "--host -h --help" -- "${cur}") )
343
+ ;;
344
+ info|status|ship|deploy)
345
+ COMPREPLY=( $(compgen -W "--ship --host -h --help" -- "${cur}") )
346
+ ;;
347
+ new)
348
+ COMPREPLY=( $(compgen -W "--domain --host -h --help" -- "${cur}") )
349
+ ;;
350
+ configure)
351
+ COMPREPLY=( $(compgen -W "--payments --ship --host -h --help" -- "${cur}") )
352
+ ;;
353
+ complete)
354
+ COMPREPLY=( $(compgen -W "bash zsh fish" -- "${cur}") )
355
+ ;;
356
+ *)
357
+ COMPREPLY=( $(compgen -W "--host -h --help" -- "${cur}") )
358
+ ;;
359
+ esac
360
+ }
361
+
362
+ complete -o default -F _omaship omaship
363
+ BASH
364
+ end
365
+
366
+ def zsh_completion_script
367
+ <<~ZSH
368
+ #compdef omaship
369
+
370
+ _omaship_ship_refs() {
371
+ local -a refs
372
+ refs=(${(f)"$(omaship list 2>/dev/null | awk '/^[* ] [0-9]+ / { print $3 }')"})
373
+ _describe 'ship' refs
374
+ }
375
+
376
+ _omaship() {
377
+ local context state line
378
+ local -a commands
379
+
380
+ commands=(
381
+ 'login:Store API token credentials locally'
382
+ 'whoami:Show authenticated user'
383
+ 'list:List ships you can access'
384
+ 'use:Set default ship from omaship list'
385
+ 'info:Show ship info'
386
+ 'status:Alias for info'
387
+ 'ship:Alias for info'
388
+ 'new:Create and provision a new ship'
389
+ 'configure:Configure a ship'
390
+ 'deploy:Deploy a ship'
391
+ 'logout:Remove local CLI credentials'
392
+ 'complete:Print shell completion script'
393
+ 'help:Describe available commands'
394
+ )
395
+
396
+ _arguments -C \
397
+ '--host[API host]:host:' \
398
+ '1:command:->command' \
399
+ '*::arg:->args'
400
+
401
+ case $state in
402
+ command)
403
+ _describe -t commands 'command' commands
404
+ ;;
405
+ args)
406
+ case $line[1] in
407
+ login)
408
+ _arguments '--token[API token from omaship settings]:token:' '--host[API host]:host:'
409
+ ;;
410
+ whoami|list|logout)
411
+ _arguments '--host[API host]:host:'
412
+ ;;
413
+ use)
414
+ _arguments '--host[API host]:host:' '1:ship reference:_omaship_ship_refs'
415
+ ;;
416
+ info|status|ship|deploy)
417
+ _arguments '--ship[Ship from omaship list]:ship:_omaship_ship_refs' '--host[API host]:host:'
418
+ ;;
419
+ new)
420
+ _arguments '--domain[Root domain]:domain:' '--host[API host]:host:' '1:name:'
421
+ ;;
422
+ configure)
423
+ _arguments '--payments[Payments provider]:provider:(stripe)' '--ship[Ship from omaship list]:ship:_omaship_ship_refs' '--host[API host]:host:'
424
+ ;;
425
+ complete)
426
+ _arguments '1:shell:(bash zsh fish)'
427
+ ;;
428
+ *)
429
+ _arguments '--host[API host]:host:'
430
+ ;;
431
+ esac
432
+ ;;
433
+ esac
434
+ }
435
+
436
+ compdef _omaship omaship
437
+ ZSH
438
+ end
439
+
440
+ def fish_completion_script
441
+ <<~FISH
442
+ function __fish_omaship_ship_refs
443
+ omaship list 2>/dev/null | awk '/^[* ] [0-9]+ / { print $3 }'
444
+ end
445
+
446
+ complete -c omaship -f
447
+ complete -c omaship -n '__fish_use_subcommand' -a 'login whoami list use info status ship new configure deploy logout complete help'
448
+ complete -c omaship -l host -d 'API host'
449
+
450
+ complete -c omaship -n '__fish_seen_subcommand_from login' -l token -d 'API token from omaship settings'
451
+ complete -c omaship -n '__fish_seen_subcommand_from new' -l domain -d 'Root domain'
452
+ complete -c omaship -n '__fish_seen_subcommand_from configure' -l payments -d 'Payments provider' -a 'stripe'
453
+ complete -c omaship -n '__fish_seen_subcommand_from configure' -l ship -d 'Ship from omaship list' -a '(__fish_omaship_ship_refs)'
454
+ complete -c omaship -n '__fish_seen_subcommand_from info status ship deploy' -l ship -d 'Ship from omaship list' -a '(__fish_omaship_ship_refs)'
455
+ complete -c omaship -n '__fish_seen_subcommand_from use' -f -a '(__fish_omaship_ship_refs)'
456
+ complete -c omaship -n '__fish_seen_subcommand_from complete' -f -a 'bash zsh fish'
457
+ FISH
458
+ end
459
+
460
+ def current_token
461
+ value = credentials.token
462
+ if value.to_s.strip.empty?
463
+ raise Thor::Error, "Not logged in. Run `omaship login` first."
464
+ end
465
+ value
466
+ end
467
+
468
+ def resolve_ship(token:)
469
+ requested_ship_reference = options[:ship].to_s.strip
470
+
471
+ if requested_ship_reference.empty?
472
+ resolve_ship_without_explicit_selection(token: token)
473
+ else
474
+ find_ship_by_reference(token: token, ship_reference: requested_ship_reference)
475
+ end
476
+ end
477
+
478
+ def resolve_ship_without_explicit_selection(token:)
479
+ ships = build_api_client(token: token).list_ships
480
+ default_ship_reference = credentials.default_ship.to_s.strip
481
+
482
+ if !default_ship_reference.empty?
483
+ default_ship = match_ship(ships: ships, ship_reference: default_ship_reference)
484
+
485
+ if default_ship
486
+ return default_ship
487
+ else
488
+ credentials.clear_default_ship
489
+ end
490
+ end
491
+
492
+ if ships.empty?
493
+ raise Thor::Error, no_ships_message
494
+ elsif ships.one?
495
+ ship = ships.first
496
+ persist_default_ship(ship)
497
+ ship
498
+ else
499
+ raise Thor::Error, multiple_ships_message
500
+ end
501
+ end
502
+
503
+ def find_ship_by_reference(token:, ship_reference:)
504
+ ships = build_api_client(token: token).list_ships
505
+ ship = match_ship(ships: ships, ship_reference: ship_reference)
506
+
507
+ if ship
508
+ ship
509
+ else
510
+ raise Thor::Error, "Unknown ship `#{ship_reference}`. Run `omaship list` and use the full ship name (for example: `omaship use omaship/acme`) or the numeric id."
511
+ end
512
+ end
513
+
514
+ def match_ship(ships:, ship_reference:)
515
+ ships.find do |ship|
516
+ default_ship_match?(ship: ship, ship_reference: ship_reference)
517
+ end
518
+ end
519
+
520
+ def default_ship_match?(ship:, ship_reference:)
521
+ ship.fetch("id").to_s == ship_reference || ship.fetch("full_name") == ship_reference
522
+ end
523
+
524
+ def persist_default_ship(ship)
525
+ credentials.write_default_ship(ship.fetch("full_name"))
526
+ end
527
+
528
+ def resolve_ship_for_deploy(token:)
529
+ resolve_ship(token: token)
530
+ rescue Thor::Error => error
531
+ if options[:ship].to_s.strip.empty? && selection_error_message?(error.message)
532
+ raise Thor::Error, "#{error.message} `omaship deploy` also requires a full-access token."
533
+ end
534
+
535
+ raise
536
+ end
537
+
538
+ def selection_error_message?(message)
539
+ message == no_ships_message || message == multiple_ships_message
540
+ end
541
+
542
+ def no_ships_message
543
+ "No ships available yet. Run `omaship new <name>` first."
544
+ end
545
+
546
+ def multiple_ships_message
547
+ "Multiple ships found. Run `omaship list` and `omaship use <org/repo>` (or a ship id)."
548
+ end
549
+
550
+ def with_api_error_handling(command:)
551
+ yield
552
+ rescue Omaship::ApiClient::UnauthorizedError
553
+ raise Thor::Error, "Authentication failed. Check your API token and run `omaship login` again."
554
+ rescue Omaship::ApiClient::PermissionDeniedError
555
+ raise Thor::Error, permission_denied_message(command: command)
556
+ rescue Omaship::ApiClient::Error => error
557
+ raise Thor::Error, error.message
558
+ end
559
+
560
+ def permission_denied_message(command:)
561
+ if command == :new_ship
562
+ "`omaship new` requires a full-access token. Create a token with Full CLI access and run `omaship login` again."
563
+ elsif command == :configure
564
+ "`omaship configure` requires a full-access token. Create a token with Full CLI access and run `omaship login` again."
565
+ elsif command == :deploy
566
+ "`omaship deploy` requires a full-access token. Create a token with Full CLI access and run `omaship login` again."
567
+ else
568
+ "Permission denied for this action. Check your token scopes and run `omaship login` again."
569
+ end
570
+ end
571
+
572
+ def build_api_client(token:)
573
+ Omaship::ApiClient.new(host: resolved_host, token: token)
574
+ end
575
+
576
+ def credentials
577
+ @credentials ||= Omaship::Credentials.new
578
+ end
579
+
580
+ def progress_renderer
581
+ @progress_renderer ||= Omaship::ProgressRenderer.new(out: $stdout)
582
+ end
583
+
584
+ def emit_logs(log_entries)
585
+ log_entries.each do |entry|
586
+ message = entry.fetch("message")
587
+ progress_renderer.step(message)
588
+ end
589
+ end
590
+
591
+ def poll_until_terminal(ship_id:, token:)
592
+ client = build_api_client(token: token)
593
+ last_log_id = 0
594
+ 90.times do
595
+ ship_payload = client.ship(ship_id: ship_id).fetch("ship")
596
+ logs_payload = client.ship_logs(ship_id: ship_id)
597
+ logs = logs_payload.fetch("logs").select { |entry| entry.fetch("id") > last_log_id }
598
+ if logs.any?
599
+ last_log_id = logs.last.fetch("id")
600
+ end
601
+
602
+ emit_logs(logs)
603
+ status = ship_payload.fetch("status")
604
+ if %w[live error].include?(status)
605
+ return ship_payload
606
+ end
607
+ sleep 2
608
+ end
609
+
610
+ raise Thor::Error, "Provisioning timed out."
611
+ end
612
+
613
+ def poll_until_configuration_complete(ship_id:, token:)
614
+ client = build_api_client(token: token)
615
+ 60.times do
616
+ ship_payload = client.ship(ship_id: ship_id).fetch("ship")
617
+ pending = ship_payload.fetch("package_update_pending")
618
+ if pending
619
+ progress_renderer.step("Applying configuration changes.")
620
+ sleep 2
621
+ else
622
+ error_message = ship_payload["error_message"].to_s.strip
623
+ if error_message.empty?
624
+ return
625
+ end
626
+
627
+ raise Thor::Error, error_message
628
+ end
629
+ end
630
+
631
+ raise Thor::Error, "Configuration timed out."
632
+ end
633
+
634
+ def poll_until_deploy_finished(ship_id:, token:)
635
+ client = build_api_client(token: token)
636
+ last_status = nil
637
+ 90.times do
638
+ deploy_payload = client.latest_deploy(ship_id: ship_id).fetch("deploy")
639
+ status = deploy_payload.fetch("status")
640
+ conclusion = deploy_payload["conclusion"]
641
+ if status != last_status
642
+ emit_deploy_status(status: status)
643
+ last_status = status
644
+ end
645
+
646
+ if status == "completed"
647
+ if conclusion == "success"
648
+ progress_renderer.step("Live. 34 seconds, zero downtime.")
649
+ return
650
+ end
651
+ raise Thor::Error, "Deploy failed with conclusion: #{conclusion || 'unknown'}"
652
+ end
653
+
654
+ sleep 2
655
+ end
656
+
657
+ raise Thor::Error, "Deploy timed out."
658
+ end
659
+
660
+ def deploy_status_summary(deploy_payload)
661
+ status = deploy_payload.fetch("status")
662
+ conclusion = deploy_payload["conclusion"].to_s.strip
663
+
664
+ if conclusion.empty?
665
+ status
666
+ else
667
+ "#{status} (#{conclusion})"
668
+ end
669
+ end
670
+
671
+ def emit_deploy_status(status:)
672
+ if status == "not_found"
673
+ progress_renderer.step("Waiting for deploy workflow run.")
674
+ elsif %w[queued requested waiting pending].include?(status)
675
+ progress_renderer.step("Deploy queued.")
676
+ elsif status == "in_progress"
677
+ progress_renderer.step("Deploy in progress.")
678
+ elsif status != "completed"
679
+ progress_renderer.step("Deploy status: #{status}.")
680
+ end
681
+ end
682
+
683
+ def resolved_host
684
+ options[:host] || credentials.host || ENV["OMASHIP_HOST"] || "http://localhost:3000"
685
+ end
686
+
687
+ def existing_credentials_status_message
688
+ saved_email = credentials.read.dig("user", "email_address").to_s.strip
689
+ if saved_email.empty?
690
+ "No local credentials were changed."
691
+ else
692
+ "Existing login kept for #{saved_email}."
693
+ end
694
+ end
695
+
696
+ def normalize_orgs(orgs)
697
+ Array(orgs).map(&:to_s).map(&:strip).reject(&:empty?).uniq.sort
698
+ end
699
+ end
700
+ end
@@ -0,0 +1,78 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ module Omaship
5
+ class Credentials
6
+ DEFAULT_PATH = File.join(Dir.home, ".config", "omaship", "credentials.json")
7
+
8
+ def initialize(path: DEFAULT_PATH)
9
+ @path = path
10
+ end
11
+
12
+ attr_reader :path
13
+
14
+ def write(host:, token:, user:)
15
+ payload = read
16
+ payload["version"] = 1
17
+ payload["host"] = host
18
+ payload["token"] = token
19
+ payload["user"] = user
20
+
21
+ write_payload(payload)
22
+ end
23
+
24
+ def read
25
+ return {} unless File.exist?(@path)
26
+
27
+ JSON.parse(File.read(@path))
28
+ rescue JSON::ParserError
29
+ {}
30
+ end
31
+
32
+ def token
33
+ read["token"]
34
+ end
35
+
36
+ def host
37
+ read["host"]
38
+ end
39
+
40
+ def default_ship
41
+ read["default_ship"]
42
+ end
43
+
44
+ def write_default_ship(ship_reference)
45
+ payload = read
46
+ payload["version"] = 1
47
+ payload["default_ship"] = ship_reference
48
+
49
+ write_payload(payload)
50
+ end
51
+
52
+ def clear_default_ship
53
+ payload = read
54
+ payload.delete("default_ship")
55
+ write_payload(payload)
56
+ end
57
+
58
+ def clear
59
+ File.delete(@path) if File.exist?(@path)
60
+ end
61
+
62
+ private
63
+ def write_payload(payload)
64
+ ensure_parent_directory
65
+ tmp_path = "#{@path}.tmp"
66
+ File.write(tmp_path, JSON.pretty_generate(payload) + "\n")
67
+ File.chmod(0o600, tmp_path)
68
+ FileUtils.mv(tmp_path, @path)
69
+ File.chmod(0o600, @path)
70
+ end
71
+
72
+ def ensure_parent_directory
73
+ directory = File.dirname(@path)
74
+ FileUtils.mkdir_p(directory)
75
+ File.chmod(0o700, directory)
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,15 @@
1
+ module Omaship
2
+ class ProgressRenderer
3
+ def initialize(out:)
4
+ @out = out
5
+ @seen = {}
6
+ end
7
+
8
+ def step(message)
9
+ return if @seen[message]
10
+
11
+ @seen[message] = true
12
+ @out.puts message
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,47 @@
1
+ require "open3"
2
+
3
+ module Omaship
4
+ class ShipDetector
5
+ class DetectionError < StandardError; end
6
+ SHIP_CONTEXT_ERROR_MESSAGE = "Could not determine which ship to use automatically. Pass --ship <org/repo> (or ship id) to choose a ship.".freeze
7
+
8
+ def initialize(api_client:)
9
+ @api_client = api_client
10
+ end
11
+
12
+ def detect_current_ship
13
+ remote = git_remote_url
14
+ full_name = parse_full_name(remote)
15
+ ships = @api_client.list_ships
16
+ match = ships.find { |ship| ship.fetch("full_name") == full_name }
17
+
18
+ if match
19
+ match
20
+ else
21
+ raise DetectionError, SHIP_CONTEXT_ERROR_MESSAGE
22
+ end
23
+ end
24
+
25
+ private
26
+ def git_remote_url
27
+ stdout, stderr, status = Open3.capture3("git", "remote", "get-url", "origin")
28
+ unless status.success?
29
+ raise DetectionError, SHIP_CONTEXT_ERROR_MESSAGE
30
+ end
31
+
32
+ stdout.strip
33
+ end
34
+
35
+ def parse_full_name(remote_url)
36
+ if remote_url.match?(/\Agit@github\.com:/)
37
+ remote_url.sub(/\Agit@github\.com:/, "").sub(/\.git\z/, "")
38
+ elsif remote_url.match?(/\Ahttps:\/\/github\.com\//)
39
+ remote_url.sub(/\Ahttps:\/\/github\.com\//, "").sub(/\.git\z/, "")
40
+ elsif remote_url.match?(/\Ahttps:\/\/[^@]+@github\.com\//)
41
+ remote_url.sub(/\Ahttps:\/\/[^@]+@github\.com\//, "").sub(/\.git\z/, "")
42
+ else
43
+ raise DetectionError, SHIP_CONTEXT_ERROR_MESSAGE
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,3 @@
1
+ module Omaship
2
+ VERSION = "0.2.1".freeze
3
+ end
data/lib/omaship.rb ADDED
@@ -0,0 +1,9 @@
1
+ module Omaship
2
+ end
3
+
4
+ require_relative "omaship/version"
5
+ require_relative "omaship/api_client"
6
+ require_relative "omaship/credentials"
7
+ require_relative "omaship/progress_renderer"
8
+ require_relative "omaship/ship_detector"
9
+ require_relative "omaship/cli"
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: omaship
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Omaship
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday-retry
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: thor
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ description: CLI for login, ship provisioning, configuration, and deploy operations
55
+ against Omaship.
56
+ email:
57
+ - hello@omaship.com
58
+ executables:
59
+ - omaship
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - CHANGELOG.md
64
+ - README.md
65
+ - bin/omaship
66
+ - lib/omaship.rb
67
+ - lib/omaship/api_client.rb
68
+ - lib/omaship/cli.rb
69
+ - lib/omaship/credentials.rb
70
+ - lib/omaship/progress_renderer.rb
71
+ - lib/omaship/ship_detector.rb
72
+ - lib/omaship/version.rb
73
+ homepage: https://omaship.com
74
+ licenses:
75
+ - Nonstandard
76
+ metadata:
77
+ homepage_uri: https://omaship.com
78
+ source_code_uri: https://github.com/bloomedai/omaship
79
+ changelog_uri: https://github.com/bloomedai/omaship/blob/main/cli/CHANGELOG.md
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 4.0.0
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 4.0.3
95
+ specification_version: 4
96
+ summary: Omaship command-line interface
97
+ test_files: []