shipeasy-sdk 1.7.0 → 2.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.
@@ -1,7 +1,7 @@
1
1
  # Single configuration object for the Shipeasy gem.
2
2
  #
3
3
  # Covers both subsystems:
4
- # - SDK / experimentation (api_key, base_url) — drives FlagsClient
4
+ # - SDK / experimentation (api_key, base_url) — drives Engine
5
5
  # - i18n / string manager (public_key, profile, cdn_base_url, ...) — drives
6
6
  # the Rails view helpers and label fetcher
7
7
  #
@@ -14,7 +14,7 @@
14
14
  # end
15
15
  #
16
16
  # Anything not set falls back to the defaults below. The same Shipeasy.config
17
- # is read by FlagsClient and the Rails helpers, so there is one place to
17
+ # is read by Engine and the Rails helpers, so there is one place to
18
18
  # point environment variables at.
19
19
 
20
20
  module Shipeasy
@@ -22,6 +22,39 @@ module Shipeasy
22
22
  # ---- experimentation / SDK ----
23
23
  attr_accessor :api_key, :base_url
24
24
 
25
+ # Advanced `configure` options — threaded into the global Engine `configure`
26
+ # builds, so callers never construct an Engine themselves:
27
+ # - env (default "prod"): deployment tag on see() events + usage telemetry.
28
+ # - disable_telemetry (default false): opt out of per-eval usage telemetry.
29
+ # - telemetry_url: override the telemetry endpoint (rarely needed).
30
+ # - private_attributes: attribute keys stripped from every outbound event
31
+ # before it leaves the process (they still drive targeting locally).
32
+ # - sticky_store: pin a user's experiment group across re-buckets.
33
+ attr_accessor :env, :disable_telemetry, :telemetry_url,
34
+ :private_attributes, :sticky_store
35
+
36
+ # Fetch lifecycle for the global engine `configure` builds:
37
+ # - init (default true): fire a one-shot fetch fire-and-forget so the first
38
+ # `Shipeasy::Client.new(user).get_flag(...)` resolves against real rules
39
+ # (ideal for serverless / short-lived processes).
40
+ # - poll (default false): start the background poll (initial fetch +
41
+ # periodic refresh) for a long-running server, so flags stay fresh
42
+ # without a redeploy. Configuration owns the lifecycle — you never call
43
+ # `engine.init` yourself.
44
+ attr_accessor :init, :poll
45
+
46
+ # Optional transform from YOUR user object (any shape) to the Shipeasy
47
+ # attribute hash every flag/experiment evaluation uses. A callable
48
+ # (lambda/proc or anything responding to #call). Default = identity (the
49
+ # user object is assumed to already BE the attribute hash). Runs once, in
50
+ # the Shipeasy::Client constructor.
51
+ #
52
+ # Shipeasy.configure do |c|
53
+ # c.api_key = ENV["SHIPEASY_SERVER_KEY"]
54
+ # c.attributes = ->(u) { { "user_id" => u.id, "plan" => u.plan } }
55
+ # end
56
+ attr_accessor :attributes
57
+
25
58
  # ---- i18n / string manager ----
26
59
  attr_accessor :public_key, :profile, :default_chunk,
27
60
  :cdn_base_url, :loader_url,
@@ -29,6 +62,14 @@ module Shipeasy
29
62
 
30
63
  def initialize
31
64
  @base_url = "https://edge.shipeasy.dev"
65
+ @attributes = nil
66
+ @init = true
67
+ @poll = false
68
+ @env = "prod"
69
+ @disable_telemetry = false
70
+ @telemetry_url = nil
71
+ @private_attributes = nil
72
+ @sticky_store = nil
32
73
 
33
74
  @profile = "default"
34
75
  @default_chunk = "index"
@@ -45,8 +86,225 @@ module Shipeasy
45
86
  @config ||= Configuration.new
46
87
  end
47
88
 
