omaship 0.5.0 → 0.6.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.
data/lib/omaship/cli.rb CHANGED
@@ -1,7 +1,31 @@
1
1
  require "thor"
2
+ require "securerandom"
3
+ require "uri"
2
4
 
3
5
  module Omaship
4
6
  class CLI < Thor
7
+ PROVISION_STEP_MESSAGES = {
8
+ "resolve_source" => "Preparing your ship run",
9
+ "prepare_server" => "Preparing your harbor",
10
+ "scaffold_app" => "Scaffolding your ship",
11
+ "deploy_app" => "Deploying your ship",
12
+ "create_tunnel" => "Configuring private access",
13
+ "cutover_dns" => "Wiring up your ship routes",
14
+ "lockdown_firewall" => "Locking down the harbor",
15
+ "healthcheck" => "Running final health checks"
16
+ }.freeze
17
+ PROVISION_STEP_ETAS = {
18
+ "resolve_source" => "about 2 min",
19
+ "prepare_server" => "about 2 min",
20
+ "scaffold_app" => "about 90 sec",
21
+ "deploy_app" => "about 90 sec",
22
+ "create_tunnel" => "about 45 sec",
23
+ "cutover_dns" => "about 30 sec",
24
+ "lockdown_firewall" => "about 20 sec",
25
+ "healthcheck" => "less than 15 sec"
26
+ }.freeze
27
+ DEFAULT_PROVISIONING_ETA = "about 2 min"
28
+
5
29
  map "new" => :new_ship
6
30
  map "status" => :info
7
31
  map "ship" => :info
@@ -13,8 +37,16 @@ module Omaship
13
37
  end
14
38
 
15
39
  def self.start(given_args = ARGV, config = {})
40
+ if root_version_requested?(given_args)
41
+ $stdout.puts Omaship::VERSION
42
+ return
43
+ end
44
+
16
45
  normalized_args = normalize_help_args(given_args)
17
46
  super(normalized_args, config)
47
+ rescue Interrupt
48
+ $stderr.puts "\nAborted."
49
+ exit(130)
18
50
  end
19
51
 
20
52
  def self.normalize_help_args(given_args)
@@ -36,6 +68,10 @@ module Omaship
36
68
  args == [ "-h" ] || args == [ "--help" ] || args == [ "help" ]
37
69
  end
38
70
 
71
+ def self.root_version_requested?(args)
72
+ args == [ "-v" ] || args == [ "--version" ]
73
+ end
74
+
39
75
  def self.help(shell, subcommand = false)
40
76
  if subcommand
41
77
  super
@@ -48,10 +84,9 @@ module Omaship
48
84
  shell.say " login Store API token credentials locally"
49
85
  shell.say " whoami Show authenticated user"
50
86
  shell.say " list List ships you can access"
51
- shell.say " use Set default ship from `omaship list` (for example: `omaship use omaship/acme`)"
87
+ shell.say " use Set default ship from `omaship list` (for example: `omaship use acme.omaship.app`)"
52
88
  shell.say " info Show details for a ship (aliases: ship, status)"
53
89
  shell.say " new Create and provision a new ship"
54
- shell.say " deploy Deploy a ship (requires Full CLI access)"
55
90
  shell.say " upgrade Open browser to upgrade your plan"
56
91
  shell.say " complete Print shell completion script (bash, zsh, fish)"
57
92
  shell.say " logout Remove local CLI credentials"
@@ -101,26 +136,14 @@ module Omaship
101
136
  auth_payload = client.authenticate
102
137
  user_payload = auth_payload.fetch("user")
103
138
  account_payload = auth_payload.fetch("account", {})
104
- token_payload = auth_payload.fetch("token", {})
105
139
  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
140
+ plan = normalized_plan_label(plan: user_payload["plan"], admin: user_payload["admin"])
141
+ default_ship_reference = current_default_ship_reference(token: token)
112
142
 
113
143
  say "User: #{user_payload.fetch("email_address")}"
114
144
  say "Host: #{resolved_host}"
115
- say "Access: #{access_level}"
145
+ say "Plan: #{plan}"
116
146
  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
147
 
125
148
  if default_ship_reference.empty?
126
149
  say "Default Ship: -"
@@ -135,7 +158,7 @@ module Omaship
135
158
  with_api_error_handling(command: :list) do
