open-in-editor-bridge 0.1.1 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ec2cde5e04f7296d806dcfeff4926492de642d4b09983478295b3bc45a68f08
4
- data.tar.gz: 60e87f9f41f6207bd75bb58fce1c0daa4118af0b3bbbd4cb64030eedd83a3f56
3
+ metadata.gz: 894d087228ad0690acd8e32f1f2284240055e9b934965fd181556adc7aba116b
4
+ data.tar.gz: f5fd6f10dc3110132077b781224d9c1e67eb46d9c6c83d85f0f083cca89b1391
5
5
  SHA512:
6
- metadata.gz: 8e767cca6409335c11dee8e522f1ba6b04e973ec638bad090a089ac2e998544ac347287805f4475a9739cd61448d6dbe3cd13577a95dde1733b4def30b040959
7
- data.tar.gz: 5b48df59f4d402e62c3cff453ba6523b4c775c4744c00c48324f0cbb145f258a8d97bae825542fd2b44967614c9dfa880955793a5356545107a5a646c4c7e6b8
6
+ metadata.gz: 00a3e25e014adfdff6d33fa389e4eb11d75ba364fb89536d83b83128154854322dc367938544d34ccad2ffab7a5487ad3f7a3d4f135c35ec5694ebb15f2c315b
7
+ data.tar.gz: 1b6a96b2710f51240f152028b49d0b9ac573dc844d7bb6c8177586faaf3c4a59cd18cf6c09aba920fafd688e22350be1403d6ddf102a606a6cb81af2bdb47260
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-10-09
4
+
5
+ ### Added
6
+
7
+ - Shared host broker with checkout-specific editor sessions and simultaneous-start coordination.
8
+ - `OPEN_IN_EDITOR_BRIDGE_BIND_ADDRESS` for explicit Docker-reachable binding; loopback remains the default.
9
+ - Stable checkout IDs through `OpenInEditorBridge.session_id`, `--session-id`, and the `with_running` block argument.
10
+ - Independent foreground leases and idempotent detached registration for Compose `up -d` / `down`.
11
+
12
+ ### Changed
13
+
14
+ - Shutdown releases only the calling checkout's detached registration; the last active lease stops the broker.
15
+ - Shared private runtime state replaces checkout-local PID/log files. Stop 0.1.x bridges before upgrading.
16
+ - Multi-checkout editor links require an explicit `session` query parameter; single-checkout links remain valid.
17
+ - Registration/release use nonce-bound HMAC-authenticated requests/responses and verify protocol/PID identity without transmitting the control secret or signaling disk-sourced PIDs.
18
+
19
+ ### Fixed
20
+
21
+ - Incorrect host-checkout routing and duplicate-port startup failures for concurrent worktrees.
22
+ - Premature detached-session cleanup when using the documented persistent lifecycle.
23
+ - Path-prefix collisions and double decoding of percent-encoded filenames.
24
+ - Relative editor commands now execute in the requesting checkout, not the checkout that first started the broker.
25
+ - Failed health probes preserve live shared-broker credentials instead of orphaning active sessions.
26
+ - Malformed authentication bytes are rejected without terminating other checkout sessions.
data/README.md CHANGED
@@ -1,49 +1,131 @@
1
1
  # OpenInEditorBridge
2
2
 
3
- A small Ruby gem that runs a local HTTP bridge for opening files from a container in the host editor.
3
+ A Ruby gem that runs a shared local HTTP bridge for opening container files in the correct host checkout's editor. Requires Ruby 3.2 or newer; no runtime gem dependencies.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```ruby
8
- gem "open-in-editor-bridge"
8
+ gem "open-in-editor-bridge", "~> 0.2"
9
9
  ```
10
10
 
11
- ## Ruby API
11
+ ```sh
12
+ gem install open-in-editor-bridge
13
+ ```
14
+
15
+ ## Foreground Ruby API
12
16
 
13
- Wrap the local development command that needs the bridge:
17
+ Wrap commands whose lifetime matches the application, such as foreground Compose:
14
18
 
15
19
  ```ruby
16
20
  require "open_in_editor_bridge"
17
21
 
18
- OpenInEditorBridge.with_running do
19
- # Run the command that emits open-in-editor links.
22
+ OpenInEditorBridge.with_running do |session_id|
23
+ ENV["OPEN_IN_EDITOR_SESSION_ID"] = session_id
24
+ system("docker", "compose", "up", exception: true)
20
25
  end
21
26
  ```
22
27
 
23
- Use `ensure_running: false` when the wrapped command is responsible for stopping the bridge:
28
+ The block returns its normal result. Its checkout lease is released in `ensure`, including when the block raises; cleanup does not replace the original exception. Overlapping wrappers have independent leases, even in the same checkout. Finishing one wrapper never releases another wrapper or an existing detached registration.
29
+
30
+ `OpenInEditorBridge.session_id` returns the stable ID for the configured checkout without starting the bridge. `OpenInEditorBridge.new(env: ..., project_root: ...)` supports explicit configuration for library consumers.
31
+
32
+ ## Detached Compose
33
+
34
+ `up -d` returns before the application stops. Use persistent registration, **not** `with_running` around detached startup:
35
+
36
+ ```ruby
37
+ require "open_in_editor_bridge"
38
+
39
+ OpenInEditorBridge.call("--ensure-running")
40
+ ENV["OPEN_IN_EDITOR_SESSION_ID"] = OpenInEditorBridge.session_id
41
+ system("docker", "compose", "up", "-d", exception: true)
42
+ ```
43
+
44
+ Release that checkout's persistent registration when taking it down:
24
45
 
25
46
  ```ruby
26
- OpenInEditorBridge.with_running(ensure_running: false) { run_down_command }
47
+ OpenInEditorBridge.with_running(ensure_running: false) do
48
+ system("docker", "compose", "down", exception: true)
49
+ end
27
50
  ```
28
51
 
29
- The bridge is stopped in the wrapper's `ensure` path. A block exception remains the raised exception.
52
+ `ensure_running: false` does not register a new lease and releases only the detached registration after the block. You can alternatively call `OpenInEditorBridge.call("--shutdown")` directly. Repeated `--ensure-running` calls are idempotent for a checkout; one `--shutdown` releases its detached registration. Foreground leases and registrations for other checkouts remain active. The listener exits when the last lease is released.
30
53
 
31
54
  ## CLI
32
55
 
56
+ Run lifecycle commands from the host checkout (or set `OPEN_IN_EDITOR_PROJECT_ROOT` explicitly):
57
+
33
58
  ```sh
