gitbroker 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e5c1620070dc6b1ea787d390ae8f957190e90c347f51cb3e7bb87d97db616f7
4
- data.tar.gz: 14b74da5bc45089ee0af18b29a8c897e988d0908acb0353787a7faf11777d495
3
+ metadata.gz: 946c2fd1fc092b025239f5e0bed964713648ef3c722ae2cd1955d129079ec107
4
+ data.tar.gz: 3cb2803f1de324bc10195b6136b302ededcd3e34a632bdeaba823c4d98639ea4
5
5
  SHA512:
6
- metadata.gz: 6c90662bdd2ce91c9d5caa27e33c8528aef51021b545b9d951b23d31481eb512dc86c22abaf5a32b4919716e9831ef89d799cf24b0374d4c3a29d72191837b7c
7
- data.tar.gz: 5bf014fde6beb119ea28255295e7dc210a531d8d0a2ce0075c4da36624a03b327b9add007557ce7110d92874143efb0b0af36fd2af65dc6dc06407c60e9d1238
6
+ metadata.gz: cd93a7d8e7fd151960b28b786ee9409b520f89405552725e3d23dcdde98628afdd952b16cabcd1d841e5c4009215291bd237e93f829a8a7ebd92e339cb38fdb9
7
+ data.tar.gz: 492201664eca213938e466a6e4e5f8ac920a8d726d746700989de4028fd51644275c2a45fb5e630cd04aaea0935cb7032b74ea1756ac5b6f047c7fb796198f03
data/lib/gitbroker/api.rb CHANGED
@@ -23,13 +23,21 @@ module Gitbroker
23
23
  end
24
24
 
25
25
  def post(path, body = {})
26
+ send_request(Net::HTTP::Post, path) { |request| request.body = JSON.generate(body) }
27
+ end
28
+
29
+ def get(path) = send_request(Net::HTTP::Get, path)
30
+
31
+ private
32
+
33
+ def send_request(verb, path)
26
34
  uri = URI("#{@server}#{path}")
27
- request = Net::HTTP::Post.new(uri)
35
+ request = verb.new(uri)
28
36
  request["Content-Type"] = "application/json"
29
37
  request["Accept"] = "application/json"
30
38
  request["User-Agent"] = "gitbroker/#{VERSION}"
31
39
  request["Authorization"] = "Bearer #{@token}" if @token
32
- request.body = JSON.generate(body)
40
+ yield request if block_given?
33
41
 
34
42
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
35
43
  http.request(request)
@@ -40,8 +48,6 @@ module Gitbroker
40
48
  parsed
41
49
  end
42
50
 
43
- private
44
-
45
51
  def parse(body)
46
52
  return {} if body.to_s.strip.empty?
47
53
 
data/lib/gitbroker/cli.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "openssl"
4
5
  require "optparse"
5
6
  require "rbconfig"
6
7
  require "socket"
@@ -11,15 +12,18 @@ module Gitbroker
11
12
  Usage: gitbroker <command> [options]
12
13
 
13
14
  Commands:
14
- login [--server URL] [--name NAME] Pair this machine with Git Broker (you type the code at /devices)
15
- start Stay connected; run your agent when you ask from a PR card
15
+ start [--server URL] [--name NAME] Set this machine up and keep it connected: pairs it (paste the code
16
+ it prints at git.broker/devices), installs the background service and the
17
+ "Start companion" button, then runs your agent when you ask from a PR card.
18
+ Run it again any time; it only does what is still missing.
16
19
  launch Start the companion in the background if it is not running
17
20
  stop Stop the companion (and its background service, so it stays stopped)
18
21
  restart Restart the companion, e.g. after `gem update gitbroker`
19
22
  upgrade Install the newest gem, remove old versions, restart the companion
20
23
  uninstall Remove the service, the start link, the Claude Code hooks and the gem (keeps your pairing)