136
159
  token = current_token
137
160
  ships = build_api_client(token: token).list_ships
138
- default_ship_reference = credentials.default_ship.to_s.strip
161
+ default_ship_reference = current_default_ship_reference(token: token, ships: ships)
139
162
 
140
163
  if ships.empty?
141
164
  say "No ships available yet."
@@ -143,7 +166,7 @@ module Omaship
143
166
  say "Ships:"
144
167
  ships.each do |ship|
145
168
  marker = default_ship_match?(ship: ship, ship_reference: default_ship_reference) ? "*" : " "
146
- say "#{marker} #{ship.fetch("id")} #{ship.fetch("display_name")} #{ship.fetch("status")} #{ship.fetch("primary_url", "-") || "-"}"
169
+ say ship_list_line(ship:, marker:)
147
170
  end
148
171
 
149
172
  if !default_ship_reference.empty?
@@ -159,12 +182,11 @@ module Omaship
159
182
  Set the default ship used by commands when `--ship` is omitted.
160
183
 
161
184
  Get available values with `omaship list`.
162
- Preferred format is full ship name (`org/repo`), for example `omaship/acme`.
163
- Landing pages can also be selected by root domain, for example `acme.omaship.app`.
185
+ Use the ship domain from `omaship list`, for example `acme.omaship.app`.
164
186
  Numeric ship ids from `omaship list` also work.
165
187
 
166
188
  Examples:
167
- omaship use omaship/acme
189
+ omaship use acme.omaship.app
168
190
  omaship use 17
169
191
  DESC
170
192
  def use(ship_reference)
@@ -178,7 +200,7 @@ module Omaship
178
200
  end
179
201
 
180
202
  desc "info", "Show ship info"
181
- method_option :ship, type: :string, desc: "Ship from `omaship list` (preferred: omaship/acme or acme.omaship.app; id also works)"
203
+ method_option :ship, type: :string, desc: "Ship from `omaship list` (use the ship domain or id)"
182
204
  def info
183
205
  with_api_error_handling(command: :info) do
184
206
  token = current_token
@@ -186,59 +208,54 @@ module Omaship
186
208
  resolved_ship = resolve_ship(token: token)
187
209
  ship = client.ship(ship_id: resolved_ship.fetch("id")).fetch("ship")
188
210
  default_ship_reference = credentials.default_ship.to_s.strip
211
+ latest_voyage = latest_voyage_summary_for(ship:)
189
212
 
190
213
  say "Ship: #{ship.fetch("display_name")}"
191
214
  say "ID: #{ship.fetch("id")}"
192
215
  say "Status: #{ship.fetch("status")}"
193
- say "App URL: #{ship["app_url"] || "-"}"
194
- say "Landing URL: #{ship["landing_url"] || "-"}"
195
- say "Repo URL: #{ship["repo_url"] || "-"}"
216
+ say "Domain: #{ship["root_domain"] || "-"}"
217
+ app_url = ship["app_url"].to_s.strip
218
+ if !app_url.empty?
219
+ say "App: #{app_url}"
220
+ end
221
+ api_url = ship["api_url"].to_s.strip
222
+ if !api_url.empty?
223
+ say "API: #{api_url}"
224
+ end
196
225
  say "Default Ship: #{default_ship_reference.empty? ? "-" : default_ship_reference}"
197
226
 
198
- if ship.fetch("deployable")
199
- deploy = client.latest_deploy(ship_id: ship.fetch("id")).fetch("deploy")
200
-
201
- say "Last Deploy: #{deploy_status_summary(deploy)}"
202
- if deploy["run_number"]
203
- say "Run: ##{deploy.fetch("run_number")}"
204
- end
205
- if deploy["started_at"]
206
- say "Started At: #{deploy.fetch("started_at")}"
227
+ if latest_voyage
228
+ say "Latest Voyage: #{latest_voyage.fetch(:kind)} (#{latest_voyage.fetch(:status)})"
229
+ if latest_voyage[:id]
230
+ say "Voyage ID: ##{latest_voyage.fetch(:id)}"
207
231
  end
208
232
  end
209
233
  end
210
234
  end
211
235
 
212
236
  desc "new NAME", "Create and provision a new ship"
213
- method_option :domain, type: :string, desc: "Root domain (defaults to NAME.com)"
237
+ method_option :domain, type: :string, desc: "Custom root domain"
214
238
  def new_ship(name)