59
+ export OPEN_IN_EDITOR_COMMAND='code'
60
+ # Explicit opt-in for Docker bridge-network access; read the security section.
61
+ export OPEN_IN_EDITOR_BRIDGE_BIND_ADDRESS=0.0.0.0
62
+ export OPEN_IN_EDITOR_SESSION_ID="$(open-in-editor-bridge --session-id)"
34
63
  open-in-editor-bridge --ensure-running
64
+ docker compose up -d
65
+
66
+ # Later, with the same port, bind address, runtime directory, and project root:
67
+ docker compose down
35
68
  open-in-editor-bridge --shutdown
36
- open-in-editor-bridge --serve
37
69
  ```
38
70
 
39
- The server provides `GET /health` and `GET /__open-in-editor?file=...`. Container paths beginning with `/usr/src/web` are mapped to the host `web` directory, preserving optional `:line:column` locations.
71
+ `--serve` runs the broker in the foreground and registers the current checkout. It requires a free port. Prefer the managed lifecycle above for sharing: manually interrupting a foreground broker stops all its sessions.
72
+
73
+ ## Docker and Checkout Routing
74
+
75
+ All worktrees using one listener must agree on port, bind address, and runtime directory. Each checkout registers its own host-root mapping and editor command. Simultaneous startup is serialized by a shared per-port lock, not by checkout-local pidfiles.
76
+
77
+ On Linux, make the host accessible from Compose and pass the checkout ID into the web service:
78
+
79
+ ```yaml
80
+ services:
81
+ web:
82
+ extra_hosts:
83
+ - "host.docker.internal:host-gateway"
84
+ environment:
85
+ OPEN_IN_EDITOR_SESSION_ID: "${OPEN_IN_EDITOR_SESSION_ID:?Set the host checkout session ID}"
86
+ ```
87
+
88
+ The container's dev-server proxy must forward editor requests to `http://host.docker.internal:3333` and add its `OPEN_IN_EDITOR_SESSION_ID` as the `session` query parameter. For example:
89
+
90
+ ```text
91
+ GET /__open-in-editor?session=<checkout-id>&file=%2Fusr%2Fsrc%2Fweb%2Fapp.rb%3A12%3A4
92
+ ```
93
+
94
+ `GET /health` reports the broker PID, protocol, bind address, and active checkout IDs. `GET /__open-in-editor` spawns the registered editor with `--goto <translated-target>`, preserving optional `:line:column`. Container paths equal to or below `/usr/src/web` map to that checkout's host `web` directory. Other paths pass through unchanged. Query encoding is decoded exactly once, preserving literal percent sequences in filenames.
95
+
96
+ With exactly one checkout, existing links without `session` still work. With multiple checkouts, missing `session` returns HTTP 400 rather than guessing a checkout; unknown IDs return HTTP 404. Registering the same checkout with different roots or editor configuration while it is active is rejected; stop its existing leases before changing configuration.
40
97
 
41
98
  ## Configuration
42
99
 
43
- - `OPEN_IN_EDITOR_BRIDGE_PORT` (default `3333`)
44
- - `OPEN_IN_EDITOR_PROJECT_ROOT` (default current directory)
45
- - `OPEN_IN_EDITOR_CONTAINER_WEB_ROOT` (default `/usr/src/web`)
46
- - `OPEN_IN_EDITOR_HOST_WEB_ROOT` (default `<project-root>/web`)
47
- - `OPEN_IN_EDITOR_COMMAND` (or `EDITOR`)
100
+ | Variable | Default | Purpose |
101
+ |---|---|---|
102
+ | `OPEN_IN_EDITOR_BRIDGE_PORT` | `3333` | Shared host listener port |
103
+ | `OPEN_IN_EDITOR_BRIDGE_BIND_ADDRESS` | `127.0.0.1` | Listener address; `0.0.0.0` allows Docker IPv4 access |
104
+ | `OPEN_IN_EDITOR_PROJECT_ROOT` | Current directory | Checkout identity and default host mapping |
105
+ | `OPEN_IN_EDITOR_CONTAINER_WEB_ROOT` | `/usr/src/web` | Container directory to translate |
106
+ | `OPEN_IN_EDITOR_HOST_WEB_ROOT` | `<project-root>/web` | Corresponding host directory |
107
+ | `OPEN_IN_EDITOR_COMMAND` | `EDITOR` | Editor command, parsed with `Shellwords` |
108
+ | `OPEN_IN_EDITOR_BRIDGE_RUNTIME_DIR` | `<system-tmp>/open-in-editor-bridge-<uid>` | Shared private host runtime state |
109
+ | `OPEN_IN_EDITOR_BRIDGE_STARTUP_TIMEOUT` | `5` seconds | Startup/shutdown deadline |
110
+
111
+ The editor command is required when registering a checkout, but not for `--session-id` or `--shutdown`. Editors run in their registered checkout's project root, so relative commands such as `./bin/editor` resolve in the correct checkout. Runtime directories must be owned by the current user and private (`0700`). Broker state and its control token use `0600`; runtime files are keyed by port. Checkout IDs derive from the canonical absolute project root.
112
+
113
+ ## Security and Upgrade Notes
114
+
115
+ Loopback binding is the default. `0.0.0.0` also exposes the listener to other reachable network peers, not only Docker. Use it only on a trusted development network with host firewall restrictions; a specific Docker-reachable host interface can narrow exposure. Editor requests are intentionally unauthenticated and health exposes checkout IDs: anyone who can reach the listener can invoke configured editors. Do not expose it on an untrusted network or forward it publicly.
116
+
117
+ Registration and release use nonce-bound HMAC signatures derived from a random control token held in the private host runtime directory; the token is never sent over HTTP. Clients authenticate health and control responses and verify broker protocol/PID against shared state. The broker rejects replayed control requests. Shutdown uses authenticated HTTP control, never signals a PID read from disk. Unknown listeners, old bridge versions, mismatched PIDs, and mismatched bind configurations are not reused.
118
+
119
+ A failed or timed-out health probe does not discard a still-listening broker's credentials. Lifecycle operations fail explicitly until its identity can be verified; retry after the listener becomes responsive.
120
+
121
+ Version 0.2 replaces checkout-local PID/log files with shared runtime state. Stop any 0.1.x bridge using its original installed version before upgrading; it cannot share the new protocol. Sessions live in the broker's memory. After a broker crash or deliberate manual stop, rerun `--ensure-running` for detached applications. Abruptly killed foreground clients cannot execute `ensure`; stop that broker manually and re-register surviving checkouts if their leases become orphaned.
122
+
123
+ ## Development and Release
124
+
125
+ ```sh
126
+ bundle install
127
+ bundle exec rake test
128
+ gem build open-in-editor-bridge.gemspec
129
+ ```
48
130
 
