omaship 0.4.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,8 +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
- app_url = ship["app_url"] || "-"
147
- say "#{marker} #{ship.fetch("id")} #{ship.fetch("full_name")} #{ship.fetch("status")} #{app_url}"
169
+ say ship_list_line(ship:, marker:)
148
170
  end
149
171
 
150
172
  if !default_ship_reference.empty?
@@ -160,11 +182,11 @@ module Omaship
160
182
  Set the default ship used by commands when `--ship` is omitted.
161
183
 
162
184
  Get available values with `omaship list`.
163
- Preferred format is full ship name (`org/repo`), for example `omaship/acme`.
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)
@@ -173,67 +195,67 @@ module Omaship
173
195
  ship = find_ship_by_reference(token: token, ship_reference: ship_reference)
174
196
  persist_default_ship(ship)
175
197
 
176
- say "Default ship set to #{ship.fetch("full_name")}."
198
+ say "Default ship set to #{ship.fetch("reference")}."
177
199
  end
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; 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
185
207
  client = build_api_client(token: token)
186
208
  resolved_ship = resolve_ship(token: token)
187
209
  ship = client.ship(ship_id: resolved_ship.fetch("id")).fetch("ship")
188
- deploy = client.latest_deploy(ship_id: ship.fetch("id")).fetch("deploy")
189
210
  default_ship_reference = credentials.default_ship.to_s.strip
211
+ latest_voyage = latest_voyage_summary_for(ship:)
190
212
 
191
- say "Ship: #{ship.fetch("full_name")}"
213
+ say "Ship: #{ship.fetch("display_name")}"
192
214
  say "ID: #{ship.fetch("id")}"
193
215
  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")}"
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}"
200
224
  end
201
- if deploy["started_at"]
202
- say "Started At: #{deploy.fetch("started_at")}"
225
+ say "Default Ship: #{default_ship_reference.empty? ? "-" : default_ship_reference}"
226
+
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)}"
231
+ end
203
232
  end
204
233
  end
205
234
  end
206
235
 
207
236
  desc "new NAME", "Create and provision a new ship"
208
- method_option :domain, type: :string, desc: "Root domain (defaults to NAME.com)"
209
- method_option :skip_purpose, type: :boolean, default: false, desc: "Skip purpose profile questions"
237
+ method_option :domain, type: :string, desc: "Custom root domain"
210
238
  def new_ship(name)
211
239
  with_api_error_handling(command: :new_ship) do
212
240
  token = current_token
213
- plan = fetch_plan(token: token)
241
+ auth = fetch_auth(token: token)
242
+ allowed_starter_kinds = allowed_starter_kinds_from_auth(auth)
214
243
 
215
- if plan == "free"
216
- create_free_ship(name: name, token: token)
217
- else
218
- 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."
219
246
  end
220
- end
221
- end
222
247
 
223
- desc "deploy", "Deploy a ship (requires Full CLI access)"
224
- method_option :ship, type: :string, desc: "Ship from `omaship list` (preferred: omaship/acme; id also works)"
225
- def deploy
226
- with_api_error_handling(command: :deploy) do
227
- token = current_token
228
- ship = resolve_ship_for_deploy(token: token)
229
- ship_name = ship.fetch("full_name", "##{ship.fetch("id")}")
230
-
231
- progress_renderer.step("Target ship: #{ship_name}.")
232
-
233
- build_api_client(token: token).create_deploy(ship_id: ship.fetch("id"))
234
- 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
235
252
 
236
- 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
237
259
  end
238
260
  end
239
261
 
@@ -295,7 +317,7 @@ module Omaship
295
317
  fi
296
318
 
297
319
  if [[ ${COMP_CWORD} -eq 1 ]]; then
298
- 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}") )
299
321
  return 0
300
322
  fi
301
323
 
@@ -324,11 +346,11 @@ module Omaship
324
346
  use)
325
347
  COMPREPLY=( $(compgen -W "--host -h --help" -- "${cur}") )
326
348
  ;;
327
- info|status|ship|deploy)
349
+ info|status|ship)
328
350
  COMPREPLY=( $(compgen -W "--ship --host -h --help" -- "${cur}") )
329
351
  ;;
330
352
  new)
