inquirex-tools 0.8.0 → 0.9.5

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.
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module Tools
5
+ module Commands
6
+ # `inquirex create-release` — tags and publishes a GitHub release for
7
+ # every package in the family.
8
+ #
9
+ # The orchestrator runs each package's own `just release` in that
10
+ # package's directory, so how a repository tags and writes its notes
11
+ # stays that repository's business. What this adds is the order, refusing
12
+ # to tag a dirty tree or a feature branch, and one command instead of six.
13
+ #
14
+ # Separate from `publish` on purpose. Publishing is irreversible and
15
+ # authenticated; releasing is neither, and the two fail for entirely
16
+ # different reasons — a release that cannot find `gh` should not leave a
17
+ # half-published family behind it.
18
+ class CreateRelease < Dry::CLI::Command
19
+ desc "Tag and create a GitHub release for every package in the family"
20
+
21
+ option :dry_run,
22
+ type: :boolean,
23
+ default: false,
24
+ desc: "Print what would run; tag nothing"
25
+
26
+ option :force,
27
+ type: :boolean,
28
+ default: false,
29
+ desc: "Release even from a dirty tree or a branch other than main"
30
+
31
+ example [
32
+ " # release every package at its current version",
33
+ "--dry-run # print what would happen, tag nothing",
34
+ "--force # skip the clean-tree and main-branch checks"
35
+ ]
36
+
37
+ # Safe to re-run: each package's recipe force-moves its tag and
38
+ # recreates its GitHub release, so a run that stopped halfway can be
39
+ # fixed and reissued rather than unpicked.
40
+ #
41
+ # @param options [Hash] parsed CLI options
42
+ # @return [void]
43
+ def call(**options)
44
+ ok = Inquirex::Tools::Releaser.new.call(
45
+ dry_run: options.fetch(:dry_run, false),
46
+ force: options.fetch(:force, false)
47
+ )
48
+ exit(1) unless ok
49
+ rescue Inquirex::Tools::Error => e
50
+ warn "ERROR: #{e.message}"
51
+ exit 1
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module Tools
5
+ module Commands
6
+ # `inquirex publish` — publishes every package in the family.
7
+ #
8
+ # The orchestrator runs each package's own `just publish` in that
9
+ # package's directory, so how a package reaches its registry stays that
10
+ # package's business. What this adds is the order, a fresh 2FA code per
11
+ # gem, and stopping at the first failure.
12
+ class Publish < Dry::CLI::Command
13
+ desc "Publish every package in the family to RubyGems and npm"
14
+
15
+ option :dry_run,
16
+ type: :boolean,
17
+ default: false,
18
+ desc: "Print the commands that would run; publish nothing"
19
+
20
+ option :force,
21
+ type: :boolean,
22
+ default: false,
23
+ desc: "Attempt every package, even ones already on their registry"
24
+
25
+ example [
26
+ " # publish whatever is not out yet",
27
+ "--dry-run # print what would happen, touch no registry",
28
+ "--force # skip the already-published check"
29
+ ]
30
+
31
+ # Idempotent by default: a package whose version is already on its
32
+ # registry is skipped, so re-running after a mid-release failure
33
+ # resumes rather than throwing rejections at RubyGems and npm.
34
+ #
35
+ # @param options [Hash] parsed CLI options
36
+ # @return [void]
37
+ def call(**options)
38
+ ok = Inquirex::Tools::Publisher.new.call(
39
+ dry_run: options.fetch(:dry_run, false),
40
+ force: options.fetch(:force, false)
41
+ )
42
+ exit(1) unless ok
43
+ rescue Inquirex::Tools::Error => e
44
+ warn "ERROR: #{e.message}"
45
+ exit 1
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module Tools
5
+ module Commands
6
+ # `inquirex versions-bump` — moves every lockstep package to one version.
7
+ #
8
+ # The write counterpart of `inquirex versions-check`. Separate commands
9
+ # rather than one command with a `--set` flag, so nothing that writes can
10
+ # be reached by accident from a command meant to report.
11
+ class VersionsBump < Dry::CLI::Command
12
+ desc "Set every lockstep package to one version"
13
+
14
+ argument :version,
15
+ type: :string,
16
+ required: true,
17
+ desc: "Target version, MAJOR.MINOR.PATCH — must not go backwards"
18
+
19
+ example [
20
+ "0.9.0 # move every lockstep package to 0.9.0"
21
+ ]
22
+
23
+ # @param version [String, nil] target version
24
+ # @return [void]
25
+ def call(version: nil, **)
26
+ exit(1) unless Inquirex::Tools::VersionBumper.new.call(version)
27
+ rescue Inquirex::Tools::Error => e
28
+ warn "ERROR: #{e.message}"
29
+ exit 1
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module Tools
5
+ module Commands
6
+ # `inquirex versions-check` — reports the family's lockstep version.
7
+ #
8
+ # Plural on purpose: `inquirex version` prints this tool's own version,
9
+ # `inquirex versions-check` reports the family's. Read-only; the write
10
+ # counterpart is `inquirex versions-bump`.
11
+ class VersionsCheck < Dry::CLI::Command
12
+ desc "Report lockstep version parity across the family"
13
+
14
+ option :preflight,
15
+ type: :boolean,
16
+ default: false,
17
+ desc: "Also check clean trees, main branch, and tags"
18
+
19
+ example [
20
+ " # report parity, exit 1 on drift",
21
+ "--preflight # release readiness"
22
+ ]
23
+
24
+ # @param preflight [Boolean] also report git release readiness
25
+ # @return [void]
26
+ def call(preflight: false, **)
27
+ checker = Inquirex::Tools::VersionChecker.new
28
+ exit(1) unless preflight ? checker.preflight : checker.call
29
+ rescue Inquirex::Tools::Error => e
30
+ warn "ERROR: #{e.message}"
31
+ exit 1
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -3,7 +3,10 @@
3
3
  require "dry/cli"