215
239
  with_api_error_handling(command: :new_ship) do
216
240
  token = current_token
217
241
  auth = fetch_auth(token: token)
218
- plan = auth.dig("user", "plan") || "free"
242
+ allowed_starter_kinds = allowed_starter_kinds_from_auth(auth)
219
243
 
220
- if plan == "free"
221
- create_free_ship(name: name, token: token)
222
- else
223
- create_paid_ship(name: name, token: token)
244
+ unless can_create_ship_from_auth?(auth)
245
+ raise Thor::Error, "Ship limit reached for your plan. Run `omaship upgrade` to create another ship."
224
246
  end
225
- end
226
- end
227
247
 
228
- desc "deploy", "Deploy a ship (requires Full CLI access)"
229
- method_option :ship, type: :string, desc: "Ship from `omaship list` (preferred: omaship/acme or acme.omaship.app; id also works)"
230
- def deploy
231
- with_api_error_handling(command: :deploy) do
232
- token = current_token
233
- ship = resolve_ship_for_deploy(token: token)
234
- ship_name = ship.fetch("full_name", "##{ship.fetch("id")}")
235
-
236
- progress_renderer.step("Target ship: #{ship_name}.")
237
-
238
- build_api_client(token: token).create_deploy(ship_id: ship.fetch("id"))
239
- progress_renderer.step("Deploy requested.")
248
+ if allowed_starter_kinds == [ "landing" ]
249
+ create_free_ship(name: name, token: token)
250
+ else
251
+ starter_kind = choose_paid_starter_kind
240
252
 
241
- poll_until_deploy_finished(ship_id: ship.fetch("id"), token: token)
253
+ if starter_kind == "landing"
254
+ create_paid_landingpage(name: name, token: token)
255
+ else
256
+ create_paid_ship(name: name, token: token, starter_kind: starter_kind, auth: auth)
257
+ end
258
+ end
242
259
  end
243
260
  end
244
261
 
@@ -300,7 +317,7 @@ module Omaship
300
317
  fi
301
318
 
302
319
  if [[ ${COMP_CWORD} -eq 1 ]]; then
303
- COMPREPLY=( $(compgen -W "login whoami list use info status ship new deploy upgrade logout complete help" -- "${cur}") )
320
+ COMPREPLY=( $(compgen -W "login whoami list use info status ship new upgrade logout complete help" -- "${cur}") )
304
321
  return 0
305
322
  fi
306
323
 
@@ -329,7 +346,7 @@ module Omaship
329
346
  use)
330
347
  COMPREPLY=( $(compgen -W "--host -h --help" -- "${cur}") )
331
348
  ;;
332
- info|status|ship|deploy)
349
+ info|status|ship)
333
350
  COMPREPLY=( $(compgen -W "--ship --host -h --help" -- "${cur}") )
334
351
  ;;
335
352
  new)
@@ -371,7 +388,6 @@ module Omaship
371
388
  'status:Alias for info'
372
389
  'ship:Alias for info'
373
390
  'new:Create and provision a new ship'
374
- 'deploy:Deploy a ship'
375
391
  'upgrade:Open browser to upgrade your plan'
376
392
  'logout:Remove local CLI credentials'
377
393
  'complete:Print shell completion script'
@@ -398,7 +414,7 @@ module Omaship
398
414
  use)
399
415
  _arguments '--host[API host]:host:' '1:ship reference:_omaship_ship_refs'
400
416
  ;;
401
- info|status|ship|deploy)
417
+ info|status|ship)
402
418
  _arguments '--ship[Ship from omaship list]:ship:_omaship_ship_refs' '--host[API host]:host:'
403
419
  ;;
404
420
  new)
@@ -426,12 +442,23 @@ module Omaship
426
442
  end
427
443
 
428
444
  complete -c omaship -f
429
- complete -c omaship -n '__fish_use_subcommand' -a 'login whoami list use info status ship new deploy upgrade logout complete help'
430
445
  complete -c omaship -l host -d 'API host'