331
- COMPREPLY=( $(compgen -W "--domain --skip-purpose --host -h --help" -- "${cur}") )
353
+ COMPREPLY=( $(compgen -W "--domain --host -h --help" -- "${cur}") )
332
354
  ;;
333
355
  complete)
334
356
  COMPREPLY=( $(compgen -W "bash zsh fish" -- "${cur}") )
@@ -366,7 +388,6 @@ module Omaship
366
388
  'status:Alias for info'
367
389
  'ship:Alias for info'
368
390
  'new:Create and provision a new ship'
369
- 'deploy:Deploy a ship'
370
391
  'upgrade:Open browser to upgrade your plan'
371
392
  'logout:Remove local CLI credentials'
372
393
  'complete:Print shell completion script'
@@ -393,11 +414,11 @@ module Omaship
393
414
  use)
394
415
  _arguments '--host[API host]:host:' '1:ship reference:_omaship_ship_refs'
395
416
  ;;
396
- info|status|ship|deploy)
417
+ info|status|ship)
397
418
  _arguments '--ship[Ship from omaship list]:ship:_omaship_ship_refs' '--host[API host]:host:'
398
419
  ;;
399
420
  new)
400
- _arguments '--domain[Root domain]:domain:' '--skip-purpose[Skip purpose questions]' '--host[API host]:host:' '1:name:'
421
+ _arguments '--domain[Root domain]:domain:' '--host[API host]:host:' '1:name:'
401
422
  ;;
402
423
  complete)
403
424
  _arguments '1:shell:(bash zsh fish)'
@@ -421,30 +442,88 @@ module Omaship
421
442
  end
422
443
 
423
444
  complete -c omaship -f
424
- complete -c omaship -n '__fish_use_subcommand' -a 'login whoami list use info status ship new deploy upgrade logout complete help'
425
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'
426
458
 
427
459
  complete -c omaship -n '__fish_seen_subcommand_from login' -l token -d 'API token from omaship settings'
428
460
  complete -c omaship -n '__fish_seen_subcommand_from new' -l domain -d 'Root domain'
429
- complete -c omaship -n '__fish_seen_subcommand_from new' -l skip-purpose -d 'Skip purpose questions'
430
- 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)'
431
462
  complete -c omaship -n '__fish_seen_subcommand_from use' -f -a '(__fish_omaship_ship_refs)'
432
463
  complete -c omaship -n '__fish_seen_subcommand_from complete' -f -a 'bash zsh fish'
433
464
  FISH
434
465
  end
435
466
 
436
- def fetch_plan(token:)
437
- auth = build_api_client(token: token).authenticate
438
- auth.dig("user", "plan") || "free"
467
+ def fetch_auth(token:)
468
+ build_api_client(token: token).authenticate
469
+ end
470
+
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
439
502
  end
440
503
 
441
- def create_paid_ship(name:, token:)
442
- root_domain = options[:domain] || "#{name}.com"
443
- payload = build_api_client(token: token).create_ship(root_domain: root_domain)
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
+ )
444
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
+ )
445
521
 
446
- progress_renderer.step("Setting up your codebase")
447
- 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
448
527
 
449
528
  if final_ship.fetch("status") == "live"
450
529
  progress_renderer.step("Ready. Customers can sign up and pay at #{final_ship.fetch("root_domain")}")
@@ -454,23 +533,48 @@ module Omaship
454
533
  end
455
534
  end
456
535
 
457
- def create_free_ship(name:, token:)
458
- 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
459
543
 
460
- purpose_profile = {}
461
- color_scheme = "mono-dark"
544
+ def choose_harbor_id(auth:)
545
+ ready_harbors = ready_harbors_from_auth(auth)
462
546
 
463
- unless options[:skip_purpose]
464
- if ask_purpose_profile?
465
- purpose_profile = collect_purpose_profile
466
- end
467
- color_scheme = pick_color_scheme
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."
468
549
  end
469
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
557
+
558
+ def create_paid_landingpage(name:, token:)
559
+ create_landingpage_ship(name: name, token: token, show_free_banner: false)
560
+ end
561
+
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
569
+ end
570
+
571
+ purpose_profile = collect_purpose_profile
572
+ color_scheme = pick_color_scheme
573
+
470
574
  say
471
575
  progress_renderer.step("Creating your landing page...")
472
576
 