49
- The editor command is required and is parsed with `Shellwords`.
131
+ Release changes on an issue-linked branch and pull request, including the version and changelog. Self-review and exercise real host/container lifecycle and editor invocation before merging. From synchronized `main`, build and push the gem with the maintainer's RubyGems credentials, then create a matching GitHub tag/release. Install the published version from RubyGems into a clean `GEM_HOME`, confirm `OpenInEditorBridge::VERSION`, and repeat foreground, detached, routing, and Docker-reachability checks. A recorded editor process proves invocation and arguments, not that an editor window rendered.
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ class OpenInEditorBridge
6
+ module Authentication
7
+ def self.signature(token, direction, nonce, method_or_status, path, body)
8
+ digest = OpenSSL::HMAC.new(token, "SHA256")
9
+ [direction, nonce, method_or_status, path, body].each do |part|
10
+ bytes = part.to_s
11
+ digest << [bytes.bytesize].pack("Q>") << bytes
12
+ end
13
+ digest.hexdigest
14
+ end
15
+
16
+ def self.valid_nonce?(nonce)
17
+ nonce.is_a?(String) && nonce.ascii_only? && nonce.match?(/\A[0-9a-f]{32}\z/)
18
+ end
19
+
20
+ def self.valid?(provided, token, *message)
21
+ return false unless provided.is_a?(String) && token.is_a?(String) && !token.empty?
22
+
23
+ expected = signature(token, *message)
24
+ return false unless provided.bytesize == expected.bytesize
25
+
26
+ OpenSSL.fixed_length_secure_compare(expected, provided)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "rbconfig"
5
+ require "securerandom"
6
+
7
+ class OpenInEditorBridge
8
+ class Client
9
+ def initialize(configuration)
10
+ @configuration = configuration
11
+ end
12
+
13
+ def register(lease)
14
+ session = @configuration.session
15
+ @configuration.synchronize do
16
+ state = compatible_state
17
+ state ||= start_broker
18
+ payload = control_request("/sessions", state, "session" => session, "lease" => lease)
19
+ result = payload.fetch("added") ? :started : :reused
20
+ puts "Editor session #{session.fetch("id")} #{result} on port #{@configuration.port}."
21
+ result
22
+ end
23
+ end
24
+
25
+ def release(lease)
26
+ @configuration.synchronize do
27
+ state = compatible_state
28
+ return unless state
29
+
30
+ payload = control_request("/release", state, "session_id" => @configuration.session_id, "lease" => lease)
31
+ wait_until_stopped(state.fetch("pid")) if payload.fetch("stopping")
32
+ end
33
+ end
34
+
35
+ def serve
36
+ server = @configuration.synchronize do
37
+ raise StartupError, "Editor bridge is already running" if compatible_state
38
+ token = SecureRandom.hex(32)
39
+ broker = Server.new(@configuration, token: token)
40
+ broker.start(initial_session: @configuration.session)
41
+ broker
42
+ end
43
+ server.serve
44
+ end
45
+
46
+ private
47
+
48
+ def health(state: nil)
49
+ nonce = SecureRandom.hex(16)
50
+ request = Net::HTTP::Get.new("/health")
51
+ request["X-Open-In-Editor-Nonce"] = nonce
52
+ response = http_request(request)
53
+ return nil unless response && response.code == "200" && response["X-Open-In-Editor-Bridge"] == Configuration::PROTOCOL.to_s
54
+ if state
55
+ return nil unless Authentication.valid?(response["X-Open-In-Editor-Signature"], state["token"],
56
+ "response", nonce, response.code, "/health", response.body)
57
+ end
58
+
59
+ payload = JSON.parse(response.body)
60
+ return nil unless payload.is_a?(Hash) && payload["ok"] == true && payload["protocol"] == Configuration::PROTOCOL
61
+ payload
62
+ rescue JSON::ParserError
63
+ nil
64
+ end
65
+
66
+ def compatible_state
67
+ state = @configuration.read_state
68
+ current_health = health(state: state)
69
+ if current_health
70
+ unless state && state["pid"] == current_health["pid"] && state["protocol"] == Configuration::PROTOCOL &&
71
+ state["bind_address"] == @configuration.bind_address && current_health["bind_address"] == @configuration.bind_address &&
72
+ state["token"].is_a?(String) && !state["token"].empty?
73
+ raise StartupError, "Listener identity or bind address does not match shared runtime state"
74
+ end
75
+ return state
76
+ end
77
+ if listener_present?
78
+ raise StartupError, "Port is occupied by an unknown or unresponsive listener; shared state preserved"
79
+ end
80
+ if state
81
+ # Never signal a PID read from disk. Only authenticated broker control can stop it.
82
+ @configuration.delete_state(expected_pid: state["pid"])
83
+ end
84
+ nil
85
+ end
86
+
87
+ def listener_present?
88
+ Socket.tcp(@configuration.connect_address, @configuration.port, connect_timeout: 0.5) { true }
89
+ rescue Errno::ECONNREFUSED
90
+ false
91
+ rescue SystemCallError, Timeout::Error
92
+ # An inconclusive connection failure is not proof that shared state is stale.
93
+ true
94
+ end
95
+
96
+ def start_broker
97
+ token = SecureRandom.hex(32)
98
+ library_directory = File.expand_path("..", __dir__)
99
+ script = "configuration = OpenInEditorBridge::Configuration.new(env: ENV); " \
100
+ "server = OpenInEditorBridge::Server.new(configuration, token: ENV.fetch('OPEN_IN_EDITOR_BRIDGE_CONTROL_TOKEN')); " \
101
+ "server.start; server.serve"
102
+ pid = Process.spawn(@configuration.server_environment(token), RbConfig.ruby, "-I", library_directory,
103
+ "-ropen_in_editor_bridge", "-e", script, out: @configuration.log_file, err: @configuration.log_file, pgroup: true)
104
+ # Keep the child waitable until readiness proves we own this live PID.
105
+ deadline = monotonic_time + @configuration.startup_timeout
106
+ loop do
107
+ raise StartupError, "Editor bridge failed to start. See #{@configuration.log_file}." if Process.waitpid(pid, Process::WNOHANG)
108
+ state = @configuration.read_state
109
+ current_health = health(state: state)
110
+ if state && state["pid"] == pid && state["token"] == token && current_health && current_health["pid"] == pid
111
+ Process.detach(pid)
112
+ return state
113
+ end
114
+ raise StartupError, "Editor bridge did not become ready. See #{@configuration.log_file}." if monotonic_time >= deadline
115
+ sleep 0.05
116
+ end
117
+ rescue StandardError
118
+ if pid
119
+ begin
120
+ unless Process.waitpid(pid, Process::WNOHANG)
121
+ Process.kill("TERM", pid)
122
+ Process.waitpid(pid)
123
+ end
124
+ rescue Errno::ECHILD, Errno::ESRCH
125
+ # An exited child is not a PID we still own.
126
+ end
127
+ @configuration.delete_state(expected_pid: pid)
128
+ end
129
+ raise
130
+ end
131
+
132
+ def control_request(path, state, payload)
133
+ nonce = SecureRandom.hex(16)
134
+ request = Net::HTTP::Post.new(path)
135
+ request["Content-Type"] = "application/json"
136
+ request.body = JSON.generate(payload)
137
+ request["X-Open-In-Editor-Nonce"] = nonce
138
+ request["X-Open-In-Editor-Signature"] = Authentication.signature(state.fetch("token"),
139
+ "request", nonce, "POST", path, request.body)
140
+ response = http_request(request)
141
+ unless response && Authentication.valid?(response["X-Open-In-Editor-Signature"], state.fetch("token"),
142
+ "response", nonce, response.code, path, response.body)
143
+ raise StartupError, "Bridge control response failed authentication"
144
+ end
145
+ unless response && response.code == "200"
146
+ raise StartupError, "Bridge session request failed: #{response&.code} #{response&.body}"
147
+ end
148
+ JSON.parse(response.body)
149
+ end
150
+
151
+ def http_request(request)
152
+ Net::HTTP.start(@configuration.connect_address, @configuration.port, nil,
153
+ open_timeout: 0.5, read_timeout: 0.5, write_timeout: 0.5) { |http| http.request(request) }
154
+ rescue IOError, SystemCallError, Timeout::Error
155
+ nil
156
+ end
157
+
158
+ def wait_until_stopped(pid)
159
+ deadline = monotonic_time + @configuration.startup_timeout
160
+ loop do
161
+ state = @configuration.read_state
162
+ return unless state && state["pid"] == pid
163
+ raise StartupError, "Editor bridge did not stop" if monotonic_time >= deadline
164
+ sleep 0.05
165
+ end
166
+ end
167
+
168
+ def monotonic_time
169
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "shellwords"
6
+ require "tmpdir"
7
+
8
+ class OpenInEditorBridge
9
+ class Configuration
10
+ PROTOCOL = 2
11
+
12
+ attr_reader :port, :bind_address, :connect_address, :runtime_directory, :startup_timeout, :session_id
13
+
14
+ def initialize(env:, project_root: nil)
15
+ @env = env
16
+ @project_root = File.expand_path(project_root || env.fetch("OPEN_IN_EDITOR_PROJECT_ROOT", Dir.pwd))
17
+ @project_root = File.realpath(@project_root) if File.directory?(@project_root)
18
+ @port = Integer(env.fetch("OPEN_IN_EDITOR_BRIDGE_PORT", "3333"))
19
+ @bind_address = env.fetch("OPEN_IN_EDITOR_BRIDGE_BIND_ADDRESS", "127.0.0.1")
20
+ @connect_address = { "0.0.0.0" => "127.0.0.1", "::" => "::1" }.fetch(@bind_address, @bind_address)
21
+ @runtime_directory = env.fetch("OPEN_IN_EDITOR_BRIDGE_RUNTIME_DIR", File.join(Dir.tmpdir, "open-in-editor-bridge-#{Process.uid}"))
22
+ @startup_timeout = Float(env.fetch("OPEN_IN_EDITOR_BRIDGE_STARTUP_TIMEOUT", "5"))
23
+ @session_id = Digest::SHA256.hexdigest(@project_root)
24
+ end
25
+
26
+ def session
27
+ command = @env.fetch("OPEN_IN_EDITOR_COMMAND", @env.fetch("EDITOR", "")).to_s
28
+ raise ArgumentError, "No editor configured. Set OPEN_IN_EDITOR_COMMAND or EDITOR." if Shellwords.split(command).empty?
29
+
30
+ {
31
+ "id" => session_id,
32
+ "working_directory" => @project_root,
33
+ "container_root" => @env.fetch("OPEN_IN_EDITOR_CONTAINER_WEB_ROOT", "/usr/src/web").delete_suffix("/"),
34
+ "host_root" => File.expand_path(@env.fetch("OPEN_IN_EDITOR_HOST_WEB_ROOT", File.join(@project_root, "web"))),
35
+ "editor_command" => command,
36
+ }
37
+ end
38
+
39
+ def state_file
40
+ File.join(runtime_directory, "#{port}.json")
41
+ end
42
+
43
+ def log_file
44
+ File.join(runtime_directory, "#{port}.log")
45
+ end
46
+
47
+ def synchronize
48
+ ensure_runtime_directory
49
+ File.open(File.join(runtime_directory, "#{port}.lock"), File::RDWR | File::CREAT, 0o600) do |lock|
50
+ lock.flock(File::LOCK_EX)
51
+ yield
52
+ ensure
53
+ lock.flock(File::LOCK_UN)
54
+ end
55
+ end
56
+
57
+ def ensure_runtime_directory
58
+ begin
59
+ Dir.mkdir(runtime_directory, 0o700)
60
+ rescue Errno::EEXIST
61
+ # Shared by all checkout clients of this user.
62
+ end
63
+ stat = File.lstat(runtime_directory)
64
+ unless stat.directory? && stat.uid == Process.uid && (stat.mode & 0o077).zero?
65
+ raise StartupError, "Bridge runtime directory must be owned by this user and private (0700): #{runtime_directory}"
66
+ end
67
+ end
68
+
69
+ def read_state
70
+ state = JSON.parse(File.read(state_file))
71
+ state if state.is_a?(Hash)
72
+ rescue Errno::ENOENT, JSON::ParserError
73
+ nil
74
+ end
75
+
76
+ def write_state(token)
77
+ state = { "pid" => Process.pid, "token" => token, "protocol" => PROTOCOL, "bind_address" => bind_address }
78
+ File.open(state_file, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |file| file.write(JSON.generate(state)) }
79
+ end
80
+
81
+ def delete_state(expected_pid:)
82
+ File.delete(state_file) if read_state&.fetch("pid", nil) == expected_pid
83
+ rescue Errno::ENOENT
84
+ nil
85
+ end
86
+
87
+ def server_environment(token)
88
+ {
89
+ "OPEN_IN_EDITOR_BRIDGE_PORT" => port.to_s,
90
+ "OPEN_IN_EDITOR_BRIDGE_BIND_ADDRESS" => bind_address,
91
+ "OPEN_IN_EDITOR_BRIDGE_RUNTIME_DIR" => runtime_directory,
92
+ "OPEN_IN_EDITOR_BRIDGE_CONTROL_TOKEN" => token,
93
+ }
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "timeout"
5
+ require "uri"
6
+
7
+ class OpenInEditorBridge
8
+ class Server
9
+ RESPONSE_PHRASES = {
10
+ 200 => "OK", 400 => "Bad Request", 403 => "Forbidden", 404 => "Not Found",
11
+ 405 => "Method Not Allowed", 409 => "Conflict", 500 => "Internal Server Error",
12
+ }.freeze
13
+
14
+ def initialize(configuration, token:)
15
+ @configuration = configuration
16
+ @token = token
17
+ # Only this broker mutates sessions; each lease represents one live client.
18
+ @sessions = {}
19
+ # Accepted control nonces cannot be replayed during this broker's lifetime.
20
+ @used_nonces = {}
21
+ end
22
+
23
+ def start(initial_session: nil)
24
+ @listener = TCPServer.new(@configuration.bind_address, @configuration.port)
25
+ @sessions[initial_session.fetch("id")] = initial_session.merge("leases" => ["detached"]) if initial_session
26
+ @configuration.write_state(@token)
27
+ @running = true
28
+ rescue StandardError
29
+ @listener&.close
30
+ raise
31
+ end
32
+
33
+ def serve
34
+ trap("INT") { stop }
35
+ trap("TERM") { stop }
36
+ while @running
37
+ begin
38
+ socket = @listener.accept
39
+ handle_request(socket)
40
+ rescue IOError, Errno::EBADF
41
+ break unless @running
42
+ raise
43
+ end
44
+ end
45
+ ensure
46
+ @listener&.close unless @listener&.closed?
47
+ @configuration.delete_state(expected_pid: Process.pid)
48
+ end
49
+
50
+ private
51
+
52
+ def stop
53
+ @running = false
54
+ @listener.close unless @listener.closed?
55
+ end
56
+
57
+ def handle_request(socket)
58
+ Timeout.timeout(2) do
59
+ method, target, headers, body = read_request(socket)
60
+ path, query = target.split("?", 2)
61
+ params = URI.decode_www_form(query.to_s).to_h
62
+ case path
63
+ when "/health"
64
+ if method == "GET"
65
+ nonce = headers["x-open-in-editor-nonce"]
66
+ if nonce && !Authentication.valid_nonce?(nonce)
67
+ return respond(socket, 400, { "error" => "Invalid health nonce" })
68
+ end
69
+ respond(socket, 200, { "ok" => true, "pid" => Process.pid, "protocol" => Configuration::PROTOCOL,
70
+ "bind_address" => @configuration.bind_address, "sessions" => @sessions.keys },
71
+ nonce: nonce, path: path)
72
+ else
73
+ respond(socket, 405, { "error" => "Method not allowed" })
74
+ end
75
+ when "/__open-in-editor"
76
+ method == "GET" ? open_in_editor(socket, params) : respond(socket, 405, { "error" => "Method not allowed" })
77
+ when "/sessions", "/release"
78
+ control_request(socket, method, path, headers, body)
79
+ else
80
+ respond(socket, 404, { "error" => "Not found" })
81
+ end
82
+ end
83
+ rescue ArgumentError, JSON::ParserError, EOFError => error
84
+ respond(socket, 400, { "error" => "Bad request: #{error.message}" })
85
+ rescue Timeout::Error, Errno::EPIPE, Errno::ECONNRESET
86
+ nil
87
+ ensure
88
+ socket.close unless socket.closed?
89
+ end
90
+
91
+ def read_request(socket)
92
+ line = socket.gets("\n", 8192)
93
+ raise EOFError, "Missing request line" unless line
94
+ method, target, = line.split(" ")
95
+ raise ArgumentError, "Missing request target" unless target
96
+
97
+ headers = {}
98
+ header_bytes = 0
99
+ loop do
100
+ line = socket.gets("\n", 8192)
101
+ raise EOFError, "Incomplete headers" unless line
102
+ header_bytes += line.bytesize
103
+ raise ArgumentError, "Headers too large" if header_bytes > 65_536
104
+ break if line == "\r\n"
105
+
106
+ name, value = line.split(":", 2)
107
+ raise ArgumentError, "Invalid header" unless value
108
+ headers[name.downcase] = value.strip
109
+ end
110
+ length = Integer(headers.fetch("content-length", "0"))
111
+ raise ArgumentError, "Invalid content length" unless (0..65_536).cover?(length)
112
+ body = length.zero? ? "" : socket.read(length)
113
+ raise EOFError, "Incomplete body" unless body && body.bytesize == length
114
+ [method, target, headers, body]
115
+ end
116
+
117
+ def control_request(socket, method, path, headers, body)
118
+ return respond(socket, 405, { "error" => "Method not allowed" }) unless method == "POST"
119
+ nonce = headers["x-open-in-editor-nonce"]
120
+ valid_nonce = Authentication.valid_nonce?(nonce) && !@used_nonces.key?(nonce)
121
+ return respond(socket, 403, { "error" => "Forbidden" }) unless valid_nonce
122
+
123
+ valid_signature = Authentication.valid?(headers["x-open-in-editor-signature"], @token,
124
+ "request", nonce, method, path, body)
125
+ return respond(socket, 403, { "error" => "Forbidden" }) unless valid_signature
126
+
127
+ @used_nonces[nonce] = true
128
+ payload = JSON.parse(body)
129
+ path == "/sessions" ? register(socket, payload, nonce) : release(socket, payload, nonce)
130
+ end
131
+
132
+ def register(socket, payload, nonce)
133
+ session = payload.fetch("session")
134
+ id = session.fetch("id")
135
+ lease = payload.fetch("lease")
136
+ existing = @sessions[id]
137
+ if existing && existing.reject { |key, _| key == "leases" } != session
138
+ return respond(socket, 409, { "error" => "Checkout already registered with different editor configuration" },
139
+ nonce: nonce, path: "/sessions")
140
+ end
141
+ existing ||= @sessions[id] = session.merge("leases" => [])
142
+ added = !existing.fetch("leases").include?(lease)
143
+ existing.fetch("leases") << lease if added
144
+ respond(socket, 200, { "ok" => true, "added" => added }, nonce: nonce, path: "/sessions")
145
+ end
146
+
147
+ def release(socket, payload, nonce)
148
+ id = payload.fetch("session_id")
149
+ session = @sessions[id]
150
+ removed = session && session.fetch("leases").delete(payload.fetch("lease"))
151
+ @sessions.delete(id) if session && session.fetch("leases").empty?
152
+ stopping = !!removed && @sessions.empty?
153
+ respond(socket, 200, { "ok" => true, "stopping" => stopping }, nonce: nonce, path: "/release")
154
+ stop if stopping
155
+ end
156
+
157
+ def open_in_editor(socket, params)
158
+ file = params["file"]
159
+ return respond(socket, 400, { "error" => "Missing required query parameter: file" }) if file.nil? || file.empty?
160
+
161
+ id = params["session"]
162
+ if id.nil?
163
+ return respond(socket, 400, { "error" => "Specify session when multiple checkouts are registered" }) unless @sessions.size == 1
164
+ id = @sessions.keys.first
165
+ end
166
+ session = @sessions[id]
167
+ return respond(socket, 404, { "error" => "Unknown editor session" }) unless session
168
+
169
+ translated_target = translate_target(file, session)
170
+ editor_command = session.fetch("editor_command")
171
+ pid = Process.spawn(*Shellwords.split(editor_command), "--goto", translated_target,
172
+ chdir: session.fetch("working_directory"))
173
+ Process.detach(pid)
174
+ respond(socket, 200, { "ok" => true, "requestedFile" => file, "translatedTarget" => translated_target,
175
+ "editorCommand" => editor_command, "session" => id })
176
+ rescue StandardError => error
177
+ respond(socket, 500, { "ok" => false, "error" => "Failed to open editor: #{error.message}",
178
+ "requestedFile" => file, "translatedTarget" => translated_target, "editorCommand" => editor_command })
179
+ end
180
+
181
+ def translate_target(target, session)
182
+ # Query parameters have already been decoded; literal percent signs are filenames.
183
+ match = target.match(/\A(.+?):(\d+)(?::(\d+))?\z/)
184
+ path = match ? match[1] : target
185
+ location = match ? ":#{[match[2], match[3]].compact.join(":")}" : ""
186
+ root = session.fetch("container_root")
187
+ if path == root || path.start_with?("#{root}/")
188
+ path = "#{session.fetch("host_root")}#{path.delete_prefix(root)}"
189
+ end
190
+ "#{path}#{location}"
191
+ end
192
+
193
+ def respond(socket, status, payload, nonce: nil, path: nil)
194
+ body = JSON.generate(payload)
195
+ signature_header = ""
196
+ if nonce
197
+ signature = Authentication.signature(@token, "response", nonce, status, path, body)
198
+ signature_header = "X-Open-In-Editor-Signature: #{signature}\r\n"
199
+ end
200
+ socket.write("HTTP/1.1 #{status} #{RESPONSE_PHRASES.fetch(status)}\r\n" \
201
+ "Content-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\n" \
202
+ "X-Open-In-Editor-Bridge: #{Configuration::PROTOCOL}\r\n#{signature_header}" \
203
+ "Content-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}")
204
+ rescue Errno::EPIPE, Errno::ECONNRESET
205
+ # A disconnected HTTP client must not take down other checkout sessions.
206
+ nil
207
+ end
208
+ end
209
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class OpenInEditorBridge
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -1,26 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
- require "json"
5
- require "rbconfig"
6
- require "shellwords"
7
- require "socket"
8
- require "timeout"
9
- require "uri"
10
3
  require_relative "open_in_editor_bridge/version"
