shipeasy-sdk 2.0.0 → 2.3.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.
Files changed (177) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +87 -294
  3. data/bin/shipeasy-skill +8 -0
  4. data/docs/skill/SKILL.md +152 -0
  5. data/lib/generators/shipeasy/install/USAGE +24 -0
  6. data/lib/generators/shipeasy/install/install_generator.rb +86 -0
  7. data/lib/generators/shipeasy/install/templates/initializer.rb.tt +41 -0
  8. data/lib/shipeasy/admin.rb +85 -0
  9. data/lib/shipeasy/client.rb +9 -0
  10. data/lib/shipeasy/config.rb +192 -7
  11. data/lib/shipeasy/engine.rb +16 -8
  12. data/lib/shipeasy/sdk/openfeature.rb +33 -6
  13. data/lib/shipeasy/sdk/skill.rb +76 -0
  14. data/lib/shipeasy/sdk/version.rb +1 -1
  15. data/lib/shipeasy_admin/api/alert_rules_api.rb +292 -0
  16. data/lib/shipeasy_admin/api/attributes_api.rb +82 -0
  17. data/lib/shipeasy_admin/api/configs_api.rb +680 -0
  18. data/lib/shipeasy_admin/api/events_api.rb +438 -0
  19. data/lib/shipeasy_admin/api/experiments_api.rb +727 -0
  20. data/lib/shipeasy_admin/api/gates_api.rb +438 -0
  21. data/lib/shipeasy_admin/api/i18n_api.rb +507 -0
  22. data/lib/shipeasy_admin/api/killswitches_api.rb +526 -0
  23. data/lib/shipeasy_admin/api/metrics_api.rb +281 -0
  24. data/lib/shipeasy_admin/api/ops_api.rb +525 -0
  25. data/lib/shipeasy_admin/api/projects_api.rb +153 -0
  26. data/lib/shipeasy_admin/api/universes_api.rb +306 -0
  27. data/lib/shipeasy_admin/api_client.rb +441 -0
  28. data/lib/shipeasy_admin/api_error.rb +58 -0
  29. data/lib/shipeasy_admin/api_model_base.rb +88 -0
  30. data/lib/shipeasy_admin/configuration.rb +397 -0
  31. data/lib/shipeasy_admin/models/approve_event_request.rb +198 -0
  32. data/lib/shipeasy_admin/models/approve_event_response.rb +166 -0
  33. data/lib/shipeasy_admin/models/create_alert_rule_request.rb +408 -0
  34. data/lib/shipeasy_admin/models/create_alert_rule_response.rb +165 -0
  35. data/lib/shipeasy_admin/models/create_config_request.rb +280 -0
  36. data/lib/shipeasy_admin/models/create_config_response.rb +201 -0
  37. data/lib/shipeasy_admin/models/create_event_request.rb +253 -0
  38. data/lib/shipeasy_admin/models/create_event_response.rb +193 -0
  39. data/lib/shipeasy_admin/models/create_experiment_request.rb +869 -0
  40. data/lib/shipeasy_admin/models/create_experiment_request_goal_metric.rb +336 -0
  41. data/lib/shipeasy_admin/models/create_experiment_response.rb +212 -0
  42. data/lib/shipeasy_admin/models/create_gate_request.rb +529 -0
  43. data/lib/shipeasy_admin/models/create_gate_response.rb +212 -0
  44. data/lib/shipeasy_admin/models/create_i18n_profile_request.rb +188 -0
  45. data/lib/shipeasy_admin/models/create_i18n_profile_response.rb +193 -0
  46. data/lib/shipeasy_admin/models/create_killswitch_request.rb +263 -0
  47. data/lib/shipeasy_admin/models/create_killswitch_response.rb +201 -0
  48. data/lib/shipeasy_admin/models/create_metric_request.rb +415 -0
  49. data/lib/shipeasy_admin/models/create_metric_response.rb +193 -0
  50. data/lib/shipeasy_admin/models/create_ops_item_request.rb +287 -0
  51. data/lib/shipeasy_admin/models/create_ops_item_response.rb +177 -0
  52. data/lib/shipeasy_admin/models/create_universe_request.rb +289 -0
  53. data/lib/shipeasy_admin/models/create_universe_response.rb +212 -0
  54. data/lib/shipeasy_admin/models/delete_alert_rule_response.rb +188 -0
  55. data/lib/shipeasy_admin/models/delete_config_response.rb +188 -0
  56. data/lib/shipeasy_admin/models/delete_event_response.rb +189 -0
  57. data/lib/shipeasy_admin/models/delete_experiment_response.rb +188 -0
  58. data/lib/shipeasy_admin/models/delete_gate_response.rb +188 -0
  59. data/lib/shipeasy_admin/models/delete_killswitch_response.rb +188 -0
  60. data/lib/shipeasy_admin/models/delete_metric_response.rb +189 -0
  61. data/lib/shipeasy_admin/models/delete_universe_response.rb +188 -0
  62. data/lib/shipeasy_admin/models/disable_gate_response.rb +190 -0
  63. data/lib/shipeasy_admin/models/discard_config_draft_request.rb +190 -0
  64. data/lib/shipeasy_admin/models/discard_config_draft_response.rb +188 -0
  65. data/lib/shipeasy_admin/models/enable_gate_response.rb +190 -0
  66. data/lib/shipeasy_admin/models/error_response.rb +185 -0
  67. data/lib/shipeasy_admin/models/get_config_response.rb +351 -0
  68. data/lib/shipeasy_admin/models/get_current_project_response.rb +606 -0
  69. data/lib/shipeasy_admin/models/get_current_project_response_module_translations.rb +104 -0
  70. data/lib/shipeasy_admin/models/get_event_response.rb +344 -0
  71. data/lib/shipeasy_admin/models/get_experiment_response.rb +688 -0
  72. data/lib/shipeasy_admin/models/get_experiment_results_response.rb +243 -0
  73. data/lib/shipeasy_admin/models/get_experiment_results_response_experiment.rb +248 -0
  74. data/lib/shipeasy_admin/models/get_experiment_results_response_results_inner.rb +252 -0
  75. data/lib/shipeasy_admin/models/get_experiment_timeseries_response.rb +192 -0
  76. data/lib/shipeasy_admin/models/get_experiment_timeseries_response_experiment.rb +261 -0
  77. data/lib/shipeasy_admin/models/get_experiment_timeseries_response_series_inner.rb +278 -0
  78. data/lib/shipeasy_admin/models/get_killswitch_response.rb +270 -0
  79. data/lib/shipeasy_admin/models/get_metric_response.rb +378 -0
  80. data/lib/shipeasy_admin/models/get_ops_item_response.rb +349 -0
  81. data/lib/shipeasy_admin/models/link_pr_to_ops_item_request.rb +186 -0
  82. data/lib/shipeasy_admin/models/link_pr_to_ops_item_response.rb +166 -0
  83. data/lib/shipeasy_admin/models/list_alert_rules_response_inner.rb +478 -0
  84. data/lib/shipeasy_admin/models/list_alert_rules_response_inner_notify.rb +177 -0
  85. data/lib/shipeasy_admin/models/list_alert_rules_response_inner_notify_slack_channel.rb +211 -0
  86. data/lib/shipeasy_admin/models/list_attributes_response_inner.rb +176 -0
  87. data/lib/shipeasy_admin/models/list_config_activity_response_inner.rb +280 -0
  88. data/lib/shipeasy_admin/models/list_configs_response.rb +178 -0
  89. data/lib/shipeasy_admin/models/list_configs_response_data_inner.rb +351 -0
  90. data/lib/shipeasy_admin/models/list_configs_response_data_inner_drafts_value.rb +237 -0
  91. data/lib/shipeasy_admin/models/list_configs_response_data_inner_envs_value.rb +237 -0
  92. data/lib/shipeasy_admin/models/list_events_response_inner.rb +344 -0
  93. data/lib/shipeasy_admin/models/list_events_response_inner_properties_inner.rb +289 -0
  94. data/lib/shipeasy_admin/models/list_experiments_response.rb +178 -0
  95. data/lib/shipeasy_admin/models/list_experiments_response_data_inner.rb +688 -0
  96. data/lib/shipeasy_admin/models/list_experiments_response_data_inner_groups_inner.rb +258 -0
  97. data/lib/shipeasy_admin/models/list_gates_response.rb +178 -0
  98. data/lib/shipeasy_admin/models/list_gates_response_data_inner.rb +395 -0
  99. data/lib/shipeasy_admin/models/list_gates_response_data_inner_enabled.rb +104 -0
  100. data/lib/shipeasy_admin/models/list_gates_response_data_inner_rules_inner.rb +238 -0
  101. data/lib/shipeasy_admin/models/list_gates_response_data_inner_stack_inner.rb +105 -0
  102. data/lib/shipeasy_admin/models/list_gates_response_data_inner_stack_inner_one_of.rb +375 -0
  103. data/lib/shipeasy_admin/models/list_gates_response_data_inner_stack_inner_one_of1.rb +331 -0
  104. data/lib/shipeasy_admin/models/list_gates_response_data_inner_stack_inner_one_of_ramp.rb +319 -0
  105. data/lib/shipeasy_admin/models/list_i18n_drafts_response_inner.rb +272 -0
  106. data/lib/shipeasy_admin/models/list_i18n_keys_response.rb +195 -0
  107. data/lib/shipeasy_admin/models/list_i18n_keys_response_keys_inner.rb +284 -0
  108. data/lib/shipeasy_admin/models/list_i18n_profiles_response_inner.rb +224 -0
  109. data/lib/shipeasy_admin/models/list_killswitches_response.rb +178 -0
  110. data/lib/shipeasy_admin/models/list_killswitches_response_data_inner.rb +270 -0
  111. data/lib/shipeasy_admin/models/list_killswitches_response_data_inner_envs_value.rb +248 -0
  112. data/lib/shipeasy_admin/models/list_metrics_response_inner.rb +378 -0
  113. data/lib/shipeasy_admin/models/list_metrics_response_inner_query_ir.rb +295 -0
  114. data/lib/shipeasy_admin/models/list_metrics_response_inner_query_ir_agg.rb +113 -0
  115. data/lib/shipeasy_admin/models/list_metrics_response_inner_query_ir_agg_one_of.rb +188 -0
  116. data/lib/shipeasy_admin/models/list_metrics_response_inner_query_ir_agg_one_of1.rb +188 -0
  117. data/lib/shipeasy_admin/models/list_metrics_response_inner_query_ir_agg_one_of2.rb +188 -0
  118. data/lib/shipeasy_admin/models/list_metrics_response_inner_query_ir_agg_one_of3.rb +188 -0
  119. data/lib/shipeasy_admin/models/list_metrics_response_inner_query_ir_agg_one_of4.rb +188 -0
  120. data/lib/shipeasy_admin/models/list_metrics_response_inner_query_ir_agg_one_of5.rb +188 -0
  121. data/lib/shipeasy_admin/models/list_metrics_response_inner_query_ir_agg_one_of6.rb +188 -0
  122. data/lib/shipeasy_admin/models/list_metrics_response_inner_query_ir_agg_one_of7.rb +217 -0
  123. data/lib/shipeasy_admin/models/list_metrics_response_inner_query_ir_agg_one_of8.rb +233 -0
  124. data/lib/shipeasy_admin/models/list_metrics_response_inner_query_ir_agg_one_of9.rb +240 -0
  125. data/lib/shipeasy_admin/models/list_metrics_response_inner_query_ir_agg_one_of9_numerator.rb +266 -0
  126. data/lib/shipeasy_admin/models/list_metrics_response_inner_query_ir_agg_one_of9_numerator_filters_inner.rb +264 -0
  127. data/lib/shipeasy_admin/models/list_metrics_response_inner_query_ir_group_by.rb +228 -0
  128. data/lib/shipeasy_admin/models/list_ops_items_response_inner.rb +349 -0
  129. data/lib/shipeasy_admin/models/list_slack_channels_response.rb +195 -0
  130. data/lib/shipeasy_admin/models/list_slack_channels_response_channels_inner.rb +203 -0
  131. data/lib/shipeasy_admin/models/list_universes_response.rb +178 -0
  132. data/lib/shipeasy_admin/models/list_universes_response_data_inner.rb +305 -0
  133. data/lib/shipeasy_admin/models/notify_ops_request.rb +261 -0
  134. data/lib/shipeasy_admin/models/notify_ops_response.rb +193 -0
  135. data/lib/shipeasy_admin/models/publish_config_draft_request.rb +190 -0
  136. data/lib/shipeasy_admin/models/publish_config_draft_response.rb +260 -0
  137. data/lib/shipeasy_admin/models/publish_i18n_profile_request.rb +149 -0
  138. data/lib/shipeasy_admin/models/publish_i18n_profile_response.rb +404 -0
  139. data/lib/shipeasy_admin/models/push_i18n_keys_request.rb +205 -0
  140. data/lib/shipeasy_admin/models/push_i18n_keys_request_keys_inner.rb +192 -0
  141. data/lib/shipeasy_admin/models/push_i18n_keys_response.rb +261 -0
  142. data/lib/shipeasy_admin/models/reanalyze_experiment_response.rb +214 -0
  143. data/lib/shipeasy_admin/models/save_config_draft_request.rb +202 -0
  144. data/lib/shipeasy_admin/models/save_config_draft_response.rb +286 -0
  145. data/lib/shipeasy_admin/models/set_experiment_metrics_request.rb +168 -0
  146. data/lib/shipeasy_admin/models/set_experiment_metrics_request_metrics_inner.rb +216 -0
  147. data/lib/shipeasy_admin/models/set_experiment_metrics_response.rb +179 -0
  148. data/lib/shipeasy_admin/models/set_experiment_metrics_response_metrics_inner.rb +201 -0
  149. data/lib/shipeasy_admin/models/set_experiment_status_request.rb +190 -0
  150. data/lib/shipeasy_admin/models/set_experiment_status_response.rb +214 -0
  151. data/lib/shipeasy_admin/models/set_killswitch_switch_request.rb +264 -0
  152. data/lib/shipeasy_admin/models/set_killswitch_switch_response.rb +288 -0
  153. data/lib/shipeasy_admin/models/unset_killswitch_switch_request.rb +237 -0
  154. data/lib/shipeasy_admin/models/unset_killswitch_switch_response.rb +289 -0
  155. data/lib/shipeasy_admin/models/update_alert_rule_request.rb +306 -0
  156. data/lib/shipeasy_admin/models/update_alert_rule_response.rb +165 -0
  157. data/lib/shipeasy_admin/models/update_config_request.rb +198 -0
  158. data/lib/shipeasy_admin/models/update_config_response.rb +165 -0
  159. data/lib/shipeasy_admin/models/update_event_request.rb +198 -0
  160. data/lib/shipeasy_admin/models/update_event_response.rb +166 -0
  161. data/lib/shipeasy_admin/models/update_experiment_request.rb +746 -0
  162. data/lib/shipeasy_admin/models/update_experiment_response.rb +165 -0
  163. data/lib/shipeasy_admin/models/update_gate_request.rb +403 -0
  164. data/lib/shipeasy_admin/models/update_gate_response.rb +166 -0
  165. data/lib/shipeasy_admin/models/update_i18n_key_request.rb +176 -0
  166. data/lib/shipeasy_admin/models/update_i18n_key_response.rb +166 -0
  167. data/lib/shipeasy_admin/models/update_killswitch_request.rb +224 -0
  168. data/lib/shipeasy_admin/models/update_killswitch_response.rb +165 -0
  169. data/lib/shipeasy_admin/models/update_ops_item_request.rb +205 -0
  170. data/lib/shipeasy_admin/models/update_ops_item_response.rb +166 -0
  171. data/lib/shipeasy_admin/models/update_universe_request.rb +213 -0
  172. data/lib/shipeasy_admin/models/update_universe_response.rb +165 -0
  173. data/lib/shipeasy_admin/models/upsert_project_request.rb +222 -0
  174. data/lib/shipeasy_admin/models/upsert_project_response.rb +260 -0
  175. data/lib/shipeasy_admin/version.rb +15 -0
  176. data/lib/shipeasy_admin.rb +196 -0
  177. metadata +235 -3
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Shipeasy
6
+ module Generators
7
+ # `rails generate shipeasy:install`
8
+ #
9
+ # Scaffolds Shipeasy into a Rails app the Rails way:
10
+ #
11
+ # - writes config/initializers/shipeasy.rb (the single Shipeasy.configure call)
12
+ # - with --i18n, sets the public client key in that initializer AND injects
13
+ # `<%= i18n_head_tags %>` into the application layout's <head>
14
+ # - prints the keys / credentials next steps
15
+ #
16
+ # The gem's Railties already auto-mount the anon-id Rack middleware and the
17
+ # i18n view helpers, so this generator never touches middleware wiring — it
18
+ # only creates the things an app must own: the initializer, the layout tag,
19
+ # and your keys.
20
+ class InstallGenerator < Rails::Generators::Base
21
+ source_root File.expand_path("templates", __dir__)
22
+
23
+ desc "Create config/initializers/shipeasy.rb (and, with --i18n, wire the i18n view helpers)."
24
+
25
+ class_option :i18n, type: :boolean, default: false,
26
+ desc: "Also enable i18n: set the public client key and inject i18n_head_tags into the app layout"
27
+ class_option :poll, type: :boolean, default: true,
28
+ desc: "Use the background poll (long-running server). Pass --no-poll for a serverless one-shot fetch"
29
+
30
+ APP_LAYOUT = "app/views/layouts/application.html.erb"
31
+
32
+ def create_initializer
33
+ template "initializer.rb.tt", "config/initializers/shipeasy.rb"
34
+ end
35
+
36
+ def inject_layout_helpers
37
+ return unless options[:i18n]
38
+
39
+ unless layout_exists?
40
+ say_status :skip, "#{APP_LAYOUT} not found — add <%= i18n_head_tags %> to your <head> by hand", :yellow
41
+ return
42
+ end
43
+
44
+ if layout_already_wired?
45
+ say_status :identical, "#{APP_LAYOUT} already has i18n_head_tags", :blue
46
+ return
47
+ end
48
+
49
+ # Insert just before the closing </head>, picking up its indentation.
50
+ inject_into_file APP_LAYOUT, "<%= i18n_head_tags %>\n ", before: "</head>"
51
+ end
52
+
53
+ def print_next_steps
54
+ say ""
55
+ say " Shipeasy installed → config/initializers/shipeasy.rb", :green
56
+ say ""
57
+ say " Next steps:"
58
+ say " 1. Mint your keys: https://app.shipeasy.ai → Settings → SDK keys"
59
+ say " 2. Provide them (pick one):"
60
+ say " • ENV — set SHIPEASY_SERVER_KEY#{options[:i18n] ? " and SHIPEASY_CLIENT_KEY" : ""}"
61
+ say " • Rails credentials — bin/rails credentials:edit, then read"
62
+ say " them from Rails.application.credentials in the initializer"
63
+ if options[:i18n]
64
+ say " 3. i18n_head_tags is wired into your layout — run the Shipeasy"
65
+ say " i18n install to create your en:prod profile, then translate."
66
+ end
67
+ say ""
68
+ say " Read a flag anywhere per request:"
69
+ say " Shipeasy::Client.new(current_user).get_flag(\"new_checkout\")"
70
+ say ""
71
+ say " Docs: https://docs.shipeasy.ai", :cyan
72
+ say ""
73
+ end
74
+
75
+ private
76
+
77
+ def layout_exists?
78
+ File.exist?(File.join(destination_root, APP_LAYOUT))
79
+ end
80
+
81
+ def layout_already_wired?
82
+ File.read(File.join(destination_root, APP_LAYOUT)).include?("i18n_head_tags")
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shipeasy — feature flags, dynamic configs, kill switches, A/B experiments,
4
+ # metrics and (optional) i18n. Generated by `rails generate shipeasy:install`.
5
+ #
6
+ # Configure the SDK ONCE here at boot, then construct a cheap, user-bound
7
+ # `Shipeasy::Client.new(current_user)` per request — every read takes no user
8
+ # argument because the user is bound at construction:
9
+ #
10
+ # flags = Shipeasy::Client.new(current_user)
11
+ # flags.get_flag("new_checkout") # NO user arg — bound at construction
12
+ #
13
+ # Docs: https://docs.shipeasy.ai
14
+ Shipeasy.configure do |c|
15
+ # Required — your Shipeasy SERVER key. A server-side secret; never embed it in
16
+ # the browser. Read it from the environment, or from Rails credentials:
17
+ # c.api_key = Rails.application.credentials.dig(:shipeasy, :server_key)
18
+ c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY", nil)
19
+
20
+ <% if options[:poll] -%>
21
+ # Long-running server (Puma): keep flags fresh with the background poll
22
+ # (initial fetch + periodic refresh). Set to false for serverless /
23
+ # short-lived processes — they get a one-shot fetch and no background thread.
24
+ c.poll = true
25
+ <% else -%>
26
+ # Serverless / short-lived process: one-shot fetch, no background thread.
27
+ c.poll = false
28
+ <% end -%>
29
+
30
+ # Optional — map YOUR user object to the Shipeasy attribute hash that
31
+ # targeting evaluates against. Runs once, in the Shipeasy::Client constructor.
32
+ # Delete this line to pass the object through unchanged (identity default).
33
+ c.attributes = ->(user) { { "user_id" => user.id } }
34
+ <% if options[:i18n] -%>
35
+
36
+ # i18n view helpers (i18n_head_tags / i18n_t) — the PUBLIC client key. Safe to
37
+ # expose to the browser via the loader tag these helpers emit.
38
+ c.public_key = ENV.fetch("SHIPEASY_CLIENT_KEY", nil)
39
+ c.profile = "en:prod"
40
+ <% end -%>
41
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional Admin API client for the Shipeasy Ruby SDK.
4
+ #
5
+ # This subpackage is OFF by default: the main `shipeasy-sdk` entrypoint never
6
+ # requires it, and its HTTP dependency (`faraday`) is an optional development
7
+ # dependency — so `require "shipeasy-sdk"` never pulls it in. Require it
8
+ # explicitly when you want to *administer* resources (create gates, start
9
+ # experiments, …) from server code:
10
+ #
11
+ # require "faraday"
12
+ # require "shipeasy/admin"
13
+ #
14
+ # admin = Shipeasy::Admin::Client.new(
15
+ # api_key: ENV.fetch("SHIPEASY_ADMIN_KEY"),
16
+ # project_id: ENV.fetch("SHIPEASY_PROJECT_ID"),
17
+ # )
18
+ # admin.gates.list_gates
19
+ #
20
+ # Everything under `Shipeasy::Admin::Generated` (lib/shipeasy_admin*) is produced
21
+ # by scripts/gen_admin.sh from the OpenAPI spec and must not be edited by hand.
22
+ # `Client` is a thin auth/scoping wrapper; it does NOT add name->id resolution or
23
+ # percent->basis-point conversion (that facade lives in the Shipeasy CLI/MCP). The
24
+ # surface here is the raw, 1:1-with-the-spec REST API.
25
+
26
+ # Define the namespace BEFORE loading the generated client: its files use the
27
+ # compact `module Shipeasy::Admin::Generated` form, which requires Shipeasy::Admin
28
+ # to already exist (and `require "shipeasy/admin"` may run without the main SDK).
29
+ module Shipeasy
30
+ module Admin
31
+ end
32
+ end
33
+
34
+ require "shipeasy_admin" # the generated client (loads faraday transitively)
35
+
36
+ module Shipeasy
37
+ module Admin
38
+ # Programmatic client for the Shipeasy Admin REST API. Each resource group is
39
+ # a lazily-constructed, memoized reader whose methods map 1:1 to the OpenAPI
40
+ # operations: #gates, #configs, #killswitches, #experiments, #universes,
41
+ # #metrics, #events, #alert_rules, #attributes, #projects, #ops, #i18n.
42
+ class Client
43
+ # Friendly reader name => generated Api class.
44
+ APIS = {
45
+ gates: Generated::GatesApi,
46
+ configs: Generated::ConfigsApi,
47
+ killswitches: Generated::KillswitchesApi,
48
+ experiments: Generated::ExperimentsApi,
49
+ universes: Generated::UniversesApi,
50
+ metrics: Generated::MetricsApi,
51
+ events: Generated::EventsApi,
52
+ alert_rules: Generated::AlertRulesApi,
53
+ attributes: Generated::AttributesApi,
54
+ projects: Generated::ProjectsApi,
55
+ ops: Generated::OpsApi,
56
+ i18n: Generated::I18nApi,
57
+ }.freeze
58
+
59
+ # @param api_key [String] admin SDK key, sent as `Authorization: Bearer <api_key>`.
60
+ # @param project_id [String, nil] optional project id sent as the `X-Project-Id`
61
+ # header on every request. Operations also accept an explicit `x_project_id:`
62
+ # argument to override per call.
63
+ # @param base_url [String] API base URL. Defaults to https://shipeasy.ai
64
+ # (the spec's production server); use http://localhost:3000 for local dev.
65
+ def initialize(api_key:, project_id: nil, base_url: "https://shipeasy.ai")
66
+ config = Generated::Configuration.new
67
+ config.access_token = api_key
68
+ scheme, _, host = base_url.rpartition("://")
69
+ config.scheme = scheme unless scheme.empty?
70
+ config.host = host.empty? ? base_url : host
71
+
72
+ @api_client = Generated::ApiClient.new(config)
73
+ @api_client.default_headers["X-Project-Id"] = project_id if project_id
74
+ @apis = {}
75
+ end
76
+
77
+ # The underlying generated ApiClient (advanced/escape hatch).
78
+ attr_reader :api_client
79
+
80
+ APIS.each_key do |name|
81
+ define_method(name) { @apis[name] ||= APIS.fetch(name).new(@api_client) }
82
+ end
83
+ end
84
+ end
85
+ end
@@ -55,6 +55,15 @@ module Shipeasy
55
55
  def get_killswitch(name, switch_key = nil)