4
4
 
5
5
  require_relative "commands/version"
6
- require_relative "commands/versions"
6
+ require_relative "commands/versions_bump"
7
+ require_relative "commands/versions_check"
8
+ require_relative "commands/publish"
9
+ require_relative "commands/create_release"
7
10
  require_relative "commands/changelogs"
8
11
 
9
12
  module Inquirex
@@ -16,9 +19,14 @@ module Inquirex
16
19
  module Commands
17
20
  extend Dry::CLI::Registry
18
21
 
19
- register "versions", Versions
22
+ register "versions-check", VersionsCheck
23
+ register "versions-bump", VersionsBump
24
+ register "publish", Publish
25
+ # `publish-all` reads naturally next to the justfile recipe of that name.
26
+ register "publish-all", Publish
27
+ register "create-release", CreateRelease
20
28
  register "changelogs", Changelogs
21
- register "version", Version
29
+ register "version", Version
22
30
  end
23
31
  end
24
32
  end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "colored2"
4
+ require "net/http"
5
+ require "uri"
6
+ require_relative "workspace"
7
+
8
+ module Inquirex
9
+ module Tools
10
+ # Publishes every package in the family by running each one's own
11
+ # `just publish` in its own directory.
12
+ #
13
+ # How a package reaches its registry stays that package's business: the
14
+ # gems build a .gem and `gem push` it, the npm packages run bun or yarn.
15
+ # This class only decides the order, supplies the one thing a package
16
+ # cannot work out for itself — a fresh 2FA code — and stops at the first
17
+ # failure.
18
+ #
19
+ # **A TOTP is single-use.** Reading 1Password once per gem inside a
20
+ # 30-second window returns the same six digits every time, and RubyGems
21
+ # rejects the second push as a replay. So a code is read per gem and the
22
+ # run waits for it to actually rotate before moving on, which is why
23
+ # publishing four gems takes minutes rather than seconds. The npm packages
24
+ # read their own code and are called with no argument.
25
+ #
26
+ # Order comes from {Workspace::LOCKSTEP}, so adding a package to the family
27
+ # adds it to the release. That order puts the core gem first, which matters:
28
+ # inquirex-llm and inquirex-tty depend on it and cannot resolve until it is
29
+ # on RubyGems.
30
+ #
31
+ # @example Preview a release without touching a registry
32
+ # Inquirex::Tools::Publisher.new.call(dry_run: true)
33
+ class Publisher
34
+ # How long to wait for a TOTP to rotate before giving up and using what
35
+ # 1Password last returned. Codes rotate every 30 seconds; 90 covers a
36
+ # window boundary landing badly without hanging a release indefinitely.
37
+ ROTATE_TIMEOUT = 90
38
+
39
+ # Seconds between polls of 1Password while waiting for a new code.
40
+ ROTATE_INTERVAL = 2
41
+
42
+ # Shells out with the child's stdout and stderr left attached, so a
43
+ # failing package's own output is what the operator sees.
44
+ DEFAULT_RUNNER = lambda { |dir, argv|
45
+ Dir.chdir(dir) { system("just", *argv) }
46
+ }
47
+
48
+ # Reads the current RubyGems TOTP. `op` resolves the account from
49
+ # OP_ACCOUNT, which `just publish-all` exports by decrypting
50
+ # .env.encrypted. Returns nil when unavailable, so a release without
51
+ # 1Password degrades to `gem push` prompting rather than failing.
52
+ DEFAULT_OTP_READER = lambda {
53
+ ref = "op://open-source-repos/ruby-gems/one-time password?attribute=otp"
54
+ code = `op read #{ref.inspect} 2>/dev/null`.strip
55
+ code.empty? ? nil : code
56
+ }
57
+
58
+ # Asks the registry whether a version is already public.
59
+ #
60
+ # Returns nil rather than false when the answer cannot be obtained — no
61
+ # network, a 500, a timeout. The caller treats nil as "attempt it": the
62
+ # registry itself refuses a duplicate, so guessing "not published" and
63
+ # trying is safe, whereas guessing "published" would silently skip a
64
+ # package that never shipped.
65
+ DEFAULT_PUBLISHED_CHECKER = lambda { |name, kind, version|
66
+ url = case kind
67
+ when :gem then "https://rubygems.org/api/v2/rubygems/#{name}/versions/#{version}.json"
68
+ when :npm then "https://registry.npmjs.org/#{name}/#{version}"
69
+ end
70
+ begin
71
+ uri = URI.parse(url)
72
+ response = Net::HTTP.start(uri.host,
73
+ uri.port,
74
+ use_ssl: true,
75
+ open_timeout: 5,
76
+ read_timeout: 5) do |http|
77
+ http.head(uri.request_uri)
78
+ end
79
+ case response
80
+ when Net::HTTPSuccess then true
81
+ when Net::HTTPNotFound then false
82
+ end
83
+ rescue StandardError
84
+ nil
85
+ end
86
+ }
87
+
88
+ # @param workspace [Workspace] the ecosystem checkout to publish from
89
+ # @param out [IO] where report output goes
90
+ # @param runner [#call] invoked with (dir, argv) to run a command; the
91
+ # default shells out. Injected so specs can assert what would run
92
+ # without publishing anything.
93
+ # @param otp_reader [#call] returns the current 2FA code, or nil
94
+ # @param published_checker [#call] (name, kind, version) => true/false/nil
95
+ def initialize(workspace: Workspace.new,
96
+ out: $stdout,
97
+ runner: DEFAULT_RUNNER,
98
+ otp_reader: DEFAULT_OTP_READER,
99
+ published_checker: DEFAULT_PUBLISHED_CHECKER)
100
+ @workspace = workspace
101
+ @out = out
102
+ @runner = runner
103
+ @otp_reader = otp_reader
104
+ @published_checker = published_checker
105
+ end
106
+
107
+ # Publishes every lockstep package, or prints what it would publish.
108
+ #
109
+ # Idempotent: a package whose current version is already on its registry
110
+ # is skipped, so re-running after a mid-release failure picks up exactly
111
+ # where it stopped instead of throwing six rejections at two registries.
112
+ #
113
+ # @param dry_run [Boolean] print the commands and run nothing
114
+ # @param args [Array<String>] extra arguments forwarded verbatim to each
115
+ # package's `just publish`
116
+ # @param force [Boolean] attempt every package even when it looks published
117
+ # @return [Boolean] true when every package published, skipped or printed
118
+ def call(dry_run: false, args: [], force: false)
119
+ out.puts(dry_run ? "DRY RUN — these commands would run, in this order:" : "Publishing the family:")
120
+
121
+ last_otp = nil
122
+ published = 0
123
+ skipped = 0
124
+
125
+ Workspace::LOCKSTEP.each do |name, kind|
126
+ dir = File.join(workspace.root, name)
127
+ raise Error, "no checkout for #{name} at #{dir}" unless Dir.exist?(dir)
128
+
129
+ version = workspace.version_of(name)
130
+ if !force && version && published_checker.call(name, kind, version)
131
+ row(name, kind, "already #{version} on #{registry(kind)} — skipping".dark)
132
+ skipped += 1
133
+ next
134
+ end
135
+
136
+ argv = ["publish", *args]
137
+ if dry_run
138
+ row(name, kind, "just #{argv.join(" ")}#{" (#{version})" if version}")
139
+ published += 1
140
+ next
141
+ end
142
+
143
+ # Only the gems take a code: each npm package reads its own.
144
+ if kind == :gem
145
+ last_otp = fresh_otp(last_otp)
146
+ argv += [last_otp] if last_otp
147
+ end
148
+
149
+ out.puts
150
+ out.puts "━━ #{name} (#{kind}) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
151
+ return false unless run(name, dir, argv)
152
+
153
+ published += 1
154
+ end
155
+
156
+ out.puts
157
+ out.puts summary(dry_run:, published:, skipped:)
158
+ true
159
+ end
160
+
161
+ private
162
+
163
+ # @return [Workspace] the ecosystem checkout being published
164
+ attr_reader :workspace
165
+
166
+ # @return [IO] where report output goes
167
+ attr_reader :out
168
+
169
+ # @return [#call] command runner
170
+ attr_reader :runner
171
+
172
+ # @return [#call] source of 2FA codes
173
+ attr_reader :otp_reader
174
+
175
+ # @return [#call] asks a registry whether a version is already public
176
+ attr_reader :published_checker
177
+
178
+ # @return [Boolean] whether the package published cleanly
179
+ def run(name, dir, argv)
180
+ return true if runner.call(dir, argv)
181
+
182
+ out.puts
183
+ out.puts "#{"ERROR".bold.red}: #{name} failed `just #{argv.first}` — stopping here."
184
+ out.puts "Publishing is irreversible, so the packages after #{name} were left alone."
185
+ false
186
+ end
187
+
188
+ # Blocks until 1Password returns a code that has not already been spent.
189
+ #
190
+ # @param last [String, nil] the code the previous gem used
191
+ # @return [String, nil] a fresh code, or nil when 1Password is unavailable
192
+ def fresh_otp(last)
193
+ waited = 0
194
+ loop do
195
+ code = otp_reader.call
196
+ return nil if code.nil?
197
+ return code if code != last
198
+ return code if waited >= ROTATE_TIMEOUT
199
+
200
+ out.puts " waiting for the 2FA code to rotate…" if waited.zero?
201
+ sleep ROTATE_INTERVAL
202
+ waited += ROTATE_INTERVAL
203
+ end
204
+ end
205
+
206
+ # Pads before colorizing: an ANSI escape is several characters wide to
207
+ # `format` and zero columns wide on screen, so colorizing first left the
208
+ # table ragged by exactly the length of the escape codes.
209
+ #
210
+ # @return [void]
211
+ def row(name, kind, detail)
212
+ out.puts " #{name.ljust(18).yellow} #{kind.to_s.ljust(4).cyan} #{detail}"
213
+ end
214
+
215
+ # @return [String] human-readable registry name for a package kind
216
+ def registry(kind) = kind == :gem ? "RubyGems" : "npm"
217
+
218
+ # @return [String] the closing line, which has to distinguish "nothing to
219
+ # do because everything is already out" from "nothing happened"
220
+ def summary(dry_run:, published:, skipped:)
221
+ return "Everything is already published — nothing to do.".green if published.zero? && skipped.positive?
222
+
223
+ counted = "#{published} package#{"s" unless published == 1}"
224
+ note = skipped.positive? ? ", #{skipped} already published" : ""
225
+ dry_run ? "Would publish #{counted}#{note}. Nothing was published." : "Published #{counted}#{note}.".green
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "colored2"
4
+ require_relative "workspace"
5
+
6
+ module Inquirex
7
+ module Tools
8
+ # Cuts a GitHub release for every package in the family by running each
9
+ # one's own `just release` in its own directory.
10
+ #
11
+ # The counterpart to publishing: that puts packages on RubyGems and npm,
12
+ # this puts a `vX.Y.Z` tag and a GitHub release against the commit that
13
+ # shipped. Every package already owns a `release` recipe — it tags,
14
+ # force-pushes the tag, and recreates the GitHub release with generated
15
+ # notes — so how a repository releases stays that repository's business.
16
+ # What this adds is the order, the guards, and one command instead of six.
17
+ #
18
+ # Unlike publishing, a release is **reversible**: a tag can be moved and a
19
+ # GitHub release deleted, which is exactly what each recipe does on every
20
+ # run. That is why this is safe to re-run, and why a mid-run failure can be
21
+ # fixed and the command simply issued again.
22
+ #
23
+ # The guards exist because the recipe tags `HEAD`. Releasing from a dirty
24
+ # tree tags a commit that does not match what was tested; releasing from a
25
+ # feature branch tags work that was never merged. Both produce a `v0.9.5`
26
+ # pointing somewhere nobody can reproduce, and neither is fixed by
27
+ # re-running — so they are refused rather than warned about.
28
+ #
29
+ # @example Preview a release without tagging anything
30
+ # Inquirex::Tools::Releaser.new.call(dry_run: true)
31
+ class Releaser
32
+ # The only branch a release may be cut from. A tag on anything else
33
+ # points at work that never went through review.
34
+ RELEASE_BRANCH = "main"
35
+
36
+ # Shells out with the child's stdout and stderr left attached, so a
37
+ # failing package's own `gh` output is what the operator sees.
38
+ DEFAULT_RUNNER = lambda { |dir, argv|
39
+ Dir.chdir(dir) { system("just", *argv) }
40
+ }
41
+
42
+ # @param workspace [Workspace] the ecosystem checkout to release from
43
+ # @param out [IO] where report output goes
44
+ # @param runner [#call] invoked with (dir, argv) to run a command; the
45
+ # default shells out. Injected so specs can assert what would run
46
+ # without creating a tag or a GitHub release.
47
+ def initialize(workspace: Workspace.new, out: $stdout, runner: DEFAULT_RUNNER)
48
+ @workspace = workspace
49
+ @out = out
50
+ @runner = runner
51
+ end
52
+
53
+ # Creates a GitHub release for every lockstep package.
54
+ #
55
+ # Stops at the first package that fails or is refused, and says so. The
56
+ # packages after it are left alone rather than half-tagged.
57
+ #
58
+ # @param dry_run [Boolean] print what would run; tag nothing
59
+ # @param force [Boolean] release despite a dirty tree or a non-main branch
60
+ # @param args [Array<String>] extra arguments forwarded to `just release`
61
+ # @return [Boolean] true when every package released or was printed
62
+ def call(dry_run: false, force: false, args: [])
63
+ out.puts(dry_run ? "DRY RUN — these releases would be created:" : "Creating GitHub releases for the family:")
64
+
65
+ released = 0
66
+
67
+ Workspace::LOCKSTEP.each_key do |name|
68
+ dir = File.join(workspace.root, name)
69
+ raise Error, "no checkout for #{name} at #{dir}" unless Dir.exist?(dir)
70
+
71
+ version = workspace.version_of(name)
72
+
73
+ blocker = force ? nil : blocker_for(name)
74
+ if blocker
75
+ row(name, version, blocker.red)
76
+ return refused(name)
77
+ end
78
+
79
+ argv = ["release", *args]
80
+ if dry_run
81
+ row(name, version, "just #{argv.join(" ")}")
82
+ released += 1
83
+ next
84
+ end
85
+
86
+ out.puts
87
+ out.puts "━━ #{name} v#{version} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
88
+ return false unless run(name, dir, argv)
89
+
90
+ released += 1
91
+ end
92
+
93
+ out.puts
94
+ out.puts summary(dry_run:, released:)
95
+ true
96
+ end
97
+
98
+ private
99
+
100
+ # @return [Workspace] the ecosystem checkout being released
101
+ attr_reader :workspace
102
+
103
+ # @return [IO] where report output goes
104
+ attr_reader :out
105
+
106
+ # @return [#call] command runner
107
+ attr_reader :runner
108
+
109
+ # Why this package must not be released, or nil when it is fine.
110
+ #
111
+ # A `clean` of nil means the directory is not a git checkout at all. That
112
+ # is left for the recipe to fail on rather than pre-judged here: a state
113
+ # that cannot be read should never silently skip a package, because
114
+ # "unknown" and "not releasable" are different answers.
115
+ #
116
+ # @param name [String] package name
117
+ # @return [String, nil] human-readable blocker
118
+ def blocker_for(name)
119
+ state = workspace.git_state(name)
120
+ return "working tree is dirty — the tag would not match what was tested" if state[:clean] == false
121
+
122
+ branch = state[:branch]
123
+ return nil if branch.nil? || branch.empty? || branch == RELEASE_BRANCH
124
+
125
+ "on #{branch}, not #{RELEASE_BRANCH} — the tag would point at unmerged work"
126
+ end
127
+
128
+ # @param name [String] the package that was refused
129
+ # @return [Boolean] always false, so the caller can `return refused(name)`
130
+ def refused(name)
131
+ out.puts
132
+ out.puts "#{"ERROR".bold.red}: #{name} is not releasable — stopping here."
133
+ out.puts "Nothing was tagged. Fix the checkout, or re-run with --force to override."
134
+ false
135
+ end
136
+
137
+ # @return [Boolean] whether the package released cleanly
138
+ def run(name, dir, argv)
139
+ return true if runner.call(dir, argv)
140
+
141
+ out.puts
142
+ out.puts "#{"ERROR".bold.red}: #{name} failed `just #{argv.first}` — stopping here."
143
+ out.puts "A release is re-runnable, so fix the cause and issue the command again."
144
+ false
145
+ end
146
+
147
+ # Pads before colorizing: an ANSI escape is several characters wide to
148
+ # `String#ljust` and zero columns wide on screen, so colorizing first left
149
+ # the table ragged by exactly the length of the escape codes.
150
+ #
151
+ # @return [void]
152
+ def row(name, version, detail)
153
+ out.puts " #{name.ljust(18).yellow} #{(version || "?").ljust(8).cyan} #{detail}"
154
+ end
155
+
156
+ # @return [String] the closing line
157
+ def summary(dry_run:, released:)
158
+ counted = "#{released} release#{"s" unless released == 1}"
159
+ dry_run ? "Would create #{counted}. Nothing was tagged." : "Created #{counted}.".green
160
+ end
161
+ end
162
+ end
163
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Inquirex
4
4
  module Tools
5
- VERSION = "0.8.0"
5
+ VERSION = "0.9.5"
6
6
  end
7
7
  end