446
+ complete -c omaship -n '__fish_use_subcommand' -a 'login' -d 'Store API token credentials locally'
447
+ complete -c omaship -n '__fish_use_subcommand' -a 'whoami' -d 'Show authenticated user'
448
+ complete -c omaship -n '__fish_use_subcommand' -a 'list' -d 'List ships you can access'
449
+ complete -c omaship -n '__fish_use_subcommand' -a 'use' -d 'Set default ship from omaship list'
450
+ complete -c omaship -n '__fish_use_subcommand' -a 'info' -d 'Show ship info'
451
+ complete -c omaship -n '__fish_use_subcommand' -a 'status' -d 'Alias for info'
452
+ complete -c omaship -n '__fish_use_subcommand' -a 'ship' -d 'Alias for info'
453
+ complete -c omaship -n '__fish_use_subcommand' -a 'new' -d 'Create and provision a new ship'
454
+ complete -c omaship -n '__fish_use_subcommand' -a 'upgrade' -d 'Open browser to upgrade your plan'
455
+ complete -c omaship -n '__fish_use_subcommand' -a 'logout' -d 'Remove local CLI credentials'
456
+ complete -c omaship -n '__fish_use_subcommand' -a 'complete' -d 'Print shell completion script'
457
+ complete -c omaship -n '__fish_use_subcommand' -a 'help' -d 'Describe available commands'
431
458
 
432
459
  complete -c omaship -n '__fish_seen_subcommand_from login' -l token -d 'API token from omaship settings'
433
460
  complete -c omaship -n '__fish_seen_subcommand_from new' -l domain -d 'Root domain'
434
- complete -c omaship -n '__fish_seen_subcommand_from info status ship deploy' -l ship -d 'Ship from omaship list' -a '(__fish_omaship_ship_refs)'
461
+ complete -c omaship -n '__fish_seen_subcommand_from info status ship' -l ship -d 'Ship from omaship list' -a '(__fish_omaship_ship_refs)'
435
462
  complete -c omaship -n '__fish_seen_subcommand_from use' -f -a '(__fish_omaship_ship_refs)'
436
463
  complete -c omaship -n '__fish_seen_subcommand_from complete' -f -a 'bash zsh fish'
437
464
  FISH
@@ -441,13 +468,62 @@ module Omaship
441
468
  build_api_client(token: token).authenticate
442
469
  end
443
470
 
444
- def create_paid_ship(name:, token:)
445
- root_domain = options[:domain] || "#{name}.com"
446
- payload = build_api_client(token: token).create_ship(root_domain: root_domain)
471
+ def can_create_ship_from_auth?(auth)
472
+ can_create_ship = auth.dig("account", "can_create_ship")
473
+ return can_create_ship unless can_create_ship.nil?
474
+
475
+ legacy_plan = auth.dig("user", "plan").to_s
476
+ return false if legacy_plan.empty?
477
+
478
+ if legacy_plan == "free"
479
+ auth.dig("account", "ship_count").to_i < 1
480
+ else
481
+ true
482
+ end
483
+ end
484
+
485
+ def allowed_starter_kinds_from_auth(auth)
486
+ allowed_starter_kinds = Array(auth.dig("account", "allowed_starter_kinds"))
487
+ .map(&:to_s)
488
+ .reject(&:empty?)
489
+ .uniq
490
+
491
+ return allowed_starter_kinds if allowed_starter_kinds.any?
492
+
493
+ legacy_plan = auth.dig("user", "plan").to_s
494
+
495
+ if legacy_plan == "free"
496
+ [ "landing" ]
497
+ elsif legacy_plan.empty?
498
+ []
499
+ else
500
+ %w[landing app api]
501
+ end
502
+ end
503
+
504
+ def create_paid_ship(name:, token:, starter_kind:, auth:)
505
+ identifier_suffix = generated_identifier_suffix
506
+ root_domain = requested_root_domain
507
+ harbor_id = choose_harbor_id(auth: auth)
508
+ payload = build_api_client(token: token).create_ship(
509
+ name: name,
510
+ harbor_id: harbor_id,
511
+ root_domain: root_domain,
512
+ bring_own_domain: root_domain.empty? ? "0" : "1",
513
+ identifier_suffix: identifier_suffix,
514
+ starter_kind: starter_kind
515
+ )
447
516
  ship = payload.fetch("ship")
517
+ progress_renderer.start_activity(
518
+ "Setting up #{ship_display_name(ship, fallback: name, suffix: identifier_suffix)}",
519
+ eta: DEFAULT_PROVISIONING_ETA
520
+ )
448
521
 