56
56
  @engine.get_killswitch(name, switch_key)
57
57
  end
58
+
59
+ def track(event_name, props = {})
60
+ id = @attributes["user_id"] || @attributes["anonymous_id"]
61
+ @engine.track(id, event_name, props)
62
+ end
63
+
64
+ def log_exposure(experiment_name)
65
+ @engine.log_exposure(@attributes, experiment_name)
66
+ end
58
67
  end
59
68
 
60
69
  # Raised by Shipeasy::Client when constructed before Shipeasy.configure.
@@ -22,6 +22,27 @@ 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
+
25
46
  # Optional transform from YOUR user object (any shape) to the Shipeasy
26
47
  # attribute hash every flag/experiment evaluation uses. A callable
27
48
  # (lambda/proc or anything responding to #call). Default = identity (the
@@ -42,6 +63,13 @@ module Shipeasy
42
63
  def initialize
43
64
  @base_url = "https://edge.shipeasy.dev"
44
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
45
73
 
46
74
  @profile = "default"
47
75
  @default_chunk = "index"
@@ -105,23 +133,180 @@ module Shipeasy
105
133
  @engine
106
134
  end
107
135
 
108
- # Build + register the one global engine (first-config-wins). Fires the
109
- # one-shot fetch fire-and-forget. Idempotent within a process.
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.
110
139
  def register_engine!(cfg)