21
- install-service Run `gitbroker start` in the background (launchd / systemd --user), and let
22
- the website's "Start companion" button wake it (gitbroker:// handler)
24
+ login [--server URL] [--name NAME] Only pair this machine (`start` does this when needed)
25
+ install-service Only (re)install the background service (launchd / systemd --user) and the
26
+ "Start companion" handler (`start` does this when needed)
23
27
  install-skill Install the gitbroker skills into ~/.claude/skills
24
28
  status Show pairing, configuration and the repositories found
25
29
  clean Remove finished agent worktrees (keeps ones with uncommitted work)
@@ -88,15 +92,77 @@ module Gitbroker
88
92
  Login.new(config:, out: @out, name: options[:name] || Socket.gethostname).run
89
93
  end
90
94
 
95
+ # In a terminal: whatever is still missing (pairing, the background service) is set up, then the companion is left
96
+ # running in the background. Without a terminal (the service, `gitbroker launch`) or with --foreground: the daemon
97
+ # itself, in this process.
91
98
  def start
92
- raise Error, "This machine is not paired. Run `gitbroker login` first." unless config.paired?
99
+ options = {}
100
+ OptionParser.new do |parser|
101
+ parser.on("--server URL") { options[:server] = _1 }
102
+ parser.on("--name NAME") { options[:name] = _1 }
103
+ parser.on("--foreground") { options[:foreground] = true }
104
+ end.parse!(@argv)
105
+ @config = config.save!("server" => options[:server]) if options[:server]
106
+
107
+ return run_daemon if options[:foreground] || !@input.tty?
108
+
109
+ set_up(options[:name] || Socket.gethostname)
110
+ end
111
+
112
+ def run_daemon
113
+ raise Error, "This machine is not paired. Run `gitbroker start` in a terminal to pair it." unless config.paired?
93
114
 
94
115
  @out.puts "Git Broker companion #{VERSION} → #{config.server} (agent: #{config.agent}, terminal: #{config.terminal})"
95
116
  Daemon.build(config, out: @out).run
117
+ rescue Daemon::Rejected
118
+ # The pairing is dead. Forget it and remove the service, so nothing restarts a daemon that can never connect.
119
+ config.clear_credentials!
120
+ ServiceInstaller.new(home: @home, env: @env).uninstall!
121
+ raise
96
122
  end
97
123
 
124
+ def set_up(name)
125
+ forget_dead_pairing if config.paired? && pairing_rejected?
126
+ Login.new(config:, out: @out, name:).run unless config.paired?
127
+ install_background_service
128
+ @out.puts "Git Broker is running in the background. Open #{config.server} to see it connected."
129
+ rescue Error => e
130
+ raise unless service_unavailable?(e)
131
+
132
+ @out.puts "No background service is available here (#{e.message}). Keeping the companion in this window; run " \
133
+ "`gitbroker start` again after a restart. Press Ctrl-C to stop it."
134
+ run_daemon
135
+ end
136
+
137
+ # True only when the server answered "unauthorized". An unreachable server proves nothing, so the pairing is kept.
138
+ def pairing_rejected?
139
+ Api.new(server: config.server, token: config.device_token).get("/device/ping")
140
+ false
141
+ rescue Api::HttpError => e
142
+ e.status == 401
143
+ rescue SystemCallError, IOError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
144
+ false
145
+ end
146
+
147
+ def forget_dead_pairing
148
+ @out.puts "Git Broker no longer knows this machine, so it needs pairing again."
149
+ config.clear_credentials!
150
+ ServiceInstaller.new(home: @home, env: @env).uninstall!
151
+ end
152
+
153
+ # Installs what is missing and starts it. A service that is already installed is only started (installing again
154
+ # would restart a daemon that may be running a task).
155
+ def install_background_service
156
+ service = ServiceInstaller.new(home: @home, env: @env)
157
+ return lifecycle.launch if service.installed?
158
+
159
+ install_service
160
+ end
161
+
162
+ def service_unavailable?(error) = error.message.include?("not found on PATH") || error.message.include?("systemctl")
163
+
98
164
  def install_service
