diff --git a/bin/pkg b/bin/pkg index 86330ae..a4b3a8c 100755 --- a/bin/pkg +++ b/bin/pkg @@ -1,3 +1,50 @@ #!/usr/bin/env ruby +# Simple Ruby package manager mock +# Supports: +# pkg --user install nvim +# pkg --system install nvim +# pkg install nvim # defaults to --package +# pkg --package install nvim + +require "package" + puts "Welcome to package manager" + +# Extract options and commands from arguments +args = ARGV.dup + +scope = :package # default +if args.first&.start_with?("--") + case args.shift + when "--user" + scope = :user + when "--system" + scope = :system + when "--package" + scope = :package + else + puts "Unknown option: #{args.first}" + exit 1 + end +end + +command = args.shift +package = args.shift + +if command.nil? + puts "Usage: pkg [--user|--system|--package] " + exit 1 +end + +case command +when "install" + Package.install(package, scope) +when "make" + Package.make(package, scope) +when "daemon" + Package.daemon +else + puts "Unknown command: #{command}" + exit 1 +end \ No newline at end of file diff --git a/lib/package.rb b/lib/package.rb new file mode 100644 index 0000000..a7d777b --- /dev/null +++ b/lib/package.rb @@ -0,0 +1,35 @@ + + +module Package + require_relative "package/daemon" + require_relative "package/client" + + ENDPOINT = "ipc:///tmp/zmq-rpc-demo" + + def self.make(name, scope) + puts "making package named: #{name} at #{scope}" + + c = RpcClient.new(ENDPOINT, timeout_ms: 2000) + begin + puts "echo => #{c.call("echo", {"hello"=>"world"})}" + puts "add => #{c.call("add", [1, 2, 3.5])}" + puts "time => #{c.call("time")}" + rescue ::Timeout::Error + warn "RPC timeout: is the daemon running and bound to #{ENDPOINT}?" + warn "Start it with: ruby daemon.rb (or your systemd service)" + rescue => e + warn "RPC error: #{e.class}: #{e.message}" + ensure + c.close + end + end + + def self.install(name, scope) + puts "making package named: #{name} at #{scope}" + end + + # gem install ffi-rzmq + def self.daemon + start_daemon(ENDPOINT) + end +end \ No newline at end of file diff --git a/lib/package/client.rb b/lib/package/client.rb new file mode 100644 index 0000000..f4f9950 --- /dev/null +++ b/lib/package/client.rb @@ -0,0 +1,51 @@ +# rpc_client.rb +require 'ffi-rzmq' +require 'json' +require 'securerandom' +require 'timeout' + +ENDPOINT = "ipc:///tmp/zmq-rpc-demo" + +class RpcClient + def initialize(endpoint, timeout_ms: 2000) + @ctx = ZMQ::Context.new + @req = @ctx.socket(ZMQ::REQ) + + @req.setsockopt(ZMQ::LINGER, 0) + + rc = @req.connect(endpoint) + raise "connect failed: #{ZMQ::Util.error_string}" unless rc == 0 + @poller = ZMQ::Poller.new + @poller.register_readable(@req) + @timeout = timeout_ms + end + + def call(method, params=nil) + id = SecureRandom.uuid + payload = { jsonrpc: "2.0", id: id, method: method, params: params }.to_json + @req.send_string(payload) + + # Wait for reply with timeout + rc = @poller.poll(@timeout) + raise Timeout::Error, "RPC timeout after #{@timeout}ms" if rc == 0 + + raw = '' + @req.recv_string(raw) + res = JSON.parse(raw) + + if res["error"] + code = res["error"]["code"] + msg = res["error"]["message"] + data = res["error"]["data"] + raise StandardError, "RPC error (#{code}): #{msg}#{data ? " | #{data}" : ""}" + end + + raise "Mismatched id" if res["id"] != id + res["result"] + end + + def close + @req.close + @ctx.terminate + end +end \ No newline at end of file diff --git a/lib/package/daemon.rb b/lib/package/daemon.rb new file mode 100644 index 0000000..f7a2ab4 --- /dev/null +++ b/lib/package/daemon.rb @@ -0,0 +1,121 @@ +require 'ffi-rzmq' +require 'json' +require 'time' # for Time#iso8601 + +# $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) # <<< add this line +# require "package" + +def start_daemon(endpoint) + # Clean up stale ipc file (bind will fail otherwise) + begin + path = ENDPOINT.sub("ipc://", "") + File.unlink(path) if File.exist?(path) + rescue => e + warn "Cleanup failed: #{e.message}" + end + + ctx = ZMQ::Context.new + rep = ctx.socket(ZMQ::REP) + rc = rep.bind(endpoint) + abort "bind failed: #{ZMQ::Util.error_string}" unless rc == 0 + puts "RPC server on #{endpoint}" + + # Define RPC methods (use a local variable, not a constant) + rpc_methods = { + "echo" => ->(params) { params }, # returns whatever you send + "add" => ->(params) { Array(params).map(&:to_f).sum }, # add numbers + "time" => ->(_params) { Time.now.utc.iso8601 } # current time + } + + ok = ->(id, result) { { "jsonrpc"=>"2.0", "id"=>id, "result"=>result }.to_json } + err = ->(id, code, msg, data=nil) do + body = { "jsonrpc"=>"2.0", "id"=>id, "error"=>{ "code"=>code, "message"=>msg } } + body["error"]["data"] = data unless data.nil? + body.to_json + end + + shutdown = proc do + begin + rep.close + ensure + ctx.terminate + end + puts "\nbye" + begin + path = endpoint.sub("ipc://", "") + File.unlink(path) if File.exist?(path) + rescue; end + puts "\nbye" + exit + end + trap("INT") { shutdown.call } + trap("TERM") { shutdown.call } + + loop do + # raw = '' + # next unless rep.recv_string(raw) == 0 + # puts raw + + raw = '' + rc = rep.recv_string(raw) + puts "[server] recv rc=#{rc} raw=#{raw.inspect}" + #next unless rc == 0 + + + + + + if rc == -1 + errno = ZMQ::Util.errno + warn "[server] recv error #{errno}: #{ZMQ::Util.error_string}" + case errno + when ZMQ::EINTR + next # interrupted by signal; retry + when ZMQ::ETERM + break # context shutting down + when ZMQ::EFSM + # Socket in wrong state (likely previous send failed). + # Reset the REP socket. + rep.close + rep = ctx.socket(ZMQ::REP) + rep.setsockopt(ZMQ::LINGER, 0) + rep.bind(ENDPOINT) + next + else + sleep 0.01 # avoid hot loop on unexpected errors + next + end + end + + + + + + + + begin + req = JSON.parse(raw) + id = req["id"] + meth = req["method"] + params = req["params"] + + if req["jsonrpc"] != "2.0" + rep.send_string(err.call(id, -32600, "Invalid Request: jsonrpc must be '2.0'")) + next + end + + handler = rpc_methods[meth] + unless handler + rep.send_string(err.call(id, -32601, "Method not found: #{meth}")) + next + end + + result = handler.call(params) + rep.send_string(ok.call(id, result)) + rescue JSON::ParserError => e + rep.send_string(err.call(nil, -32700, "Parse error", e.message)) + rescue => e + rep.send_string(err.call(nil, -32000, "Server error", e.message)) + end + end +end