deployangel 0.1.25 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 05e4cde0e2b605e294d51c73f25af3a2b5d9496c6a8e0b3270c358a9a5b9e4ec
4
- data.tar.gz: 722541542ff63d2cb8eedb0020a974a71ecf4eaaff3338e50e1002bc88e7ebbd
3
+ metadata.gz: 66a47a8fb6c86749fb8d29333b9f9f01c7d0c6512dfc7bea7c93fd7191d231a2
4
+ data.tar.gz: 93c324ea15c7ddc1e361d82c922a9543c9d15e23511206f93586e6fa1c7b9c4c
5
5
  SHA512:
6
- metadata.gz: c9674f14d5c5f09540e2fe9a102fd1e16571c6492214a47b70a155bb6c70c98af1e333c58734b8eca2079aab323aca6090a47bf8b964111a59e6734dcee316d3
7
- data.tar.gz: 71fad66b1e6f4d36b1794e8f403b05524a0b151a2b4232067e39f3f7341c80d6673b28362215bdf2250ef179640124249c397ec366c85556a9eae5f9d87ce10a
6
+ metadata.gz: 0ba0355966761d97fc73b3c64eab89f448114c23de7192555913c49d97de57e6349de81eb1fb9994a1a850e170313e8cbed39249fe00d8f3045ba88ea6840fe0
7
+ data.tar.gz: 8d9ca49ffbd98f4feb88e10b94887f171b7bd877e77ff36396ae716defa016ccc29ebc23452475f894f67d149ad3f78421f867ace1644faff8660f7006db2763
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.26 (2026-10-11)
4
+
5
+ - `deployangel setup --code=CODE` redeems a one-time code from "Set up with
6
+ your coding agent" and saves the app's token and a "CI deploys" token to
7
+ `.deployangel/credentials.json` (readable only by you, kept out of git),
8
+ without printing either, so tokens stay out of coding agents' chats. The
9
+ command and MCP server use the saved token when `DEPLOYANGEL_API_TOKEN`
10
+ isn't set. `deployangel token app|api` prints a saved token for piping into
11
+ a host's or CI's secrets.
12
+
3
13
  ## 0.1.25 (2026-10-10)
4
14
 
5
15
  - `verify` prints what a clearance doesn't cover right under it: app-wide
data/README.md CHANGED
@@ -430,6 +430,20 @@ with the `verifications:read` scope (plus `deployments` to register deploys or
430
430
  report checks). Never use the production telemetry token on a developer
431
431
  machine.
432
432
 
433
+ To let a coding agent set DeployAngel up, use "Set up with your coding agent"
434
+ on the app's page in DeployAngel: it gives a one-time setup code, not tokens.
435
+ `deployangel setup --code=CODE` redeems it and saves the app's token and a "CI
436
+ deploys" token to `.deployangel/credentials.json`, readable only by you and
437
+ kept out of git, without printing either. The command and MCP server then use
438
+ the saved token whenever `DEPLOYANGEL_API_TOKEN` isn't set. `deployangel token
439
+ app` (or `api`) prints a saved token for piping into a host's or CI's
440
+ secrets, so it never appears on screen or in an agent's conversation:
441
+
442
+ ```bash
443
+ fly secrets set DEPLOYANGEL_TOKEN=$(bundle exec deployangel token app)
444
+ bundle exec deployangel token api | gh secret set DEPLOYANGEL_API_TOKEN
445
+ ```
446
+
433
447
  ```bash
434
448
  export DEPLOYANGEL_API_TOKEN=da_live_...
435
449
 
@@ -8,6 +8,7 @@ require "optparse"
8
8
  require_relative "version"
9
9
  require_relative "configuration"
10
10
  require_relative "client"
11
+ require_relative "credentials"
11
12
  require_relative "ci_environment"
12
13
  require_relative "verification_waiter"
13
14
  require_relative "cli/formatter"
@@ -19,7 +20,7 @@ module DeployAngel
19
20
  # Runs without booting Rails.
20
21
  class CLI
21
22
  USAGE_ERROR = 5
22
- COMMANDS = %w[release verify status plan exercise exception check install doctor mcp version help].freeze
23
+ COMMANDS = %w[release verify status plan exercise exception check install doctor setup token mcp version help].freeze
23
24
 
24
25
  HELP = <<~TEXT
25
26
  Usage: deployangel <command> [options]
@@ -42,13 +43,18 @@ module DeployAngel
42
43
  or set up coding agents deployangel install agents
43
44
  doctor Check the setup: this checkout [--format=text|json]
44
45
  and what DeployAngel has seen
46
+ setup Redeem a one-time setup code deployangel setup --code=CODE
47
+ and save the app's tokens here, without printing them
48
+ token Print a saved token, to pipe deployangel token app|api
49
+ into a host's or CI's secrets
45
50
  mcp Run the MCP server over stdio (for coding agents)
46
51
 
47
52
  With no target, verify, plan, and check use the current git HEAD commit.