11
4
 
12
5
  class OpenInEditorBridge
13
- DEFAULT_PORT = 3333
14
- HEALTH_PATH = "/health"
15
- OPEN_IN_EDITOR_PATH = "/__open-in-editor"
16
- RESPONSE_PHRASES = {
17
- 200 => "OK",
18
- 400 => "Bad Request",
19
- 404 => "Not Found",
20
- 405 => "Method Not Allowed",
21
- 500 => "Internal Server Error",
22
- }.freeze
23
-
24
6
  class StartupError < StandardError
25
7
  end
26
8
 
@@ -28,324 +10,64 @@ class OpenInEditorBridge
28
10
  new.call(*args)
29
11
  end
30
12
 
13
+ def self.session_id
14
+ new.session_id
15
+ end
16
+
31
17
  def self.with_running(ensure_running: true)
32
- owns_server = !ensure_running
33
- owns_server = call("--ensure-running") == :started if ensure_running
18
+ bridge = new
19
+ lease = SecureRandom.hex(16) if ensure_running
20
+ bridge.start_session(lease) if ensure_running
34
21
 
35
22
  begin
36
- yield
23
+ yield bridge.session_id
37
24
  ensure
38
- if owns_server
39
- block_error = $!
40
- begin
41
- call("--shutdown")
42
- rescue StandardError => cleanup_error
43
- raise cleanup_error unless block_error
44
- end
25
+ block_error = $!
26
+ begin
27
+ ensure_running ? bridge.stop_session(lease) : bridge.call("--shutdown")
28
+ rescue StandardError => cleanup_error
29
+ raise cleanup_error unless block_error
45
30
  end
