libbeachcomber 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 828cc2e242605a1780d4fe4bb1dcd860b1ff8055af6e1fe468d77d8277410f08
4
+ data.tar.gz: 052b6205a895eaccbb65c86257dcaaa427ee651a7446cb0afcf81dd71507f706
5
+ SHA512:
6
+ metadata.gz: 52eee270039542c05ece9bcbf63c5057f915b1fa56be399bfb2c1e38d3c6c4381d37b1bd0cdd4634380bcc5d97f1b8071ed8fae7abf75d213dc9e47ac41d677d
7
+ data.tar.gz: 64608926fdca4fb02c8fdbbc943f15d4219a5fd2051818a6c84c58d6f9a3bd5a0b04cc3d9fb9e3bbd2fa4498c901e012d3f050cc0371c15036f869d14b592c58
@@ -0,0 +1,220 @@
1
+ require 'socket'
2
+ require 'json'
3
+
4
+ require_relative 'discovery'
5
+ require_relative 'errors'
6
+ require_relative 'result'
7
+
8
+ module Beachcomber
9
+ DEFAULT_TIMEOUT = 0.1 # seconds (100 ms)
10
+
11
+ # Session holds a persistent connection to the daemon and sends multiple
12
+ # requests over the same socket.
13
+ #
14
+ # Obtain a Session via {Client#session}:
15
+ #
16
+ # client.session do |s|
17
+ # s.set_context('/repo')
18
+ # r = s.get('git.branch')
19
+ # end
20
+ #
21
+ # Not thread-safe; use one session per thread.
22
+ class Session
23
+ def initialize(socket, timeout)
24
+ @socket = socket
25
+ @timeout = timeout
26
+ end
27
+
28
+ # Sets the default path for subsequent queries on this connection.
29
+ #
30
+ # @param path [String]
31
+ def set_context(path)
32
+ roundtrip({ op: 'context', path: path })
33
+ nil
34
+ end
35
+
36
+ # Reads a cached value.
37
+ #
38
+ # @param key [String] e.g. "git.branch" or "git"
39
+ # @param path [String, nil] optional working-directory override
40
+ # @return [Result]
41
+ def get(key, path: nil)
42
+ req = { op: 'get', key: key }
43
+ req[:path] = path if path
44
+ roundtrip(req)
45
+ end
46
+
47
+ # Forces the daemon to recompute a provider/key.
48
+ #
49
+ # @param key [String]
50
+ # @param path [String, nil]
51
+ def poke(key, path: nil)
52
+ req = { op: 'poke', key: key }
53
+ req[:path] = path if path
54
+ roundtrip(req)
55
+ nil
56
+ end
57
+
58
+ # Lists available providers.
59
+ #
60
+ # @return [Result]
61
+ def list
62
+ roundtrip({ op: 'list' })
63
+ end
64
+
65
+ # Returns daemon status.
66
+ #
67
+ # @return [Result]
68
+ def status
69
+ roundtrip({ op: 'status' })
70
+ end
71
+
72
+ # Closes the underlying socket connection.
73
+ def close
74
+ @socket.close unless @socket.closed?
75
+ end
76
+
77
+ private
78
+
79
+ def roundtrip(req)
80
+ line = JSON.generate(req) + "\n"
81
+ @socket.write(line)
82
+ raw = @socket.gets
83
+ raise ProtocolError, "connection closed before response" if raw.nil?
84
+
85
+ parse_response(raw.chomp)
86
+ end
87
+
88
+ def parse_response(raw)
89
+ begin
90
+ resp = JSON.parse(raw)
91
+ rescue JSON::ParserError => e
92
+ raise ProtocolError, "malformed JSON: #{e.message}"
93
+ end
94
+
95
+ unless resp.is_a?(Hash)
96
+ raise ProtocolError, "expected JSON object, got #{resp.class}"
97
+ end
98
+
99
+ ok = resp['ok']
100
+ data = resp['data']
101
+ age_ms = (resp['age_ms'] || 0).to_i
102
+ stale = resp['stale'] == true
103
+ error = resp['error']
104
+
105
+ unless ok
106
+ raise ServerError, (error || 'unknown error')
107
+ end
108
+
109
+ Result.new(ok: ok, data: data, age_ms: age_ms, stale: stale, error: error)
110
+ end
111
+ end
112
+
113
+ # Client sends individual requests, opening a fresh socket connection for
114
+ # each call. For workloads that issue many queries per invocation, use
115
+ # {#session} to reuse a persistent connection.
116
+ #
117
+ # Examples:
118
+ #
119
+ # client = Beachcomber::Client.new
120
+ # result = client.get('git.branch', path: '/repo')
121
+ # puts result.data if result.hit?
122
+ class Client
123
+ # @param socket_path [String, nil] explicit socket path; auto-discovered when nil
124
+ # @param timeout [Numeric] connect/read timeout in seconds (default 0.1)
125
+ def initialize(socket_path: nil, timeout: DEFAULT_TIMEOUT)
126
+ @socket_path = socket_path || Discovery.socket_path
127
+ @timeout = timeout
128
+ end
129
+
130
+ # Reads a cached value.
131
+ #
132
+ # @param key [String] e.g. "git.branch" or "git"
133
+ # @param path [String, nil] optional working-directory context
134
+ # @return [Result]
135
+ # @raise [DaemonNotRunning] when the socket cannot be reached
136
+ # @raise [ServerError] when the daemon returns ok: false
137
+ def get(key, path: nil)
138
+ req = { op: 'get', key: key }
139
+ req[:path] = path if path
140
+ roundtrip(req)
141
+ end
142
+
143
+ # Forces the daemon to recompute a provider/key.
144
+ #
145
+ # @param key [String]
146
+ # @param path [String, nil]
147
+ # @raise [DaemonNotRunning]
148
+ # @raise [ServerError]
149
+ def poke(key, path: nil)
150
+ req = { op: 'poke', key: key }
151
+ req[:path] = path if path
152
+ roundtrip(req)
153
+ nil
154
+ end
155
+
156
+ # Lists available providers registered with the daemon.
157
+ #
158
+ # @return [Result]
159
+ def list
160
+ roundtrip({ op: 'list' })
161
+ end
162
+
163
+ # Returns scheduler and cache status from the daemon.
164
+ #
165
+ # @return [Result]
166
+ def status
167
+ roundtrip({ op: 'status' })
168
+ end
169
+
170
+ # Opens a persistent session and yields it to the block. The connection is
171
+ # closed automatically when the block returns (even on exception).
172
+ #
173
+ # @yield [Session]
174
+ # @return the block's return value
175
+ def session
176
+ sock = dial
177
+ session = Session.new(sock, @timeout)
178
+ yield session
179
+ ensure
180
+ session&.close
181
+ end
182
+
183
+ private
184
+
185
+ def roundtrip(req)
186
+ sock = dial
187
+ begin
188
+ s = Session.new(sock, @timeout)
189
+ s.send(:roundtrip, req)
190
+ ensure
191
+ sock.close unless sock.closed?
192
+ end
193
+ end
194
+
195
+ def dial
196
+ sock = Socket.new(:UNIX, :STREAM)
197
+ addr = Socket.pack_sockaddr_un(@socket_path)
198
+
199
+ # Apply timeout to both connect and subsequent reads/writes.
200
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDTIMEO, timeval(@timeout))
201
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, timeval(@timeout))
202
+
203
+ begin
204
+ sock.connect(addr)
205
+ rescue Errno::ENOENT, Errno::ECONNREFUSED, Errno::EACCES
206
+ sock.close
207
+ raise DaemonNotRunning.new(@socket_path)
208
+ end
209
+
210
+ sock
211
+ end
212
+
213
+ # Packs a Float (seconds) into the C timeval structure expected by setsockopt.
214
+ def timeval(seconds)
215
+ secs = seconds.to_i
216
+ usecs = ((seconds - secs) * 1_000_000).to_i
217
+ [secs, usecs].pack('l_2')
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,25 @@
1
+ require 'etc'
2
+
3
+ module Beachcomber
4
+ # Discovers the Unix socket path for the beachcomber daemon.
5
+ #
6
+ # Discovery order:
7
+ # 1. $XDG_RUNTIME_DIR/beachcomber/sock (if set and the path exists)
8
+ # 2. $TMPDIR/beachcomber-<uid>/sock
9
+ # 3. /tmp/beachcomber-<uid>/sock
10
+ module Discovery
11
+ # @return [String] the discovered or best-guess socket path
12
+ def self.socket_path
13
+ xdg = ENV['XDG_RUNTIME_DIR']
14
+ if xdg && !xdg.empty?
15
+ candidate = File.join(xdg, 'beachcomber', 'sock')
16
+ return candidate if File.exist?(candidate)
17
+ end
18
+
19
+ uid = Process.uid
20
+ dir = "beachcomber-#{uid}"
21
+ base = ENV.fetch('TMPDIR', nil) || '/tmp'
22
+ File.join(base, dir, 'sock')
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ module Beachcomber
2
+ # Base class for all Beachcomber errors.
3
+ class Error < StandardError; end
4
+
5
+ # Raised when the daemon socket cannot be reached.
6
+ class DaemonNotRunning < Error
7
+ def initialize(socket_path)
8
+ super("beachcomber daemon is not running (socket: #{socket_path})")
9
+ end
10
+ end
11
+
12
+ # Raised when the daemon responds with ok: false.
13
+ class ServerError < Error
14
+ attr_reader :message
15
+
16
+ def initialize(message)
17
+ @message = message
18
+ super("beachcomber: daemon error: #{message}")
19
+ end
20
+ end
21
+
22
+ # Raised when a response cannot be parsed.
23
+ class ProtocolError < Error
24
+ def initialize(detail)
25
+ super("beachcomber: protocol error: #{detail}")
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,81 @@
1
+ module Beachcomber
2
+ # Wraps a daemon response.
3
+ #
4
+ # A result can represent a cache hit (data present), a miss (ok but no data),
5
+ # or an error (ok: false — though errors are normally raised as ServerError).
6
+ #
7
+ # Examples:
8
+ #
9
+ # result = client.get('git.branch', path: '/repo')
10
+ # if result.hit?
11
+ # puts result.data # "main"
12
+ # puts result.age_ms # 42
13
+ # end
14
+ #
15
+ # # hash data access
16
+ # result = client.get('git', path: '/repo')
17
+ # puts result['branch']
18
+ class Result
19
+ # @return [Boolean] whether the daemon reported success
20
+ attr_reader :ok
21
+
22
+ # @return [Object, nil] decoded payload (String, Integer, Hash, Array, etc.)
23
+ attr_reader :data
24
+
25
+ # @return [Integer] age of the cached value in milliseconds
26
+ attr_reader :age_ms
27
+
28
+ # @return [String, nil] error message when ok is false
29
+ attr_reader :error
30
+
31
+ def initialize(ok:, data:, age_ms:, stale:, error:)
32
+ @ok = ok
33
+ @data = data
34
+ @age_ms = age_ms
35
+ @stale = stale
36
+ @error = error
37
+ end
38
+
39
+ # @return [Boolean]
40
+ def ok?
41
+ @ok
42
+ end
43
+
44
+ # @return [Boolean] true when the response carried data (cache hit)
45
+ def hit?
46
+ @ok && !@data.nil?
47
+ end
48
+
49
+ # @return [Boolean] true when the response was successful but had no data
50
+ def miss?
51
+ @ok && @data.nil?
52
+ end
53
+
54
+ # @return [Boolean] true when the cached value is stale
55
+ def stale?
56
+ @stale
57
+ end
58
+
59
+ # Delegates field access to the data hash.
60
+ #
61
+ # @param key [String] field name
62
+ # @return [Object, nil]
63
+ # @raise [TypeError] if data is not a Hash
64
+ def [](key)
65
+ unless @data.is_a?(Hash)
66
+ raise TypeError, "Result#[] requires hash data, got #{@data.class}"
67
+ end
68
+
69
+ @data[key]
70
+ end
71
+
72
+ def inspect
73
+ parts = ["ok=#{@ok}"]
74
+ parts << "data=#{@data.inspect}" unless @data.nil?
75
+ parts << "age_ms=#{@age_ms}" if @age_ms > 0
76
+ parts << "stale=true" if @stale
77
+ parts << "error=#{@error.inspect}" if @error
78
+ "#<Beachcomber::Result #{parts.join(', ')}>"
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,28 @@
1
+ require_relative 'beachcomber/errors'
2
+ require_relative 'beachcomber/result'
3
+ require_relative 'beachcomber/discovery'
4
+ require_relative 'beachcomber/client'
5
+
6
+ # Beachcomber is a Ruby client for the beachcomber daemon.
7
+ #
8
+ # The daemon caches shell-environment data (git state, hostname, battery, …)
9
+ # and serves it over a Unix domain socket using newline-delimited JSON.
10
+ #
11
+ # Quick start:
12
+ #
13
+ # require 'beachcomber'
14
+ #
15
+ # client = Beachcomber::Client.new
16
+ # result = client.get('git.branch', path: '/path/to/repo')
17
+ # puts result.data if result.hit?
18
+ #
19
+ # Persistent session (one connection, multiple queries):
20
+ #
21
+ # client.session do |s|
22
+ # s.set_context('/path/to/repo')
23
+ # r1 = s.get('git.branch')
24
+ # r2 = s.get('git.dirty')
25
+ # end
26
+ module Beachcomber
27
+ VERSION = '0.1.0'
28
+ end
metadata ADDED
@@ -0,0 +1,51 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: libbeachcomber
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - NavistAu
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-01 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Communicates with the beachcomber daemon over a Unix domain socket to
14
+ query cached shell-environment data (git state, hostname, battery, etc.).
15
+ email:
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - lib/beachcomber.rb
21
+ - lib/beachcomber/client.rb
22
+ - lib/beachcomber/discovery.rb
23
+ - lib/beachcomber/errors.rb
24
+ - lib/beachcomber/result.rb
25
+ homepage: https://github.com/NavistAu/beachcomber
26
+ licenses:
27
+ - MIT
28
+ metadata:
29
+ source_code_uri: https://github.com/NavistAu/beachcomber/tree/main/sdks/ruby
30
+ bug_tracker_uri: https://github.com/NavistAu/beachcomber/issues
31
+ changelog_uri: https://github.com/NavistAu/beachcomber/blob/main/CHANGELOG.md
32
+ post_install_message:
33
+ rdoc_options: []
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ required_rubygems_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ requirements: []
47
+ rubygems_version: 3.5.22
48
+ signing_key:
49
+ specification_version: 4
50
+ summary: Ruby client for the beachcomber shell-data daemon
51
+ test_files: []