48
53
  In GitHub Actions, GitLab CI, CircleCI, and Buildkite, release fills in the
49
54
  commit, a build label (e.g. run-123), and a link to the run automatically.
50
55
  In a Kamal hook, it registers Kamal's release (KAMAL_VERSION).
51
- Environment: DEPLOYANGEL_API_TOKEN (required), DEPLOYANGEL_URL (default #{Configuration::DEFAULT_ENDPOINT})
56
+ Token: DEPLOYANGEL_API_TOKEN, else the one `deployangel setup` saved in .deployangel/.
57
+ Environment: DEPLOYANGEL_URL (default #{Configuration::DEFAULT_ENDPOINT})
52
58
 
53
59
  Exit codes: 0 verified, 1 failed, 2 inconclusive, 3 still in progress / timed out,
54
60
  4 deployment not found, 5 usage, auth, or network error,
@@ -153,6 +159,8 @@ module DeployAngel
153
159
  when "check" then check
154
160
  when "install" then install
155
161
  when "doctor" then doctor
162
+ when "setup" then setup
163
+ when "token" then token
156
164
  when "mcp" then mcp
157
165
  when "version", "--version", "-v" then @stdout.puts(DeployAngel::VERSION) || 0
158
166
  when nil, "help", "--help", "-h" then @stdout.puts(HELP) || 0
@@ -354,8 +362,9 @@ module DeployAngel
354
362
  def doctor_client
355
363
  return @client if @client
356
364
 
357
- token = [ @env["DEPLOYANGEL_API_TOKEN"], @env["DEPLOYANGEL_TOKEN"] ].find { |value| !value.to_s.empty? } or return
358
- Client.new(token: token, endpoint: @env["DEPLOYANGEL_URL"] || Configuration::DEFAULT_ENDPOINT)
365
+ token = [ @env["DEPLOYANGEL_API_TOKEN"], @env["DEPLOYANGEL_TOKEN"], saved_credentials["api_token"] ]
366
+ .find { |value| !value.to_s.empty? } or return
367
+ Client.new(token: token, endpoint: endpoint)
359
368
  end
360
369
 
361
370
  def install
@@ -576,9 +585,63 @@ module DeployAngel
576
585
  nil
577
586
  end
578
587
 
588
+ # DEPLOYANGEL_API_TOKEN, else the token `deployangel setup` saved here.
579
589
  def client
580
- @client ||= Client.new(token: @env["DEPLOYANGEL_API_TOKEN"],
581
- endpoint: @env["DEPLOYANGEL_URL"] || Configuration::DEFAULT_ENDPOINT)
590
+ token = @env["DEPLOYANGEL_API_TOKEN"].to_s.empty? ? saved_credentials["api_token"] : @env["DEPLOYANGEL_API_TOKEN"]
591
+ @client ||= Client.new(token: token, endpoint: endpoint)
592
+ end
593
+
594
+ def endpoint
595
+ @env["DEPLOYANGEL_URL"].to_s.empty? ? (saved_credentials["endpoint"] || Configuration::DEFAULT_ENDPOINT) : @env["DEPLOYANGEL_URL"]
596
+ end
597
+
598
+ def saved_credentials
599
+ @saved_credentials ||= Credentials.read(@root)
600
+ end
601
+
602
+ # Redeems a one-time code from "Set up with your coding agent" for the
603
+ # app's two tokens, and saves them without printing either, so they
604
+ # stay out of a coding agent's conversation.
605
+ def setup
606
+ options = parse(code: nil) { |o, opts| o.on("--code=CODE") { |v| opts[:code] = v } }
607
+ return usage_error("setup needs --code (from \"Set up with your coding agent\" on the app's page)") if options[:code].to_s.empty?
608
+
609
+ redeemer = @client || Client.new(token: nil, endpoint: endpoint, require_token: false)
610
+ result = redeemer.redeem_setup_code(options[:code].strip)
611
+ app = result["app"] || {}
612
+ file = Credentials.write(@root, { "app" => app, "app_token" => result["app_token"], "api_token" => result["api_token"],
613
+ "endpoint" => endpoint })
614
+ @saved_credentials = nil
615
+ relative = file.delete_prefix("#{@root}/")
616
+ @stdout.puts(<<~TEXT)
617
+ Set up DeployAngel for #{app["name"]} (#{app["environment"]}). Saved its app token and a "CI deploys" token to
618
+ #{relative}, kept out of git and not shown here. The deployangel command and MCP server here use them.
619
+
620
+ Give production the app token without printing it, for example:
621
+ fly secrets set DEPLOYANGEL_TOKEN=$(#{command_name} token app)
622
+ heroku config:set DEPLOYANGEL_TOKEN=$(#{command_name} token app)
623
+ and CI the "CI deploys" token:
624
+ #{command_name} token api | gh secret set DEPLOYANGEL_API_TOKEN
625
+ TEXT
626
+ 0
627
+ end
628
+
629
+ # Prints a saved token, for piping into a host's or CI's secrets. Never
630
+ # run it bare where its output is shown, such as in an agent's chat.
631
+ def token
632
+ kind = @argv.shift
633
+ return usage_error("token needs app or api: deployangel token app") unless %w[app api].include?(kind)
634
+
635
+ value = saved_credentials["#{kind}_token"]
636
+ return usage_error("no token saved here; run deployangel setup --code=<code> first") if value.to_s.empty?
637
+
638
+ @stdout.print(value)
639
+ @stdout.puts if @stdout.tty?
640
+ 0
641
+ end
642
+
643
+ def command_name
644
+ File.exist?(File.join(@root, "Gemfile")) ? "bundle exec deployangel" : "deployangel"
582
645
  end
583
646
 
584
647
  def usage_error(message)
@@ -22,8 +22,11 @@ module DeployAngel
22
22
 
23
23
  attr_reader :endpoint
24
24
 
25
- def initialize(token:, endpoint: Configuration::DEFAULT_ENDPOINT, timeout: 15)
26
- raise Unauthorized, "DEPLOYANGEL_API_TOKEN is not set" if token.to_s.empty?
25
+ # token: nil only for redeeming a setup code, which is its own credential.
26
+ def initialize(token:, endpoint: Configuration::DEFAULT_ENDPOINT, timeout: 15, require_token: true)
27
+ if require_token && token.to_s.empty?
28
+ raise Unauthorized, "DEPLOYANGEL_API_TOKEN is not set, and no token was saved here by `deployangel setup`"
29
+ end
27
30
 
28
31
  @token = token
29
32
  @endpoint = endpoint.to_s.chomp("/")
@@ -41,6 +44,9 @@ module DeployAngel
41
44
  get("/api/v1/deployments/#{deployment_id}/verification", all_findings ? { findings: "all" } : {})
42
45
  end
43
46
 
47
+ # The app's token and a "CI deploys" token, for a one-time setup code.
48
+ def redeem_setup_code(code) = post("/api/v1/setup_codes/redeem", { code: code })
49
+
44
50
  def register_deployment(commit: nil, version: nil, kind: nil, provider: nil, source_url: nil)
45
51
  post("/api/v1/deployments", { commit: commit, version: version, kind: kind, provider: provider, source_url: source_url }.compact)
46
52
  end
@@ -71,7 +77,7 @@ module DeployAngel
71
77
  end
72
78
 
73
79
  def request(request)
74
- request["Authorization"] = "Bearer #{@token}"
80
+ request["Authorization"] = "Bearer #{@token}" unless @token.to_s.empty?
75
81
  request["Accept"] = "application/json"
76
82
  request["User-Agent"] = "deployangel-cli/#{DeployAngel::VERSION}"
77
83
  uri = request.uri
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module DeployAngel
7
+ # Tokens `deployangel setup` saved for a project, so they never have to be
8
+ # printed, pasted into a coding agent's chat, or kept in the environment:
9
+ # .deployangel/credentials.json, readable only by its owner. The directory
10
+ # carries its own .gitignore, so it stays out of git without touching the
11
+ # project's.
12
+ module Credentials
13
+ DIR = ".deployangel"
14
+ FILE = "credentials.json"
15
+
16
+ module_function
17
+
18
+ def path(root)
19
+ File.join(root, DIR, FILE)
20
+ end
21
+
22
+ # From root or a parent directory, as git finds .git.
23
+ def find(root)
24
+ dir = File.expand_path(root)
25
+ loop do
26
+ candidate = path(dir)
27
+ return candidate if File.file?(candidate)
28
+
29
+ parent = File.dirname(dir)
30
+ return nil if parent == dir
31
+
32
+ dir = parent
33
+ end
34
+ end
35
+
36
+ def read(root)
37
+ file = find(root) or return {}
38
+ data = JSON.parse(File.read(file))
39
+ data.is_a?(Hash) ? data : {}
40
+ rescue JSON::ParserError, SystemCallError
41
+ {}
42
+ end
43
+
44
+ def write(root, data)
45
+ dir = File.join(root, DIR)
46
+ FileUtils.mkdir_p(dir)
47
+ File.write(File.join(dir, ".gitignore"), "*\n")
48
+ file = path(root)
49
+ File.open(file, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |io| io.write(JSON.pretty_generate(data)) }
50
+ File.chmod(0o600, file)
51
+ file
52
+ end
53
+ end
54
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.25"
4
+ VERSION = "0.1.26"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: deployangel
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.25
4
+ version: 0.1.26
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Owens
@@ -46,6 +46,7 @@ files:
46
46
  - lib/deployangel/core/redaction.rb
47
47
  - lib/deployangel/core/release.rb
48
48
  - lib/deployangel/core/transport.rb
49
+ - lib/deployangel/credentials.rb
49
50
  - lib/deployangel/doctor.rb
50
51
  - lib/deployangel/exerciser.rb
51
52
  - lib/deployangel/fork_hook.rb