46
31
  end
47
32
  end
48
33
 
49
34
  def initialize(env: ENV, project_root: nil)
50
- @env = env
51
- @project_root = project_root
35
+ @configuration = Configuration.new(env: env, project_root: project_root)
36
+ @client = Client.new(@configuration)
37
+ end
38
+
39
+ def session_id
40
+ @configuration.session_id
52
41
  end
53
42
 
54
43
  def call(*args)
55
44
  case args.first || "--serve"
56
45
  when "--ensure-running"
57
- ensure_running
46
+ start_session("detached")
58
47
  when "--shutdown"
59
- shutdown
48
+ stop_session("detached")
49
+ when "--session-id"
50
+ puts session_id
51
+ session_id
60
52
  when "--serve"
61
- serve
53
+ @client.serve
62
54
  else
63
- serve
64
- end
65
- end
66
-
67
- private
68
-
69
- def ensure_running
70
- pid = running_pid(validate_health: true)
71
- if pid
72
- puts "Editor bridge already running on port #{config[:port]} (pid #{pid})."
73
- return :reused
74
- end
75
-
76
- ensure_runtime_directory
77
- pid = Process.spawn(
78
- *server_process_command,
79
- out: config[:log_file],
80
- err: config[:log_file],
81
- pgroup: true
82
- )
83
- Process.detach(pid)
84
-
85
- wait_until_ready(pid)
86
- write_pid_file(pid)
87
- puts "Started editor bridge on port #{config[:port]}."
88
- :started
89
- rescue StandardError
90
- if pid && process_running?(pid)
91
- begin
92
- Process.kill("TERM", -pid)
93
- rescue Errno::ESRCH
94
- end
55
+ raise ArgumentError, "Unknown command: #{args.first}"
95
56
  end