89
+ # Configure the gem once at boot. In addition to populating the shared
90
+ # Configuration, this builds and registers the ONE global Shipeasy::Engine
91
+ # (first-config-wins) from the api_key/base_url and kicks off its one-shot
92
+ # fetch (fire-and-forget) so `Shipeasy::Client.new(user).get_flag(...)`
93
+ # resolves against real rules with no explicit init call.
94
+ #
95
+ # Shipeasy.configure do |c|
96
+ # c.api_key = ENV["SHIPEASY_SERVER_KEY"]
97
+ # c.attributes = ->(u) { { "user_id" => u.id, "plan" => u.plan } }
98
+ # end
99
+ #
100
+ # Shipeasy::Client.new(current_user).get_flag("new_checkout")
101
+ #
102
+ # Long-running servers that also want the background poll can call
103
+ # `Shipeasy.engine.init` after configure.
48
104
  def configure
49
105
  yield config
106
+ register_engine!(config) if config.api_key
107
+ config
108
+ end
109
+
110
+ # The resolved attributes transform (callable). Default = identity, so a
111
+ # user object that is already the attribute hash is used verbatim.
112
+ def attributes_transform
113
+ transform = config.attributes
114
+ if transform.nil?
115
+ ->(user) { user }
116
+ elsif transform.respond_to?(:call)
117
+ transform
118
+ else
119
+ raise Error, "Shipeasy.configure { |c| c.attributes = … } must be a callable (e.g. a lambda)"
120
+ end
121
+ end
122
+
123
+ # The single global engine registered by configure, or nil if configure has
124
+ # not run (or ran without an api_key). Shipeasy::Client reads this.
125
+ def engine
126
+ pid = Process.pid
127
+ if @engine && @engine_pid != pid
128
+ # Post-fork: the parent's poll thread didn't survive. Rebuild lazily
129
+ # from the stored config in this child process.
130
+ @engine = nil
131
+ register_engine!(config) if config.api_key
132
+ end
133
+ @engine
134
+ end
135
+
136
+ # Build + register the one global engine (first-config-wins). Kicks off the
137
+ # configured fetch lifecycle (one-shot by default; the background poll when
138
+ # `c.poll = true`) fire-and-forget. Idempotent within a process.
139
+ def register_engine!(cfg)
140
+ return @engine if @engine && @engine_pid == Process.pid
141
+ @engine_pid = Process.pid
142
+ engine = Engine.new(
143
+ api_key: cfg.api_key,
144
+ base_url: cfg.base_url,
145
+ env: cfg.env,
146
+ disable_telemetry: cfg.disable_telemetry,
147
+ telemetry_url: cfg.telemetry_url,
148
+ private_attributes: cfg.private_attributes,
149
+ sticky_store: cfg.sticky_store,
150
+ )
151
+ @engine = engine
152
+ # Capture +engine+ in the closure (not the @engine ivar, which a concurrent
153
+ # reset/reconfigure could nil out before the thread runs).
154
+ if cfg.poll
155
+ Thread.new do
156
+ engine.init # initial fetch + background poll thread
157
+ rescue => e
158
+ warn "[shipeasy] configure(poll) background poll failed: #{e.message}"
159
+ end
160
+ elsif cfg.init
161
+ Thread.new do
162
+ engine.init_once
163
+ rescue => e
164
+ warn "[shipeasy] configure() one-shot fetch failed: #{e.message}"
165
+ end
166
+ end
167
+ engine
168
+ end
169
+
170
+ # ---- configure() test/offline siblings -----------------------------------
171
+ #
172
+ # Drop-in siblings of `Shipeasy.configure` for tests and offline evaluation.
173
+ # Unlike `configure` (first-config-wins), these REPLACE the registered global
174
+ # engine, so a suite can reconfigure between cases. After either, you read the
175
+ # same way: `Shipeasy::Client.new(user)`.
176
+
177
+ # Configure Shipeasy in TEST MODE — no api key, zero network, ever. Seed the
178
+ # values your code under test should see via the override args, then read
179
+ # through the ordinary `Shipeasy::Client.new(user)`:
180
+ #
181
+ # Shipeasy.configure_for_testing(flags: { "new_checkout" => true })
182
+ # Shipeasy::Client.new({ "user_id" => "u_1" }).get_flag("new_checkout") # => true
183
+ #
184
+ # flags: { name => bool } forced get_flag results
185
+ # configs: { name => value } forced get_config results
186
+ # experiments: { name => [group, params] } forced enrolments
187
+ # attributes: same transform as configure (default identity)
188
+ def configure_for_testing(flags: nil, configs: nil, experiments: nil, attributes: nil)
189
+ engine = Engine.for_testing
190
+ apply_overrides(engine, flags, configs, experiments)
191
+ install_global_engine(engine, attributes)
192
+ end
193
+
194
+ # Configure Shipeasy OFFLINE — evaluate the REAL rules from an in-memory
195
+ # snapshot or a JSON file, with no network. Provide exactly one source:
196
+ #
197
+ # snapshot: { "flags" => <body of /sdk/flags>, "experiments" => <body of /sdk/experiments> }
198
+ # path: "snapshot.json" (a JSON file of the same shape)
199
+ #
200
+ # Optional flags/configs/experiments overrides layer on top (same shapes as
201
+ # configure_for_testing). Replaces any previously-configured engine.
202
+ def configure_for_offline(snapshot: nil, path: nil, flags: nil, configs: nil, experiments: nil, attributes: nil)
203
+ engine =
204
+ if path
205
+ Engine.from_file(path)
206
+ elsif snapshot
207
+ s = snapshot.transform_keys(&:to_s)
208
+ Engine.from_snapshot(flags: s["flags"], experiments: s["experiments"])
209
+ else
210
+ raise Error, "Shipeasy.configure_for_offline requires snapshot: or path:"
211
+ end
212
+ apply_overrides(engine, flags, configs, experiments)
213
+ install_global_engine(engine, attributes)
214
+ end
215
+
216
+ # ---- package-level helpers (so callers never name the Engine) -------------
217
+
218
+ # On-the-spot overrides layered on top of whatever configure_for_testing /
219
+ # configure_for_offline (or a live configure) set up — they win over the blob
220
+ # until clear_overrides. Require a prior configure* call.
221
+ def override_flag(name, value)
222
+ require_engine("override_flag").override_flag(name, value)
223
+ nil
224
+ end
225
+
226
+ def override_config(name, value)
227
+ require_engine("override_config").override_config(name, value)
228
+ nil
229
+ end
230
+
231
+ def override_experiment(name, group, params)
232
+ require_engine("override_experiment").override_experiment(name, group, params)
233
+ nil
234
+ end
235
+
236
+ # Drop EVERY override — including the seed from configure_for_testing (test
237
+ # mode has no blob beneath); under configure_for_offline it reverts to the
238
+ # snapshot.
239
+ def clear_overrides
240
+ require_engine("clear_overrides").clear_overrides
241
+ nil
242
+ end
243
+
244
+ # Register a poll listener fired after a background poll fetches NEW data
245
+ # (HTTP 200, not 304). Requires configure(poll: true). Returns an unsubscribe
246
+ # proc. Accepts a block or any callable.
247
+ def on_change(callable = nil, &block)
248
+ require_engine("on_change").on_change(callable, &block)
249
+ end
250
+
251
+ # SSR tag helpers — delegate to the configured global engine, so you never
252
+ # touch it. i18n_script_tag carries the PUBLIC client key (not the server
253
+ # key); bootstrap_script_tag embeds no key.
254
+ def i18n_script_tag(client_key, profile: "en:prod", base_url: nil)
255
+ require_engine("i18n_script_tag").i18n_script_tag(client_key, profile: profile, base_url: base_url)
256
+ end
257
+
258
+ def bootstrap_script_tag(user, anon_id: nil, i18n_profile: "en:prod", base_url: nil)
259
+ require_engine("bootstrap_script_tag").bootstrap_script_tag(
260
+ user, anon_id: anon_id, i18n_profile: i18n_profile, base_url: base_url
261
+ )
262
+ end
263
+
264
+ # see() structured error reporting — package-level, dispatched through the
265
+ # last-constructed default client (the engine configure built). Never raises
266
+ # into caller code; a call before any client exists warns and no-ops.
267
+ def see(problem)
268
+ Shipeasy::SDK.see(problem)
269
+ end
270
+
271
+ def see_violation(name)
272
+ Shipeasy::SDK.see_violation(name)
273
+ end
274
+
275
+ def control_flow_exception(err)
276
+ Shipeasy::SDK.control_flow_exception(err)
277
+ end
278
+
279
+ # Replace the registered global engine + attributes transform (used by the
280
+ # configure_for_* siblings — unlike configure, they replace so a test suite
281
+ # can reconfigure between cases). Returns the engine.
282
+ def install_global_engine(engine, attributes)
283
+ config.attributes = attributes
284
+ @engine = engine
285
+ @engine_pid = Process.pid
286
+ engine
287
+ end
288
+
289
+ # Apply the configure_for_* override args onto an engine.
290
+ def apply_overrides(engine, flags, configs, experiments)
291
+ (flags || {}).each { |name, value| engine.override_flag(name, value) }
292
+ (configs || {}).each { |name, value| engine.override_config(name, value) }
293
+ (experiments || {}).each do |name, spec|
294
+ group, params = spec # spec is [group, params]
295
+ engine.override_experiment(name, group, params)
296
+ end
297
+ end
298
+
299
+ # The global engine, or raise a helpful error naming the package-level fn the
300
+ # caller used before any configure*.
301
+ def require_engine(fn_name)
302
+ e = engine
303
+ return e unless e.nil?
304
+
305
+ raise Error, "Shipeasy.#{fn_name} called before Shipeasy.configure " \
306
+ "{ |c| c.api_key = … } (or configure_for_testing / " \
307
+ "configure_for_offline). Call one once at app boot."
50
308
  end