473
- payload = build_api_client(token: token).create_landing_page(
577
+ payload = build_api_client(token: token).create_landingpage(
474
578
  name: name,
475
579
  color_scheme: color_scheme,
476
580
  purpose_profile: purpose_profile
@@ -483,8 +587,8 @@ module Omaship
483
587
  end
484
588
 
485
589
  say
486
- say "Live at: https://#{ship.fetch("root_domain")}"
487
- 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)}"
488
592
  end
489
593
 
490
594
  def print_free_banner
@@ -509,24 +613,19 @@ module Omaship
509
613
  say
510
614
  end
511
615
 
512
- def ask_purpose_profile?
513
- say "Would you like to create a purpose profile?"
514
- say "This helps generate better landing page copy. (5 questions, ~2 min)"
515
- say
516
- answer = ask("Continue with purpose profile? (Y/n):")
517
- answer.to_s.strip.downcase != "n"
518
- end
519
-
520
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)"
521
620
  say
522
621
  say "--- Purpose Profile ---"
523
622
  say
524
623
 
525
- raw_problem = ask("What problem does your product solve?\n>")
526
- raw_audience = ask("Who is most affected by this problem?\n>")
527
- raw_belief = ask("Why is this worth solving? What's your core belief?\n>")
528
- raw_contribution = ask("What's your contribution to the solution?\n>")
529
- 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?")
530
629
 