96
- delete_pid_file
97
- raise
98
57
  end
99
58
 
100
- def shutdown
101
- pid = running_pid(validate_health: true)
102
- return unless pid
103
-
104
- Process.kill("TERM", -pid)
105
- wait_until_stopped(pid)
106
- delete_pid_file
107
- puts "Stopped editor bridge."
108
- rescue Errno::ESRCH
109
- delete_pid_file
59
+ def start_session(lease)
60
+ @client.register(lease)
110
61
  end
111
62
 
112
- def serve
113
- abort("No editor configured. Set OPEN_IN_EDITOR_COMMAND or EDITOR environment variable.") if editor_command.empty?
114
-
115
- ensure_runtime_directory
116
- write_pid_file(Process.pid)
117
- @server = TCPServer.new("127.0.0.1", config[:port])
118
- @running = true
119
- install_signal_handlers
120
-
121
- while @running
122
- begin
123
- socket = @server.accept
124
- handle_request(socket)
125
- rescue IOError, Errno::EBADF
126
- break
127
- end
128
- end
129
- ensure
130
- @server&.close
131
- delete_pid_file
132
- end
133
-
134
- def handle_request(socket)
135
- request = read_request(socket)
136
- return if request.nil?
137
-
138
- method = request.fetch(:method)
139
- consume_headers(socket)
140
- return write_json_response(socket, status: 405, payload: { error: "Method not allowed" }) unless method == "GET"
141
-
142
- respond_to_get_request(socket, request.fetch(:path), request.fetch(:params))
143
- ensure
144
- socket.close unless socket.closed?
145
- end
146
-
147
- def open_in_editor(socket, params)
148
- file = params["file"]
149
- if file.nil? || file.empty?
150
- return write_json_response(
151
- socket,
152
- status: 400,
153
- payload: { error: "Missing required query parameter: file" }
154
- )
155
- end
156
-
157
- translated_target = translate_target(file)
158
- pid = Process.spawn(*editor_command_args, "--goto", translated_target)
159
- Process.detach(pid)
160
-
161
- write_json_response(
162
- socket,
163
- status: 200,
164
- payload: {
165
- ok: true,
166
- requestedFile: file,
167
- translatedTarget: translated_target,
168
- editorCommand: editor_command,
169
- }
170
- )
171
- rescue StandardError => error
172
- write_json_response(
173
- socket,
174
- status: 500,
175
- payload: {
176
- ok: false,
177
- error: "Failed to open editor: #{error.message}",
178
- requestedFile: file,
179
- translatedTarget: translated_target,
180
- editorCommand: editor_command,
181
- }
182
- )
183
- end
184
-
185
- def write_json_response(socket, status: 200, payload:)
186
- body = JSON.generate(payload)
187
- socket.write("HTTP/1.1 #{status} #{RESPONSE_PHRASES.fetch(status)}\r\n")
188
- socket.write("Content-Type: application/json\r\n")
189
- socket.write("Access-Control-Allow-Origin: *\r\n")
190
- socket.write("X-Open-In-Editor-Bridge: 1\r\n")
191
- socket.write("Content-Length: #{body.bytesize}\r\n")
192
- socket.write("Connection: close\r\n")
193
- socket.write("\r\n")
194
- socket.write(body)
195
- end
196
-
197
- def stop_server
198
- @running = false
199
- @server&.close
200
- end
201
-
202
- def ensure_runtime_directory
203
- FileUtils.mkdir_p(File.dirname(config[:pid_file]))
204
- end
205
-
206
- def write_pid_file(pid)
207
- File.write(config[:pid_file], "#{pid}\n")
208
- end
209
-
210
- def delete_pid_file
211
- File.delete(config[:pid_file]) if File.exist?(config[:pid_file])
212
- end
213
-
214
- def server_process_command
215
- [RbConfig.ruby, File.expand_path("../exe/open-in-editor-bridge", __dir__), "--serve"]
216
- end
217
-
218
- def editor_command
219
- @env.fetch("OPEN_IN_EDITOR_COMMAND", @env.fetch("EDITOR", "")).to_s
220
- end
221
-
222
- def editor_command_args
223
- Shellwords.split(editor_command)
224
- end
225
-
226
- def install_signal_handlers
227
- trap("INT") { stop_server }
228
- trap("TERM") { stop_server }
229
- end
230
-
231
- def read_request(socket)
232
- request_line = socket.gets
233
- return nil if request_line.nil?
234
-
235
- method, raw_target, = request_line.split(" ")
236
- return nil if method.nil? || raw_target.nil?
237
-
238
- path, query = raw_target.split("?", 2)
239
- params = URI.decode_www_form(query.to_s).to_h
240
- { method: method, path: path, params: params }
241
- rescue ArgumentError
242
- write_json_response(socket, status: 400, payload: { error: "Bad request" })
243
- nil
244
- end
245
-
246
- def consume_headers(socket)
247
- loop do
248
- line = socket.gets
249
- break if line.nil? || line == "\r\n"
250
- end
251
- end
252
-
253
- def respond_to_get_request(socket, path, params)
254
- case path
255
- when HEALTH_PATH
256
- write_json_response(socket, payload: { ok: true, pid: Process.pid })
257
- when OPEN_IN_EDITOR_PATH
258
- open_in_editor(socket, params)
259
- else
260
- write_json_response(socket, status: 404, payload: { error: "Not found" })
261
- end
262
- end
263
-
264
- def translate_target(target)
265
- path, location = split_target(target)
266
- translated_path = path.sub(/\A#{Regexp.escape(config[:container_web_root])}/, config[:host_web_root])
267
- return translated_path if location.nil?
268
-
269
- "#{translated_path}:#{location}"
270
- end
271
-
272
- def split_target(target)
273
- decoded_target = URI.decode_www_form_component(target)
274
- match = decoded_target.match(/\A(.+?):(\d+)(?::(\d+))?\z/)
275
- return [decoded_target, nil] if match.nil?
276
-
277
- [match[1], [match[2], match[3]].compact.join(":")]
278
- end
279
-
280
- def running_pid(validate_health: false)
281
- return nil unless File.exist?(config[:pid_file])
282
-
283
- pid = Integer(File.read(config[:pid_file]).strip)
284
- return pid if process_running?(pid) && (!validate_health || bridge_healthy?(expected_pid: pid))
285
-
286
- delete_pid_file
287
- nil
288
- rescue ArgumentError
289
- delete_pid_file
290
- nil
291
- end
292
-
293
- def process_running?(pid)
294
- Process.kill(0, pid)
295
- true
296
- rescue Errno::ESRCH, Errno::EPERM
297
- false
298
- end
299
-
300
- def wait_until_ready(pid)
301
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + config[:startup_timeout]
302
- loop do
303
- raise StartupError, "Editor bridge failed to start. See #{config[:log_file]}." unless process_running?(pid)
304
- return if bridge_healthy?(expected_pid: pid)
305
-
306
- raise StartupError, "Editor bridge did not become ready." if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
307
- sleep 0.05
308
- end
309
- end
310
-
311
- def wait_until_stopped(pid)
312
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + config[:startup_timeout]
313
- until !process_running?(pid) && !bridge_healthy?
314
- break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
315
-
316
- sleep 0.05
317
- end
318
- end
319
-
320
- def bridge_healthy?(expected_pid: nil)
321
- socket = TCPSocket.new("127.0.0.1", config[:port])
322
- response = Timeout.timeout(0.25) do
323
- socket.write("GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
324
- socket.read
325
- end
326
- headers, body = response.split("\r\n\r\n", 2)
327
- payload = JSON.parse(body.to_s)
328
- headers&.start_with?("HTTP/1.1 200 ") &&
329
- headers.lines.any? { |line| line.casecmp("X-Open-In-Editor-Bridge: 1\r\n").zero? } &&
330
- payload.fetch("ok") == true &&
331
- (expected_pid.nil? || payload.fetch("pid") == expected_pid)
332
- rescue Errno::ECONNREFUSED, JSON::ParserError, KeyError, Timeout::Error
333
- false
334
- ensure
335
- socket&.close unless socket&.closed?
336
- end
337
-
338
- def config
339
- root = @project_root || @env.fetch("OPEN_IN_EDITOR_PROJECT_ROOT", Dir.pwd)
340
- {
341
- port: Integer(@env.fetch("OPEN_IN_EDITOR_BRIDGE_PORT", DEFAULT_PORT)),
342
- pid_file: File.join(root, "tmp", "open-in-editor-bridge.pid"),
343
- log_file: File.join(root, "tmp", "open-in-editor-bridge.log"),
344
- container_web_root: @env.fetch("OPEN_IN_EDITOR_CONTAINER_WEB_ROOT", "/usr/src/web"),
345
- host_web_root: @env.fetch("OPEN_IN_EDITOR_HOST_WEB_ROOT", File.join(root, "web")),
346
- startup_timeout: Float(@env.fetch("OPEN_IN_EDITOR_BRIDGE_STARTUP_TIMEOUT", "5")),
347
- }
63
+ def stop_session(lease)
64
+ @client.release(lease)
348
65
  end
349
66
  end
350
67
 
68
+ require_relative "open_in_editor_bridge/configuration"
69
+ require_relative "open_in_editor_bridge/authentication"
70
+ require_relative "open_in_editor_bridge/server"
71
+ require_relative "open_in_editor_bridge/client"
72
+
351
73
  OpenInEditorBridge.call(*ARGV) if $PROGRAM_NAME == __FILE__
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: open-in-editor-bridge
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marlen Brunner
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-28 00:00:00.000000000 Z
11
+ date: 2026-10-09 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Ruby API and CLI for serving local open-in-editor requests during development.
14
14
  email:
@@ -18,16 +18,22 @@ executables:
18
18
  extensions: []
19
19
  extra_rdoc_files: []
20
20
  files:
21
+ - CHANGELOG.md
21
22
  - LICENSE.txt
22
23
  - README.md
23
24
  - exe/open-in-editor-bridge
24
25
  - lib/open_in_editor_bridge.rb
26
+ - lib/open_in_editor_bridge/authentication.rb
27
+ - lib/open_in_editor_bridge/client.rb
28
+ - lib/open_in_editor_bridge/configuration.rb
29
+ - lib/open_in_editor_bridge/server.rb
25
30
  - lib/open_in_editor_bridge/version.rb
26
31
  homepage: https://github.com/klondikemarlen/open-in-editor-bridge
27
32
  licenses:
28
33
  - MIT
29
34
  metadata:
30
35
  bug_tracker_uri: https://github.com/klondikemarlen/open-in-editor-bridge/issues
36
+ changelog_uri: https://github.com/klondikemarlen/open-in-editor-bridge/blob/main/CHANGELOG.md
31
37
  source_code_uri: https://github.com/klondikemarlen/open-in-editor-bridge
32
38
  post_install_message:
33
39
  rdoc_options: []