99
- raise Error, "This machine is not paired. Run `gitbroker login` first." unless config.paired?
165
+ raise Error, "This machine is not paired. Run `gitbroker start` first." unless config.paired?
100
166
 
101
167
  path = ServiceInstaller.new(home: @home, env: @env).install!
102
168
  @out.puts "Installed and started #{path}."
@@ -112,7 +178,7 @@ module Gitbroker
112
178
  # The target of the website's gitbroker://start link and of `gitbroker launch` by hand: start the background
113
179
  # service, or the daemon itself when none is installed. Safe to run when it is already running.
114
180
  def launch
115
- raise Error, "This machine is not paired. Run `gitbroker login` first." unless config.paired?
181
+ raise Error, "This machine is not paired. Run `gitbroker start` first." unless config.paired?
116
182
  lifecycle.launch
117
183
  end
118
184
 
@@ -125,7 +191,7 @@ module Gitbroker
125
191
  end
126
192
 
127
193
  def restart
128
- raise Error, "This machine is not paired. Run `gitbroker login` first." unless config.paired?
194
+ raise Error, "This machine is not paired. Run `gitbroker start` first." unless config.paired?
129
195
 
130
196
  lifecycle.restart
131
197
  @out.puts "Restarting the companion."
@@ -137,7 +203,7 @@ module Gitbroker
137
203
  # the companion comes back on the new version. An unpaired machine has no companion to restart.
138
204
  def upgrade
139
205
  maintenance.upgrade!
140
- return @out.puts("Upgraded. Run `gitbroker login` to pair this machine.") unless config.paired?
206
+ return @out.puts("Upgraded. Run `gitbroker start` to pair this machine.") unless config.paired?
141
207
 
142
208
  lifecycle.restart
143
209
  @out.puts "Upgraded. Restarting the companion."
@@ -159,7 +225,7 @@ module Gitbroker
159
225
  def status
160
226
  @out.puts "Git Broker companion #{VERSION}"
161
227
  @out.puts "Server: #{config.server}"