51
309
 
52
310
  # Reset the config back to defaults — primarily for tests.
@@ -55,9 +313,12 @@ module Shipeasy
55
313
  @flags_pid = nil
56
314
  @flags&.destroy
57
315
  @flags = nil
316
+ @engine&.destroy
317
+ @engine = nil
318
+ @engine_pid = nil
58
319
  end
59
320
 
60
- # Lazy, fork-safe singleton FlagsClient. The first call from each
321
+ # Lazy, fork-safe singleton Engine. The first call from each
61
322
  # process spawns a fresh client + poll thread — including post-fork
62
323
  # workers under Puma's preload_app!. Callers can `Shipeasy.flags.get_flag(...)`
63
324
  # straight from a controller without holding a constant or worrying
@@ -70,7 +331,12 @@ module Shipeasy
70
331
  #
71
332
  # The first request that touches `Shipeasy.flags.*` triggers init().
72
333
  # For serverless / Lambda where you want a single fetch with no thread,
73
- # build the client explicitly: `Shipeasy::SDK::FlagsClient.new(...).init_once`.
334
+ # build the engine explicitly: `Shipeasy::Engine.new(...).init_once`.
335
+ #
336
+ # NOTE: this remains a separate, polling engine from the one configure()
337
+ # registers (Shipeasy.engine). New code should prefer the
338
+ # Shipeasy.configure + Shipeasy::Client.new(user) front door; `Shipeasy.flags`
339
+ # is retained for the legacy `Shipeasy.flags.get_flag(name, user)` style.
74
340
  def flags