531
630
  {
532
631
  raw_problem: raw_problem,
@@ -545,10 +644,64 @@ module Omaship
545
644
  selected
546
645
  end
547
646
 
647
+ def ask_question(question)
648
+ say question
649
+ ask("> ")
650
+ end
651
+
548
652
  def color_picker
549
653
  @color_picker ||= Omaship::ColorPicker.new
550
654
  end
551
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
+
552
705
  def poll_until_distilled(ship_id:, token:)
553
706
  client = build_api_client(token: token)
554
707
  30.times do
@@ -584,7 +737,7 @@ module Omaship
584
737
 
585
738
  def resolve_ship_without_explicit_selection(token:)
586
739
  ships = build_api_client(token: token).list_ships
587
- default_ship_reference = credentials.default_ship.to_s.strip
740
+ default_ship_reference = current_default_ship_reference(token: token, ships: ships)
588
741
 
589
742
  if !default_ship_reference.empty?
590
743
  default_ship = match_ship(ships: ships, ship_reference: default_ship_reference)
@@ -614,7 +767,7 @@ module Omaship
614
767
  if ship
615
768
  ship
616
769
  else
617
- 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."
770
+ raise Thor::Error, "Unknown ship `#{ship_reference}`. Run `omaship list` and use the ship domain from the list, or the numeric id."
618
771
  end
619
772
  end
620
773
 
@@ -625,25 +778,13 @@ module Omaship
625
778
  end
626
779
 
627
780
  def default_ship_match?(ship:, ship_reference:)
628
- ship.fetch("id").to_s == ship_reference || ship.fetch("full_name") == ship_reference
781
+ ship.fetch("id").to_s == ship_reference ||
782
+ ship.fetch("reference") == ship_reference ||
783
+ ship["full_name"] == ship_reference
629
784
  end
630
785
 
631
786
  def persist_default_ship(ship)
632
- credentials.write_default_ship(ship.fetch("full_name"))
633
- end
634
-
635
- def resolve_ship_for_deploy(token:)
636
- resolve_ship(token: token)
637
- rescue Thor::Error => error
638
- if options[:ship].to_s.strip.empty? && selection_error_message?(error.message)
639
- raise Thor::Error, "#{error.message} `omaship deploy` also requires a full-access token."
640
- end
641
-
642
- raise
643
- end
644
-
645
- def selection_error_message?(message)
646
- message == no_ships_message || message == multiple_ships_message
787
+ credentials.write_default_ship(ship.fetch("reference"))
647
788
  end
648
789
 
649
790
  def no_ships_message
@@ -651,7 +792,7 @@ module Omaship
651
792
  end
652
793
 
653
794
  def multiple_ships_message
654
- "Multiple ships found. Run `omaship list` and `omaship use <org/repo>` (or a ship id)."
795
+ "Multiple ships found. Run `omaship list` and `omaship use <ship-from-list>` (or a ship id)."
655
796
  end
656
797
 
657
798
  def with_api_error_handling(command:)
@@ -665,15 +806,24 @@ module Omaship
665
806
  end
666
807
 
667
808
  def permission_denied_message(command:)
668
- if command == :new_ship
809
+ if current_access_level == "onboarding"
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."
811
+ elsif command == :new_ship
669
812
  "`omaship new` requires a full-access token. Create a token with Full CLI access and run `omaship login` again."
670
- elsif command == :deploy
671
- "`omaship deploy` requires a full-access token. Create a token with Full CLI access and run `omaship login` again."
672
813
  else
673
814
  "Permission denied for this action. Check your token scopes and run `omaship login` again."
674
815
  end
675
816
  end
676
817
 
818
+ def current_access_level
819
+ token = credentials.token
820
+ return "unknown" if token.to_s.strip.empty?
821
+
822
+ build_api_client(token: token).authenticate.dig("token", "access_level").to_s
823
+ rescue NoMethodError, Omaship::ApiClient::Error
824
+ "unknown"
825
+ end
826
+
677
827
  def build_api_client(token:)
678
828
  Omaship::ApiClient.new(host: resolved_host, token: token)
679
829
  end
@@ -690,8 +840,9 @@ module Omaship
690
840
  client = build_api_client(token: token)
691
841
  90.times do
692
842
  ship_payload = client.ship(ship_id: ship_id).fetch("ship")
843
+ update_provisioning_activity(client: client, ship_payload: ship_payload)
693
844
  status = ship_payload.fetch("status")
694
- if %w[live error].include?(status)
845
+ if %w[live error deploy_failed].include?(status)
695
846
  return ship_payload
696
847
  end
697
848
  sleep 2
@@ -700,53 +851,71 @@ module Omaship
700
851
  raise Thor::Error, "Provisioning timed out."
701
852
  end
702
853
 
703
- def poll_until_deploy_finished(ship_id:, token:)
704
- client = build_api_client(token: token)
705
- last_status = nil
706
- 90.times do
707
- deploy_payload = client.latest_deploy(ship_id: ship_id).fetch("deploy")
708
- status = deploy_payload.fetch("status")
709
- conclusion = deploy_payload["conclusion"]
710
- if status != last_status
711
- emit_deploy_status(status: status)
712
- last_status = status
713
- 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?
714
858
 
715
- if status == "completed"
716
- if conclusion == "success"
717
- progress_renderer.step("Live. 34 seconds, zero downtime.")
718
- return
719
- end
720
- raise Thor::Error, "Deploy failed with conclusion: #{conclusion || 'unknown'}"
721
- 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
722
866
 
723
- sleep 2
724
- end
867
+ def provisioning_message_for(voyage_payload:)
868
+ step = current_voyage_step(voyage_payload:)
725
869
 
726
- 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
727
877
  end
728
878
 
729
- def deploy_status_summary(deploy_payload)
730
- status = deploy_payload.fetch("status")
731
- conclusion = deploy_payload["conclusion"].to_s.strip
879
+ def provisioning_eta_for(voyage_payload:)
880
+ step = current_voyage_step(voyage_payload:)
732
881
 
733
- if conclusion.empty?
734
- status
882
+ if step
883
+ PROVISION_STEP_ETAS.fetch(step.fetch("step_key"), DEFAULT_PROVISIONING_ETA)
735
884
  else
736
- "#{status} (#{conclusion})"
885
+ DEFAULT_PROVISIONING_ETA
737
886
  end
738
887
  end
739
888
 
740
- def emit_deploy_status(status:)
741
- if status == "not_found"
742
- progress_renderer.step("Waiting for deploy workflow run.")
743
- elsif %w[queued requested waiting pending].include?(status)
744
- progress_renderer.step("Deploy queued.")
745
- elsif status == "in_progress"
746
- progress_renderer.step("Deploy in progress.")
747
- elsif status != "completed"
748
- progress_renderer.step("Deploy status: #{status}.")
749
- 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}"
750
919
  end
751
920
 
752
921
  def open_browser(url)
@@ -769,8 +938,75 @@ module Omaship
769
938
  end
770
939
  end
771
940
 
772
- def normalize_orgs(orgs)
773
- 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
+ }
774
1010
  end
775
1011
  end
776
1012
  end