449
- progress_renderer.step("Setting up your codebase")
450
- final_ship = poll_until_terminal(ship_id: ship.fetch("id"), token: token)
522
+ begin
523
+ final_ship = poll_until_terminal(ship_id: ship.fetch("id"), token: token)
524
+ ensure
525
+ progress_renderer.finish_activity
526
+ end
451
527
 
452
528
  if final_ship.fetch("status") == "live"
453
529
  progress_renderer.step("Ready. Customers can sign up and pay at #{final_ship.fetch("root_domain")}")
@@ -457,15 +533,42 @@ module Omaship
457
533
  end
458
534
  end
459
535
 
460
- def create_free_ship(name:, token:)
461
- print_free_banner
536
+ def choose_paid_starter_kind
537
+ say
538
+ say "--- Ship Type ---"
539
+ selected = ship_type_picker.pick
540
+ say "Selected: #{Omaship::ShipTypePicker.label_for(selected)}"
541
+ selected
542
+ end
543
+
544
+ def choose_harbor_id(auth:)
545
+ ready_harbors = ready_harbors_from_auth(auth)
546
+
547
+ if ready_harbors.empty?
548
+ raise Thor::Error, "No ready harbor available. Provision a managed harbor in Omaship before launching a full app or API."
549
+ end
550
+
551
+ say
552
+ say "--- Harbor ---"
553
+ selected = harbor_picker(ready_harbors).pick
554
+ say "Selected harbor: #{harbor_label(ready_harbors, selected)}"
555
+ selected
556
+ end
462
557
 
463
- purpose_profile = {}
464
- color_scheme = "mono-dark"
558
+ def create_paid_landingpage(name:, token:)
559
+ create_landingpage_ship(name: name, token: token, show_free_banner: false)
560
+ end
465
561
 
466
- if ask_purpose_profile?
467
- purpose_profile = collect_purpose_profile
562
+ def create_free_ship(name:, token:)
563
+ create_landingpage_ship(name: name, token: token, show_free_banner: true)
564
+ end
565
+
566
+ def create_landingpage_ship(name:, token:, show_free_banner:)
567
+ if show_free_banner
568
+ print_free_banner
468
569
  end
570
+
571
+ purpose_profile = collect_purpose_profile
469
572
  color_scheme = pick_color_scheme
470
573
 
471
574
  say
@@ -484,8 +587,8 @@ module Omaship
484
587
  end
485
588
 
486
589
  say
487
- say "Live at: https://#{ship.fetch("root_domain")}"
488
- say "Review and refine at: #{resolved_host}/ships/#{ship.fetch("id")}/purpose_profile"
590
+ say "Live at: #{landingpage_url(ship)}"
591
+ say "Review and refine at: #{ship_dashboard_url(ship)}"
489
592
  end
490
593
 
491
594
  def print_free_banner
@@ -510,24 +613,19 @@ module Omaship
510
613
  say
511
614
  end
512
615
 
513
- def ask_purpose_profile?
514
- say "Would you like to create a purpose profile?"
515
- say "This helps generate better landing page copy. (5 questions, ~2 min)"
516
- say
517
- answer = ask("Continue with purpose profile? (Y/n):")
518
- answer.to_s.strip.downcase != "n"
519
- end
520
-
521
616
  def collect_purpose_profile
617
+ say
618
+ say "We'll start with a purpose profile."
619
+ say "This helps generate better landing page copy. (5 questions, ~2 min)"
522
620
  say
523
621
  say "--- Purpose Profile ---"
524
622
  say
525
623
 
526
- raw_problem = ask("What problem does your product solve?\n>")
527
- raw_audience = ask("Who is most affected by this problem?\n>")
528
- raw_belief = ask("Why is this worth solving? What's your core belief?\n>")
529
- raw_contribution = ask("What's your contribution to the solution?\n>")
530
- raw_outcome = ask("What does the world look like when you succeed?\n>")
624
+ raw_problem = ask_question("What problem does your product solve?")
625
+ raw_audience = ask_question("Who is most affected by this problem?")
626
+ raw_belief = ask_question("Why is this worth solving? What's your core belief?")
627
+ raw_contribution = ask_question("What's your contribution to the solution?")
628
+ raw_outcome = ask_question("What does the world look like when you succeed?")
531
629
 