162
- @out.puts "Paired: #{config.paired? ? "yes (device #{config.device_id})" : "no (run `gitbroker login`)"}"
228
+ @out.puts "Paired: #{config.paired? ? "yes (device #{config.device_id})" : "no (run `gitbroker start`)"}"
163
229
  @out.puts "Agent: #{config.agent}"
164
230
  @out.puts "Terminal: #{config.terminal}"
165
231
  @out.puts "Hooks: #{hooks_installer.installed? ? "installed" : "not installed (gitbroker install-hooks)"}"
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Gitbroker
6
+ # Puts a short string on the system clipboard so the user can paste it into the browser instead of typing it
7
+ # (the pairing code). Best effort: false when no clipboard tool exists or it failed, never an error.
8
+ module Clipboard
9
+ COMMANDS = {
10
+ "macos" => [ [ "pbcopy" ] ],
11
+ "linux" => [ [ "wl-copy" ], [ "xclip", "-selection", "clipboard" ], [ "xsel", "--clipboard", "--input" ], [ "clip.exe" ] ]
12
+ }.freeze
13
+
14
+ def self.copy(text, platform: Gitbroker.platform, commands: COMMANDS)
15
+ Array(commands[platform]).any? { |argv| pipe(argv, text) }
16
+ end
17
+
18
+ def self.pipe(argv, text)
19
+ _out, status = Open3.capture2e(*argv, stdin_data: text)
20
+ status.success?
21
+ rescue SystemCallError
22
+ false
23
+ end
24
+ private_class_method :pipe
25
+ end
26
+ end
@@ -67,6 +67,12 @@ module Gitbroker
67
67
  @credentials = nil
68
68
  end
69
69
 
70
+ # Forgets the pairing (the server no longer knows this device); the next `gitbroker start` pairs again.
71
+ def clear_credentials!
72
+ FileUtils.rm_f(credentials_path)
73
+ @credentials = nil
74
+ end
75
+
70
76
  private
71
77
 
72
78
  def credentials
@@ -8,15 +8,20 @@ module Gitbroker
8
8
  # `gitbroker start`: keep the device connected, report checkouts, claim and run tasks, and relay the status of
9
9
  # agents the user started themselves (relay: Task 31, process scanner: Task 32).
10
10
  class Daemon
11
+ class Rejected < Error; end
12
+
11
13
  RESCAN_EVERY = 300
12
14
  MAX_BACKOFF = 60
13
15
  BATCH = 50
14
16
  # Spec §5.2 / main §18.4: nothing else about an observed agent ever leaves the machine.
17
+ # The server answered "unauthorized" this many times in a row (a revoked device, or a server that lost its devices):
18
+ # the pairing is dead, so the daemon stops asking instead of retrying for ever. One or two could be a blip.
19
+ REJECTIONS_BEFORE_GIVING_UP = 3
15
20
  OBSERVATION_FIELDS = %w[event agent external_session_id repository branch head_sha occurred_at].freeze
16
21
 
17
22
  def self.build(config, out: $stdout, relay: LocalRelay.new(path: File.join(config.dir, "daemon.sock")),
18
23
  process_scanner: build_process_scanner(config))
19
- raise Error, "This machine is not paired. Run `gitbroker login` first." unless config.paired?
24
+ raise Error, "This machine is not paired. Run `gitbroker start` in a terminal to pair it." unless config.paired?
20
25
 
21
26
  new(scanner: RepoScanner.new(roots: config.roots, depth: config.scan_depth, exclude: [ config.worktree_root ]),
22
27
  client_factory: -> { CableClient.new(url: config.cable_url, token: config.device_token, origin: config.server) },
@@ -58,6 +63,7 @@ module Gitbroker
58
63
 
59
64
  def run
60
65
  @backoff = 1
66
+ @rejections = 0
61
67
  @relay&.start
62
68
  @lifecycle&.write_pid
63
69
  until @stopping
@@ -66,7 +72,10 @@ module Gitbroker
66
72
  wire(@client)
67
73
  @client.run
68
74
  rescue CableClient::Unauthorized
69
- raise Error, "Git Broker rejected this device (revoked?). Run `gitbroker login` to pair again."
75
+ @rejections += 1
76
+ raise Rejected, "Git Broker no longer knows this device (revoked?). Run `gitbroker start` to pair again." if @rejections >= REJECTIONS_BEFORE_GIVING_UP
77
+
78
+ @out.puts "Git Broker rejected this device (#{@rejections} of #{REJECTIONS_BEFORE_GIVING_UP}); trying again."
70
79
  rescue Error, SystemCallError, IOError, SocketError, OpenSSL::SSL::SSLError => e
71
80
  @out.puts "Connection problem: #{e.message}"
72
81
  end
@@ -96,6 +105,7 @@ module Gitbroker
96
105
  def wire(client)
97
106
  client.on_subscribed do
98
107
  @backoff = 1
108
+ @rejections = 0
99
109
  rescan(client)
100
110
  client.perform("hello", "version" => VERSION, "platform" => Gitbroker.platform,
101
111
  "service" => @lifecycle ? @lifecycle.service_installed? : false)
@@ -10,7 +10,8 @@ module Gitbroker
10
10
  end
11
11
 
12
12
  def initialize(config:, api: Api.new(server: config.server), out: $stdout, name: Socket.gethostname,
13
- sleeper: ->(seconds) { sleep(seconds) }, opener: ->(url) { Login.open_browser(url) }, clock: -> { Time.now })
13
+ sleeper: ->(seconds) { sleep(seconds) }, opener: ->(url) { Login.open_browser(url) }, clock: -> { Time.now },
14
+ copier: ->(text) { Clipboard.copy(text) })
14
15
  @config = config
15
16
  @api = api
16
17
  @out = out
@@ -18,14 +19,22 @@ module Gitbroker
18
19
  @sleeper = sleeper
19
20
  @opener = opener
20
21
  @clock = clock
22
+ @copier = copier
21
23
  end
22
24
 
23
25
  def run
24
26
  auth = @api.post("/device_authorizations", { client_name: @name, platform: Gitbroker.platform })
25
- # No pre-filled link: the user types the code on the plain page.
26
- @out.puts "To pair this machine, open #{auth.fetch("verification_uri")} and type the code: #{auth.fetch("user_code")}"
27
- @out.puts "Approve only if the page shows this machine's name (#{@name}) and the code above."
28
- @opener.call(auth.fetch("verification_uri"))
27
+ code = auth.fetch("user_code")
28
+ uri = auth.fetch("verification_uri")
29
+ copied = @copier.call(code)
30
+ # No pre-filled link: nothing in a URL may select a code, the user pastes (or types) it on the plain page.
31
+ @out.puts "Pairing #{@name} with Git Broker."
32
+ @out.puts
33
+ @out.puts " Your code: #{code}#{" (copied to your clipboard)" if copied}"
34
+ @out.puts
35
+ @out.puts "Paste it at #{uri}, press Pair, then check the device details and press Approve."
36
+ @out.puts "Only enter a code you see here, in your own terminal."
37
+ @opener.call(uri)
29
38
  poll(auth)
30
39
  end
31
40
 
@@ -36,7 +45,7 @@ module Gitbroker
36
45
  deadline = @clock.call + auth.fetch("expires_in", 600).to_i
37
46
 
38
47
  loop do
39
- raise Error, "The pairing code expired. Run `gitbroker login` again." if @clock.call > deadline
48
+ raise Error, "The pairing code expired. Run `gitbroker start` again." if @clock.call > deadline
40
49
 
41
50
  @sleeper.call(interval)
42
51
  begin
@@ -46,14 +55,14 @@ module Gitbroker
46
55
  case e.body["error"]
47
56
  when "authorization_pending" then next
48
57
  when "slow_down" then interval += 5
49
- when "expired_token" then raise Error, "The pairing code expired. Run `gitbroker login` again."
58
+ when "expired_token" then raise Error, "The pairing code expired. Run `gitbroker start` again."
50
59
  else raise Error, "Pairing failed: #{e.message}"
51
60
  end
52
61
  next
53
62
  end
54
63
 
55
64
  @config.save_credentials!(device_token: token.fetch("access_token"), device_id: token.fetch("device_id"))
56
- @out.puts "Paired. Next: `gitbroker install-service` (or `gitbroker start`)."
65
+ @out.puts "Paired."
57
66
  return token["device_id"]
58
67
  end
59
68
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gitbroker
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.1"
5
5
  end
data/lib/gitbroker.rb CHANGED
@@ -16,6 +16,7 @@ module Gitbroker
16
16
  autoload :Agents, File.expand_path("gitbroker/agents", __dir__)
17
17
  autoload :Api, File.expand_path("gitbroker/api", __dir__)
18
18
  autoload :CableClient, File.expand_path("gitbroker/cable_client", __dir__)
19
+ autoload :Clipboard, File.expand_path("gitbroker/clipboard", __dir__)
19
20
  autoload :CLI, File.expand_path("gitbroker/cli", __dir__)
20
21
  autoload :Config, File.expand_path("gitbroker/config", __dir__)
21
22
  autoload :Daemon, File.expand_path("gitbroker/daemon", __dir__)
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "gitbroker",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Skills for working on Git Broker agent tasks: the gitbroker MCP tools, write rules and report_task."
5
5
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitbroker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuri Sidorov (@newstler)
@@ -41,6 +41,7 @@ files:
41
41
  - lib/gitbroker/api.rb
42
42
  - lib/gitbroker/cable_client.rb
43
43
  - lib/gitbroker/cli.rb
44
+ - lib/gitbroker/clipboard.rb
44
45
  - lib/gitbroker/config.rb
45
46
  - lib/gitbroker/daemon.rb
46
47
  - lib/gitbroker/explain_verifier.rb