111
140
  return @engine if @engine && @engine_pid == Process.pid
112
141
  @engine_pid = Process.pid
113
- engine = Engine.new(api_key: cfg.api_key, base_url: cfg.base_url)
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
+ )
114
151
  @engine = engine
115
152
  # Capture +engine+ in the closure (not the @engine ivar, which a concurrent
116
153
  # reset/reconfigure could nil out before the thread runs).
117
- Thread.new do
118
- engine.init_once
119
- rescue => e
120
- warn "[shipeasy] configure() one-shot fetch failed: #{e.message}"
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
121
166
  end
122
167
  engine
123
168
  end
124
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."
308
+ end
309
+
125
310
  # Reset the config back to defaults — primarily for tests.
126
311
  def reset_config!
127
312
  @config = nil
@@ -90,15 +90,19 @@ module Shipeasy
90
90
 
91
91
  # Build a no-network, immediately-usable client for tests. Telemetry is
92
92
  # disabled, init/init_once/track are no-ops (never fetch), and no api_key
93
- # 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 /
94
96
  # override_experiment, then call the normal getters.
95
97
  def self.for_testing(env: "prod")
96
- new(
98
+ client = new(
97
99
  api_key: "test",
98
100
  env: env,
99
101
  disable_telemetry: true,
100
102
  test_mode: true,
101
103
  )
104
+ client.send(:load_snapshot, {}, {})
105
+ client
102
106
  end
103
107
 
104
108
  # Build an offline client from a JSON snapshot file. The file holds the
@@ -300,17 +304,21 @@ module Shipeasy
300
304
 
301
305
  # Read a killswitch from the cached flags blob. Without +switch_key+,
302
306
  # returns true when the whole killswitch is killed. With +switch_key+,
303
- # returns true when that specific per-key switch is on. Unknown
304
- # killswitches / switches return false. Not user-scoped.
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.
305
311
  def get_killswitch(name, switch_key = nil)
306
312
  @telemetry.emit("ks", name)
307
313
  ks = @mutex.synchronize { @flags_blob&.dig("killswitches", name.to_s) }
308
314
  return false unless ks
309
- if switch_key.nil?
310
- Eval.enabled?(ks["killed"])
311
- else
312
- Eval.enabled?(ks.dig("switches", switch_key.to_s))
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
313
320
  end
321
+ Eval.enabled?(ks["killed"])
314
322
  end
315
323
 
316
324
  # Batch-evaluate every loaded gate, config and experiment for +user+ into
@@ -11,11 +11,10 @@
11
11
  # require "open_feature/sdk"
12
12
  # require "shipeasy/sdk/openfeature"
13
13
  #
14
- # client = Shipeasy::Engine.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
@@ -38,6 +37,8 @@ rescue LoadError => e
38
37
  end
39
38
 
40
39
  require_relative "../engine"
40
+ require_relative "../config"
41
+ require_relative "../client"
41
42
 
42
43
  module Shipeasy
43
44
  module OpenFeature
@@ -66,7 +67,17 @@ module Shipeasy
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 = "2.0.0"
3
+ VERSION = "2.3.0"
4
4
  end
5
5
  end