75
341
  pid = Process.pid
76
342
  if @flags && @flags_pid != pid
@@ -81,7 +347,7 @@ module Shipeasy
81
347
  end
82
348
  @flags ||= begin
83
349
  @flags_pid = pid
84
- client = SDK::FlagsClient.new(
350
+ client = Engine.new(
85
351
  api_key: config.api_key,
86
352
  base_url: config.base_url,
87
353
  )
@@ -3,15 +3,31 @@ require "uri"
3
3
  require "json"
4
4
  require "thread"
5
5
  require "cgi"
6
- require_relative "eval"
7
- require_relative "telemetry"
8
- require_relative "anon_id"
9
- require_relative "sticky_store"
10
- require_relative "see"
6
+ require_relative "sdk/eval"
7
+ require_relative "sdk/telemetry"
8
+ require_relative "sdk/anon_id"
9
+ require_relative "sdk/sticky_store"
10
+ require_relative "sdk/see"
11
11
 
12
12
  module Shipeasy
13
- module SDK
14
- class FlagsClient
13
+ # The heavyweight engine: owns the api key, HTTP transport, the blob cache,
14
+ # the background poll timer, init/init_once, local overrides, track, and
15
+ # see()/default-client wiring. Was `Shipeasy::SDK::FlagsClient` before 2.0;
16
+ # renamed to a clean top-level `Shipeasy::Engine` when the lightweight
17
+ # user-bound `Shipeasy::Client` became the primary front door.
18
+ #
19
+ # Most apps never construct an Engine directly — `Shipeasy.configure { … }`
20
+ # builds and registers the one global engine for you. Construct one explicitly
21
+ # only for advanced/serverless flows (multiple keys, offline snapshots).
22
+ class Engine
23
+ # Internal collaborators still live under Shipeasy::SDK; alias them so the
24
+ # body below can keep referring to them unqualified after the class moved
25
+ # out from under the SDK namespace.
26
+ Eval = Shipeasy::SDK::Eval
27
+ Telemetry = Shipeasy::SDK::Telemetry
28
+ AnonId = Shipeasy::SDK::AnonId
29
+ See = Shipeasy::SDK::See
30
+
15
31
  DEFAULT_BASE_URL = "https://edge.shipeasy.dev"
16
32
  # CDN origin serving the static loader scripts (/sdk/bootstrap.js,
17
33
  # /sdk/i18n/loader.js) — distinct from the edge API the blobs are fetched from.
@@ -34,7 +50,7 @@ module Shipeasy
34
50
  @sticky_store = sticky_store
35
51
  # Test mode: no network, ever. init/init_once/track become no-ops and
36
52
  # evaluation answers come purely from local overrides. Built via the
37
- # FlagsClient.for_testing factory; see clear_overrides / override_*.
53
+ # Engine.for_testing factory; see clear_overrides / override_*.
38
54
  @test_mode = test_mode
39
55
  # Per-evaluation usage telemetry. ON by default; pass
40
56
  # disable_telemetry: true to opt out. See telemetry.rb.
@@ -74,15 +90,19 @@ module Shipeasy
74
90
 
75
91
  # Build a no-network, immediately-usable client for tests. Telemetry is
76
92
  # disabled, init/init_once/track are no-ops (never fetch), and no api_key
77
- # is required. Seed it with override_flag / override_config /
93
+ # is required. The client is immediately READY against an empty blob (so a
94
+ # missing gate resolves FLAG_NOT_FOUND, not CLIENT_NOT_READY — parity with
95
+ # the other SDKs). Seed it with override_flag / override_config /
78
96
  # override_experiment, then call the normal getters.
79
97
  def self.for_testing(env: "prod")
80
- new(
98
+ client = new(
81
99
  api_key: "test",
82
100
  env: env,
83
101
  disable_telemetry: true,
84
102
  test_mode: true,
85
103
  )
104
+ client.send(:load_snapshot, {}, {})
105
+ client
86
106
  end
87
107
 
88
108
  # Build an offline client from a JSON snapshot file. The file holds the
@@ -275,6 +295,32 @@ module Shipeasy
275
295
  result
276
296
  end
277
297
 
298
+ # Public hook for the bound Shipeasy::Client: normalise an attribute hash
299
+ # and apply the request-scoped anonymous_id merge ONCE, at Client
300
+ # construction, exactly as every per-call getter does internally.
301
+ def bind_attributes(user)
302
+ with_anon_id(user)
303
+ end
304
+
305
+ # Read a killswitch from the cached flags blob. Without +switch_key+,
306
+ # returns true when the whole killswitch is killed. With +switch_key+,
307
+ # returns true when that specific named per-key switch is on — and when
308
+ # the key isn't configured on the killswitch, FALLS BACK to the top-level
309
+ # value (so an unconfigured key behaves exactly like the no-key call).
310
+ # Unknown killswitches return false. Not user-scoped.
311
+ def get_killswitch(name, switch_key = nil)
312
+ @telemetry.emit("ks", name)
313
+ ks = @mutex.synchronize { @flags_blob&.dig("killswitches", name.to_s) }
314
+ return false unless ks
315
+ unless switch_key.nil?
316
+ switches = ks["switches"] || {}
317
+ key = switch_key.to_s
318
+ return Eval.enabled?(switches[key]) if switches.key?(key)
319
+ # key not configured → fall through to the top-level value
320
+ end
321
+ Eval.enabled?(ks["killed"])
322
+ end
323
+
278
324
  # Batch-evaluate every loaded gate, config and experiment for +user+ into
279
325
  # a bootstrap payload (+{ "flags" => ..., "configs" => ..., "experiments"
280
326
  # => ..., "killswitches" => ... }+) keyed to match the browser SDK's
@@ -576,6 +622,5 @@ module Shipeasy
576
622
  http.read_timeout = 10
577
623
  http.post(uri.request_uri, body, { "X-SDK-Key" => @api_key, "Content-Type" => "text/plain" })
578
624
  end
579
- end
580
625
  end
581
626
  end
@@ -33,7 +33,7 @@ module Shipeasy
33
33
  end
34
34
 
35
35
  # The anon id RackMiddleware resolved for the current request, or nil when
36
- # no middleware ran (e.g. a background job). FlagsClient falls back to this
36
+ # no middleware ran (e.g. a background job). The Engine falls back to this
37
37
  # as the default anonymous_id, so evaluations need no per-call wiring.
38
38
  def current
39
39
  Thread.current[THREAD_KEY]
@@ -11,18 +11,17 @@
11
11
  # require "open_feature/sdk"
12
12
  # require "shipeasy/sdk/openfeature"
13
13
  #
14
- # client = Shipeasy::SDK::FlagsClient.new(api_key: ENV.fetch("SHIPEASY_SERVER_KEY"))
15
- # client.init
14
+ # Shipeasy.configure { |c| c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY"); c.poll = true }
16
15
  #
17
16
  # OpenFeature::SDK.configure do |config|
18
- # config.set_provider(Shipeasy::OpenFeature::Provider.new(client))
17
+ # config.set_provider(Shipeasy::OpenFeature::Provider.new) # uses the configured global
19
18
  # end
20
19
  #
21
20
  # of = OpenFeature::SDK.build_client
22
21
  # on = of.fetch_boolean_value(flag_key: "new_checkout", default_value: false,
23
22
  # evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "u1"))
24
23
  #
25
- # Pure adapter over `FlagsClient` — no change to evaluation. Boolean values map
24
+ # Pure adapter over `Shipeasy::Engine` — no change to evaluation. Boolean values map
26
25
  # onto gates (`get_flag_detail`); string/number/integer/float/object map onto
27
26
  # dynamic configs (`get_config`).
28
27
 
@@ -37,12 +36,14 @@ rescue LoadError => e
37
36
  "gem \"openfeature-sdk\". (#{e.message})"
38
37
  end
39
38
 
40
- require_relative "flags_client"
39
+ require_relative "../engine"
40
+ require_relative "../config"
41
+ require_relative "../client"
41
42
 
42
43
  module Shipeasy
43
44
  module OpenFeature
44
45
  # Shipeasy OpenFeature provider (server paradigm). Wraps a
45
- # `Shipeasy::SDK::FlagsClient`; evaluation is local against the cached blob,
46
+ # `Shipeasy::Engine`; evaluation is local against the cached blob,
46
47
  # so resolution is effectively synchronous.
47
48
  class Provider
48
49
  OF = ::OpenFeature::SDK::Provider
@@ -56,17 +57,27 @@ module Shipeasy
56
57
  # FLAG_NOT_FOUND → ERROR (error_code FLAG_NOT_FOUND)
57
58
  # CLIENT_NOT_READY → ERROR (error_code PROVIDER_NOT_READY)
58
59
  REASON_MAP = {
59
- Shipeasy::SDK::FlagsClient::REASON_RULE_MATCH => [OF::Reason::TARGETING_MATCH, nil],
60
- Shipeasy::SDK::FlagsClient::REASON_DEFAULT => [OF::Reason::DEFAULT, nil],
61
- Shipeasy::SDK::FlagsClient::REASON_OFF => [OF::Reason::DISABLED, nil],
62
- Shipeasy::SDK::FlagsClient::REASON_OVERRIDE => [OF::Reason::STATIC, nil],
63
- Shipeasy::SDK::FlagsClient::REASON_FLAG_NOT_FOUND => [OF::Reason::ERROR, OF::ErrorCode::FLAG_NOT_FOUND],
64
- Shipeasy::SDK::FlagsClient::REASON_CLIENT_NOT_READY => [OF::Reason::ERROR, OF::ErrorCode::PROVIDER_NOT_READY],
60
+ Shipeasy::Engine::REASON_RULE_MATCH => [OF::Reason::TARGETING_MATCH, nil],
61
+ Shipeasy::Engine::REASON_DEFAULT => [OF::Reason::DEFAULT, nil],
62
+ Shipeasy::Engine::REASON_OFF => [OF::Reason::DISABLED, nil],
63
+ Shipeasy::Engine::REASON_OVERRIDE => [OF::Reason::STATIC, nil],
64
+ Shipeasy::Engine::REASON_FLAG_NOT_FOUND => [OF::Reason::ERROR, OF::ErrorCode::FLAG_NOT_FOUND],
65
+ Shipeasy::Engine::REASON_CLIENT_NOT_READY => [OF::Reason::ERROR, OF::ErrorCode::PROVIDER_NOT_READY],
65
66
  }.freeze
66
67
 
67
68
  attr_reader :metadata
68
69
 
69
- def initialize(client)
70
+ # Construct the provider. With no argument it resolves the global engine
71
+ # configured via `Shipeasy.configure(...)`, so callers never build an
72
+ # Engine themselves — construct it AFTER your `Shipeasy.configure` call.
73
+ # Pass an explicit engine only for advanced/multi-key setups.
74
+ def initialize(client = nil)
75
+ client ||= Shipeasy.engine
76
+ if client.nil?
77
+ raise Shipeasy::Error, "Shipeasy::OpenFeature::Provider.new needs " \
78
+ "Shipeasy.configure { |c| c.api_key = … } to have run first " \
79
+ "(or pass an explicit engine)."
80
+ end
70
81
  @client = client
71
82
  @metadata = OF::ProviderMetadata.new(name: "shipeasy").freeze
72
83
  end
@@ -123,12 +134,28 @@ module Shipeasy
123
134
  end
124
135
 
125
136
  # OpenFeature `track()` → Shipeasy `track()`. No-ops without a targeting key.
126
- def track(tracking_event_name, evaluation_context: nil, details: {})
137
+ def track(tracking_event_name, evaluation_context: nil, tracking_event_details: nil)
127
138
  ctx = normalize_context(evaluation_context)
128
139
  user_id = ctx["targeting_key"] || ctx["user_id"]
129
140
  return if user_id.nil? || user_id.to_s.empty?
130
141
 
131
- props = details.is_a?(Hash) ? details : {}
142
+ # Base props = the evaluation-context attributes (minus the identity
143
+ # keys), with the tracking-event details merged on top.
144
+ props = ctx.reject { |k, _| k == "targeting_key" || k == "user_id" }
145
+
146
+ detail_fields = if tracking_event_details.respond_to?(:fields)
147
+ tracking_event_details.fields.transform_keys(&:to_s)
148
+ elsif tracking_event_details.is_a?(Hash)
149
+ tracking_event_details.transform_keys(&:to_s)
150
+ else
151
+ {}
152
+ end
153
+ props = props.merge(detail_fields)
154
+
155
+ if tracking_event_details.respond_to?(:value) && !tracking_event_details.value.nil?
156
+ props["value"] = tracking_event_details.value
157
+ end
158
+
132
159
  @client.track(user_id, tracking_event_name, props)
133
160
  end
134
161
 
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Shipeasy
6
+ module SDK
7
+ # `shipeasy-skill` — install the bundled Shipeasy agent skill into a project.
8
+ #
9
+ # RubyGems has no safe post-install hook (gems don't run code on install;
10
+ # installers run non-interactively), so installing the skill is an explicit,
11
+ # opt-in command:
12
+ #
13
+ # shipeasy-skill install # → .claude/skills/shipeasy-ruby/SKILL.md
14
+ # shipeasy-skill install --dir path/ # custom destination (file or dir)
15
+ # shipeasy-skill install --force # overwrite an existing file
16
+ # shipeasy-skill print # write the skill to stdout
17
+ #
18
+ # The skill (`docs/skill/SKILL.md`) is shipped inside the gem, so this reads
19
+ # it with no network — relative to this file, which works both from an
20
+ # installed gem and a source checkout.
21
+ module Skill
22
+ DEFAULT_DEST = ".claude/skills/shipeasy-ruby/SKILL.md"
23
+
24
+ # The bundled SKILL.md, read from docs/skill/SKILL.md (a sibling of lib/ in
25
+ # both the installed gem and a source checkout).
26
+ def self.skill_text
27
+ path = File.expand_path("../../../docs/skill/SKILL.md", __dir__)
28
+ File.read(path)
29
+ end
30
+
31
+ # Copy the skill to +dest+ (a file, or a directory it's written into).
32
+ def self.install(dest, force: false)
33
+ dest = File.join(dest, "SKILL.md") if File.directory?(dest) || File.extname(dest).empty?
34
+ if File.exist?(dest) && !force
35
+ warn "shipeasy-skill: refusing to overwrite #{dest} — pass --force"
36
+ return 1
37
+ end
38
+ FileUtils.mkdir_p(File.dirname(dest))
39
+ File.write(dest, skill_text)
40
+ puts "shipeasy-skill: installed the Shipeasy agent skill → #{dest}"
41
+ 0
42
+ end
43
+
44
+ def self.main(argv)
45
+ cmd = argv.shift
46
+ case cmd
47
+ when "install"
48
+ dest = DEFAULT_DEST
49
+ force = false
50
+ while (arg = argv.shift)
51
+ case arg
52
+ when "--dir" then dest = argv.shift
53
+ when "--force" then force = true
54
+ else
55
+ warn "shipeasy-skill: unknown argument #{arg}"
56
+ return 1
57
+ end
58
+ end
59
+ install(dest, force: force)
60
+ when "print"
61
+ puts skill_text
62
+ 0
63
+ else
64
+ puts <<~USAGE
65
+ shipeasy-skill — install the Shipeasy Ruby agent skill into your project.
66
+
67
+ Usage:
68
+ shipeasy-skill install [--dir PATH] [--force] copy SKILL.md (default: #{DEFAULT_DEST})
69
+ shipeasy-skill print print the skill to stdout
70
+ USAGE
71
+ 0
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,5 +1,5 @@
1
1
  module Shipeasy
2
2
  module SDK
3
- VERSION = "1.7.0"
3
+ VERSION = "2.1.0"
4
4
  end
5
5
  end