532
630
  {
533
631
  raw_problem: raw_problem,
@@ -546,10 +644,64 @@ module Omaship
546
644
  selected
547
645
  end
548
646
 
647
+ def ask_question(question)
648
+ say question
649
+ ask("> ")
650
+ end
651
+
549
652
  def color_picker
550
653
  @color_picker ||= Omaship::ColorPicker.new
551
654
  end
552
655
 
656
+ def ship_type_picker
657
+ @ship_type_picker ||= Omaship::ShipTypePicker.new
658
+ end
659
+
660
+ def harbor_picker(harbors)
661
+ Omaship::HarborPicker.new(harbors: harbors)
662
+ end
663
+
664
+ def ready_harbors_from_auth(auth)
665
+ Array(auth.dig("account", "ready_harbors")).map do |harbor|
666
+ {
667
+ "id" => harbor["id"] || harbor[:id],
668
+ "name" => harbor["name"] || harbor[:name],
669
+ "status" => harbor["status"] || harbor[:status],
670
+ "management" => harbor["management"] || harbor[:management]
671
+ }
672
+ end.select { |harbor| harbor["id"] && harbor["name"].to_s.strip != "" }
673
+ end
674
+
675
+ def harbor_label(harbors, harbor_id)
676
+ harbor = harbors.find { |entry| entry["id"].to_s == harbor_id.to_s }
677
+ if harbor
678
+ harbor["name"].to_s
679
+ else
680
+ harbor_id.to_s
681
+ end
682
+ end
683
+
684
+ def landingpage_url(ship)
685
+ root_domain = ship.fetch("root_domain").to_s.strip
686
+ return "" if root_domain.empty?
687
+
688
+ base_url = URI.parse(resolved_host)
689
+ scheme = base_url.scheme.to_s.strip
690
+ scheme = "https" if scheme.empty?
691
+
692
+ port = base_url.port
693
+ default_port = scheme == "https" ? 443 : 80
694
+ port_suffix = port == default_port ? "" : ":#{port}"
695
+
696
+ "#{scheme}://#{root_domain}#{port_suffix}/"
697
+ rescue URI::InvalidURIError
698
+ "https://#{root_domain}/"
699
+ end
700
+
701
+ def ship_dashboard_url(ship)
702
+ "#{resolved_host.to_s.sub(%r{/*$}, "")}/ships/#{ship.fetch("id")}"
703
+ end
704
+
553
705
  def poll_until_distilled(ship_id:, token:)
554
706
  client = build_api_client(token: token)
555
707
  30.times do
@@ -585,7 +737,7 @@ module Omaship
585
737
 
586
738
  def resolve_ship_without_explicit_selection(token:)
587
739
  ships = build_api_client(token: token).list_ships
588
- default_ship_reference = credentials.default_ship.to_s.strip
740
+ default_ship_reference = current_default_ship_reference(token: token, ships: ships)
589
741
 
590
742
  if !default_ship_reference.empty?
591
743
  default_ship = match_ship(ships: ships, ship_reference: default_ship_reference)
@@ -615,7 +767,7 @@ module Omaship
615
767
  if ship
616
768
  ship
617
769
  else
618
- raise Thor::Error, "Unknown ship `#{ship_reference}`. Run `omaship list` and use a ship name or root domain from the list, or the numeric id."
770
+ raise Thor::Error, "Unknown ship `#{ship_reference}`. Run `omaship list` and use the ship domain from the list, or the numeric id."
619
771
  end
620
772
  end
621
773
 
@@ -626,27 +778,15 @@ module Omaship
626
778
  end
627
779
 
628
780
  def default_ship_match?(ship:, ship_reference:)
629
- ship.fetch("id").to_s == ship_reference || ship.fetch("reference") == ship_reference
781
+ ship.fetch("id").to_s == ship_reference ||
782
+ ship.fetch("reference") == ship_reference ||
783
+ ship["full_name"] == ship_reference
630
784
  end
631
785
 
632
786
  def persist_default_ship(ship)
633
787
  credentials.write_default_ship(ship.fetch("reference"))
634
788
  end
635
789
 
636
- def resolve_ship_for_deploy(token:)
637
- resolve_ship(token: token)
638
- rescue Thor::Error => error
639
- if options[:ship].to_s.strip.empty? && selection_error_message?(error.message)
640
- raise Thor::Error, "#{error.message} `omaship deploy` also requires a full-access token."
641
- end
642
-
643
- raise
644
- end
645
-
646
- def selection_error_message?(message)
647
- message == no_ships_message || message == multiple_ships_message
648
- end
649
-
650
790
  def no_ships_message
651
791
  "No ships available yet. Run `omaship new <name>` first."
652
792
  end
@@ -670,8 +810,6 @@ module Omaship
670
810
  "This onboarding token only supports `omaship new` while you create your first ship. Create a full-access token in Settings after onboarding to use other CLI commands."
671
811
  elsif command == :new_ship
672
812
  "`omaship new` requires a full-access token. Create a token with Full CLI access and run `omaship login` again."
673
- elsif command == :deploy
674
- "`omaship deploy` requires a full-access token. Create a token with Full CLI access and run `omaship login` again."
675
813
  else
676
814
  "Permission denied for this action. Check your token scopes and run `omaship login` again."
677
815
  end
@@ -702,8 +840,9 @@ module Omaship
702
840
  client = build_api_client(token: token)
703
841
  90.times do
704
842
  ship_payload = client.ship(ship_id: ship_id).fetch("ship")
843
+ update_provisioning_activity(client: client, ship_payload: ship_payload)
705
844
  status = ship_payload.fetch("status")
706
- if %w[live error].include?(status)
845
+ if %w[live error deploy_failed].include?(status)
707
846
  return ship_payload
708
847
  end
709
848
  sleep 2
@@ -712,53 +851,71 @@ module Omaship
712
851
  raise Thor::Error, "Provisioning timed out."
713
852
  end
714
853
 
715
- def poll_until_deploy_finished(ship_id:, token:)
716
- client = build_api_client(token: token)
717
- last_status = nil
718
- 90.times do
719
- deploy_payload = client.latest_deploy(ship_id: ship_id).fetch("deploy")
720
- status = deploy_payload.fetch("status")
721
- conclusion = deploy_payload["conclusion"]
722
- if status != last_status
723
- emit_deploy_status(status: status)
724
- last_status = status
725
- end
854
+ def update_provisioning_activity(client:, ship_payload:)
855
+ latest_voyage = ship_payload["latest_voyage"]
856
+ voyage_id = latest_voyage && latest_voyage["id"]
857
+ return if voyage_id.to_s.strip.empty?
726
858
 
727
- if status == "completed"
728
- if conclusion == "success"
729
- progress_renderer.step("Live. 34 seconds, zero downtime.")
730
- return
731
- end
732
- raise Thor::Error, "Deploy failed with conclusion: #{conclusion || 'unknown'}"
733
- end
859
+ voyage_payload = client.voyage(voyage_id: voyage_id).fetch("voyage")
860
+ progress_renderer.update_activity(
861
+ message: provisioning_message_for(voyage_payload: voyage_payload),
862
+ eta: provisioning_eta_for(voyage_payload: voyage_payload)
863
+ )
864
+ rescue Omaship::ApiClient::Error
865
+ end
734
866
 
735
- sleep 2
736
- end
867
+ def provisioning_message_for(voyage_payload:)
868
+ step = current_voyage_step(voyage_payload:)
737
869
 
738
- raise Thor::Error, "Deploy timed out."
870
+ if step
871
+ PROVISION_STEP_MESSAGES.fetch(step.fetch("step_key"), "Setting up your codebase")
872
+ elsif voyage_payload["status"] == "queued"
873
+ "Queueing your provisioning run"
874
+ else
875
+ "Setting up your codebase"
876
+ end
739
877
  end
740
878
 
741
- def deploy_status_summary(deploy_payload)
742
- status = deploy_payload.fetch("status")
743
- conclusion = deploy_payload["conclusion"].to_s.strip
879
+ def provisioning_eta_for(voyage_payload:)
880
+ step = current_voyage_step(voyage_payload:)
744
881
 
745
- if conclusion.empty?
746
- status
882
+ if step
883
+ PROVISION_STEP_ETAS.fetch(step.fetch("step_key"), DEFAULT_PROVISIONING_ETA)
747
884
  else
748
- "#{status} (#{conclusion})"
885
+ DEFAULT_PROVISIONING_ETA
749
886
  end
750
887
  end
751
888
 
752
- def emit_deploy_status(status:)
753
- if status == "not_found"
754
- progress_renderer.step("Waiting for deploy workflow run.")
755
- elsif %w[queued requested waiting pending].include?(status)
756
- progress_renderer.step("Deploy queued.")
757
- elsif status == "in_progress"
758
- progress_renderer.step("Deploy in progress.")
759
- elsif status != "completed"
760
- progress_renderer.step("Deploy status: #{status}.")
761
- end
889
+ def current_voyage_step(voyage_payload:)
890
+ steps = Array(voyage_payload["steps"])
891
+ running_step = steps.reverse.find { |step| step["status"] == "running" }
892
+ return running_step if running_step
893
+
894
+ queued_step = steps.reverse.find { |step| step["status"] == "queued" }
895
+ return queued_step if queued_step
896
+
897
+ steps.last
898
+ end
899
+
900
+ def requested_root_domain
901
+ options[:domain].to_s.strip.downcase
902
+ end
903
+
904
+ def generated_identifier_suffix
905
+ SecureRandom.hex(2)
906
+ end
907
+
908
+ def ship_display_name(ship, fallback:, suffix:)
909
+ display_name = ship["display_name"].to_s.strip
910
+ return display_name unless display_name.empty?
911
+
912
+ base_name = ship["name"].to_s.strip
913
+ return base_name unless base_name.empty?
914
+
915
+ normalized_fallback = fallback.to_s.parameterize
916
+ return normalized_fallback if normalized_fallback.end_with?("-#{suffix}")
917
+
918
+ "#{normalized_fallback}-#{suffix}"
762
919
  end
763
920
 
764
921
  def open_browser(url)
@@ -781,8 +938,75 @@ module Omaship
781
938
  end
782
939
  end
783
940
 
784
- def normalize_orgs(orgs)
785
- Array(orgs).map(&:to_s).map(&:strip).reject(&:empty?).uniq.sort
941
+ def current_default_ship_reference(token:, ships: nil)
942
+ default_ship_reference = credentials.default_ship.to_s.strip
943
+ return "" if default_ship_reference.empty?
944
+
945
+ if ships
946
+ normalize_default_ship_reference(default_ship_reference:, ships:)
947
+ elsif legacy_default_ship_reference?(default_ship_reference)
948
+ normalize_default_ship_reference(
949
+ default_ship_reference:,
950
+ ships: build_api_client(token: token).list_ships
951
+ )
952
+ else
953
+ default_ship_reference
954
+ end
955
+ end
956
+
957
+ def normalize_default_ship_reference(default_ship_reference:, ships:)
958
+ ship = match_ship(ships:, ship_reference: default_ship_reference)
959
+
960
+ if ship
961
+ normalized_reference = ship.fetch("reference")
962
+
963
+ if normalized_reference != default_ship_reference
964
+ credentials.write_default_ship(normalized_reference)
965
+ end
966
+
967
+ normalized_reference
968
+ else
969
+ credentials.clear_default_ship
970
+ ""
971
+ end
972
+ end
973
+
974
+ def legacy_default_ship_reference?(default_ship_reference)
975
+ default_ship_reference.include?("/")
976
+ end
977
+
978
+ def normalized_plan_label(plan:, admin: false)
979
+ return "Master" if admin
980
+
981
+ value = plan.to_s.strip
982
+
983
+ if value.empty?
984
+ "Unknown"
985
+ else
986
+ value.split(/[_-]/).map(&:capitalize).join(" ")
987
+ end
988
+ end
989
+
990
+ def ship_list_line(ship:, marker:)
991
+ line = "#{marker} #{ship.fetch("id")} #{ship.fetch("reference")}"
992
+ display_name = ship.fetch("display_name").to_s.strip
993
+
994
+ if !display_name.empty? && display_name != ship.fetch("reference")
995
+ line = "#{line} #{display_name}"
996
+ end
997
+
998
+ "#{line} #{ship.fetch("status")} #{ship.fetch("primary_url", "-") || "-"}"
999
+ end
1000
+
1001
+ def latest_voyage_summary_for(ship:)
1002
+ voyage = ship["latest_voyage"]
1003
+ return unless voyage
1004
+
1005
+ {
1006
+ id: voyage["id"],
1007
+ kind: voyage["kind"],
1008
+ status: voyage["status"]
1009
+ }
786
1010
  end
787
1011
  end
788
1012
  end