Class: Clacky::BrandConfig
- Inherits:
-
Object
- Object
- Clacky::BrandConfig
- Defined in:
- lib/clacky/brand_config.rb
Overview
BrandConfig manages white-label branding for the OpenClacky gem.
Brand information is stored separately in ~/.clacky/brand.yml to avoid polluting the main config.yml. When no product_name is configured, the gem behaves exactly like the standard OpenClacky experience.
brand.yml structure:
product_name: "JohnAI"
package_name: "johnai"
logo_url: "https://example.com/logo.png"
support_contact: "support@johnai.com"
support_qr_url: "https://example.com/qr.png"
theme_color: "#3B82F6"
homepage_url: "https://johnai.com"
license_key: "0000002A-00000007-DEADBEEF-CAFEBABE-A1B2C3D4"
license_activated_at: "2025-03-01T00:00:00Z"
license_expires_at: "2026-03-01T00:00:00Z"
license_last_heartbeat: "2025-03-05T00:00:00Z"
device_id: "abc123def456..."
Constant Summary collapse
- CONFIG_DIR =
File.join(Dir.home, ".clacky")
- BRAND_FILE =
File.join(CONFIG_DIR, "brand.yml")
- HEARTBEAT_INTERVAL =
How often to send a heartbeat (seconds) — once per day
86_400- HEARTBEAT_GRACE_PERIOD =
Grace period for offline heartbeat failures (3 days)
3 * 86_400
- UPLOAD_META_FILE =
Path to the upload_meta.json file that tracks which local skills have been published to the platform and what version they were uploaded as.
Format:
{ "commit" => { "platform_version" => "1.2.0", "uploaded_at" => "2026-04-09T..." }, "nss-upload" => { "platform_version" => "1.0.0", "uploaded_at" => "..." } } File.join(Dir.home, ".clacky", "skills", "upload_meta.json").freeze
Instance Attribute Summary collapse
-
#device_id ⇒ Object
readonly
Returns the value of attribute device_id.
-
#distribution_last_refreshed_at ⇒ Object
readonly
Returns the value of attribute distribution_last_refreshed_at.
-
#homepage_url ⇒ Object
readonly
Returns the value of attribute homepage_url.
-
#license_activated_at ⇒ Object
readonly
Returns the value of attribute license_activated_at.
-
#license_expires_at ⇒ Object
readonly
Returns the value of attribute license_expires_at.
-
#license_key ⇒ Object
readonly
Returns the value of attribute license_key.
-
#license_last_heartbeat ⇒ Object
readonly
Returns the value of attribute license_last_heartbeat.
-
#license_last_heartbeat_failure ⇒ Object
readonly
Returns the value of attribute license_last_heartbeat_failure.
-
#license_user_id ⇒ Object
readonly
Returns the value of attribute license_user_id.
-
#logo_url ⇒ Object
readonly
Returns the value of attribute logo_url.
-
#package_name ⇒ Object
readonly
Returns the value of attribute package_name.
-
#product_name ⇒ Object
readonly
Returns the value of attribute product_name.
-
#support_contact ⇒ Object
readonly
Returns the value of attribute support_contact.
-
#support_qr_url ⇒ Object
readonly
Returns the value of attribute support_qr_url.
-
#theme_color ⇒ Object
readonly
Returns the value of attribute theme_color.
Class Method Summary collapse
- .install_lock_for(slug) ⇒ Object
-
.load ⇒ Object
Load brand configuration from ~/.clacky/brand.yml.
-
.load_upload_meta ⇒ Hash{String => Hash}
Load upload metadata for all published local skills.
-
.record_upload!(skill_name, platform_version) ⇒ Object
Persist a single skill's upload record.
-
.version_older?(installed, latest) ⇒ Boolean
Compare two semver strings.
Instance Method Summary collapse
-
#activate!(license_key) ⇒ Object
Activate the license against the OpenClacky Cloud API using HMAC proof.
-
#activate_mock!(license_key) ⇒ Object
Activate the license locally without calling the remote API.
-
#activated? ⇒ Boolean
Returns true when a license key has been stored (post-activation).
-
#brand_extension_detail!(id) ⇒ Object
Fetch detail for a single brand-private extension via the license-gated POST /api/v1/licenses/extension_detail endpoint.
-
#brand_extensions_registry_path ⇒ Object
Path to the JSON registry tracking installed brand extension versions.
-
#brand_skills_dir ⇒ Object
Path to the directory where brand skills are installed.
-
#branded? ⇒ Boolean
Returns true when this installation has a product name configured.
-
#clear_brand_skills! ⇒ Object
Remove all locally installed brand skills (encrypted files + metadata).
-
#deactivate! ⇒ Object
Remove the local license binding and wipe all brand-related fields from disk.
-
#decrypt_all_scripts(skill_dir, dest_dir) ⇒ Array<String>
Decrypt all supporting script files for a skill into a temporary directory.
-
#decrypt_skill_content(encrypted_path) ⇒ String
Decrypt an encrypted brand skill file and return its content in memory.
-
#delete_brand_extension!(ext_id) ⇒ Object
Remove a single installed brand extension by id (files + registry entry).
-
#delete_brand_skill!(skill_name) ⇒ void
Remove a single locally installed brand skill by name.
-
#delete_extension!(ext_id) ⇒ Object
Soft-delete (unpublish) one of the creator's extensions by id/slug.
-
#distribution_refresh_due? ⇒ Boolean
Returns true when a public distribution refresh is due.
- #ensure_device_id!(persist: true) ⇒ Object
-
#expired? ⇒ Boolean
Returns true when the license has passed its expiry date.
-
#extension_detail!(id) ⇒ Object
Fetch a single public marketplace extension's detail (contributes + version history).
-
#fetch_brand_extensions! ⇒ Object
Fetch the extensions bundled into the activated license's distribution.
-
#fetch_brand_skills! ⇒ Object
Fetch the brand skills list from the OpenClacky Cloud API.
-
#fetch_free_skills! ⇒ Object
Fetch the list of free (unencrypted, published) skills available for the configured package_name.
-
#fetch_my_extensions! ⇒ Object
Fetch the creator's own published extensions.
-
#fetch_my_skills! ⇒ Object
Fetch the public store skills list from the OpenClacky Cloud API.
-
#fetch_store_skills! ⇒ Object
Each skill in the returned array is a hash with at minimum: "name", "description", "icon", "repo".
-
#grace_period_exceeded? ⇒ Boolean
Returns true when heartbeats have been failing continuously for longer than the grace period.
-
#heartbeat! ⇒ Object
Send a heartbeat to the API and update last_heartbeat timestamp.
-
#heartbeat_due? ⇒ Boolean
Returns true when a heartbeat should be sent (interval elapsed).
-
#initialize(attrs = {}) ⇒ BrandConfig
constructor
A new instance of BrandConfig.
-
#install_brand_extension!(ext_info) ⇒ Object
Install (or update) a single brand extension by downloading its zip into the ExtensionLoader
installedlayer. -
#install_brand_skill!(skill_info, encrypted: true) ⇒ Object
Install (or update) a single brand skill by downloading and extracting its zip.
-
#install_free_skill!(skill_info) ⇒ Object
Install a single free (unencrypted) skill.
-
#install_mock_brand_skill!(skill_info) ⇒ Hash
Install a mock brand skill for brand-test mode.
-
#installed_brand_extensions ⇒ Object
Installed brand extensions keyed by ext_id => { "version" => "..." }.
-
#installed_brand_skills ⇒ Object
Read the local brand_skills.json metadata, cross-validated against the actual file system.
-
#refresh_distribution! ⇒ Object
Refresh public brand assets (logo, theme, homepage_url, support_*) for unactivated installs.
-
#save ⇒ Object
Save current state to ~/.clacky/brand.yml.
-
#search_extensions!(query: nil, sort: nil) ⇒ Object
Search the public extension marketplace.
-
#sync_brand_extensions_async!(on_complete: nil) ⇒ Thread?
Synchronise brand extensions in the background for activated installs.
-
#sync_brand_skills_async!(on_complete: nil, install_new: false) ⇒ Thread?
Synchronise brand skills in the background.
-
#sync_free_skills_async!(on_complete: nil) ⇒ Thread?
Synchronise free skills in the background for unactivated branded installs.
-
#to_h ⇒ Object
Returns a hash representation for JSON serialization (e.g. /api/brand).
- #to_yaml ⇒ Object
-
#update_extension_readme!(ext_id, readme) ⇒ Object
Update an extension's readme text without publishing a new version.
-
#upload_extension!(ext_id, zip_data, force: false, status: nil, changelog: nil, readme: nil, origin: 'marketplace') ⇒ Object
Upload (publish) a packed extension ZIP to the platform.
-
#upload_file!(data, filename:, content_type: "image/png") ⇒ Object
Upload any file to the platform's model-agnostic upload endpoint.
-
#upload_skill!(skill_name, zip_data, force: false, version_override: nil) ⇒ Object
Upload (publish) a custom skill ZIP to the OpenClacky Cloud API.
-
#user_licensed? ⇒ Boolean
Returns true when the license is bound to a specific user (user_id present).
Constructor Details
#initialize(attrs = {}) ⇒ BrandConfig
Returns a new instance of BrandConfig.
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/clacky/brand_config.rb', line 59 def initialize(attrs = {}) @product_name = attrs["product_name"] @package_name = attrs["package_name"] @logo_url = attrs["logo_url"] @support_contact = attrs["support_contact"] @support_qr_url = attrs["support_qr_url"] @theme_color = attrs["theme_color"] @homepage_url = attrs["homepage_url"] @license_key = attrs["license_key"] @license_activated_at = parse_time(attrs["license_activated_at"]) @license_expires_at = parse_time(attrs["license_expires_at"]) @license_last_heartbeat = parse_time(attrs["license_last_heartbeat"]) @device_id = attrs["device_id"] # user_id returned by the license server when the license is bound to a specific user @license_user_id = attrs["license_user_id"] # Tracks the last successful public distribution refresh (for installs that # have a package_name configured but are not yet activated — see # #refresh_distribution!). Persisted to brand.yml so 24h throttling # survives restarts. @distribution_last_refreshed_at = parse_time(attrs["distribution_last_refreshed_at"]) # Tracks when heartbeats started failing continuously. Set on a failed # heartbeat (only if currently nil), cleared on a successful one. # grace_period_exceeded? uses this — NOT last_heartbeat — so a user who # simply hasn't run the app in days doesn't see a stale "offline" warning. @license_last_heartbeat_failure = parse_time(attrs["license_last_heartbeat_failure"]) # In-memory decryption key cache: "skill_id:skill_version_id" => { key:, expires_at: } # Never persisted to disk. Survives across multiple skill invocations within one session. @decryption_keys = {} # Timestamp of last successful server contact (for grace period calculation) @last_server_contact_at = nil end |
Instance Attribute Details
#device_id ⇒ Object (readonly)
Returns the value of attribute device_id.
53 54 55 |
# File 'lib/clacky/brand_config.rb', line 53 def device_id @device_id end |
#distribution_last_refreshed_at ⇒ Object (readonly)
Returns the value of attribute distribution_last_refreshed_at.
53 54 55 |
# File 'lib/clacky/brand_config.rb', line 53 def distribution_last_refreshed_at @distribution_last_refreshed_at end |
#homepage_url ⇒ Object (readonly)
Returns the value of attribute homepage_url.
53 54 55 |
# File 'lib/clacky/brand_config.rb', line 53 def homepage_url @homepage_url end |
#license_activated_at ⇒ Object (readonly)
Returns the value of attribute license_activated_at.
53 54 55 |
# File 'lib/clacky/brand_config.rb', line 53 def license_activated_at @license_activated_at end |
#license_expires_at ⇒ Object (readonly)
Returns the value of attribute license_expires_at.
53 54 55 |
# File 'lib/clacky/brand_config.rb', line 53 def license_expires_at @license_expires_at end |
#license_key ⇒ Object (readonly)
Returns the value of attribute license_key.
53 54 55 |
# File 'lib/clacky/brand_config.rb', line 53 def license_key @license_key end |
#license_last_heartbeat ⇒ Object (readonly)
Returns the value of attribute license_last_heartbeat.
53 54 55 |
# File 'lib/clacky/brand_config.rb', line 53 def license_last_heartbeat @license_last_heartbeat end |
#license_last_heartbeat_failure ⇒ Object (readonly)
Returns the value of attribute license_last_heartbeat_failure.
53 54 55 |
# File 'lib/clacky/brand_config.rb', line 53 def license_last_heartbeat_failure @license_last_heartbeat_failure end |
#license_user_id ⇒ Object (readonly)
Returns the value of attribute license_user_id.
53 54 55 |
# File 'lib/clacky/brand_config.rb', line 53 def license_user_id @license_user_id end |
#logo_url ⇒ Object (readonly)
Returns the value of attribute logo_url.
53 54 55 |
# File 'lib/clacky/brand_config.rb', line 53 def logo_url @logo_url end |
#package_name ⇒ Object (readonly)
Returns the value of attribute package_name.
53 54 55 |
# File 'lib/clacky/brand_config.rb', line 53 def package_name @package_name end |
#product_name ⇒ Object (readonly)
Returns the value of attribute product_name.
53 54 55 |
# File 'lib/clacky/brand_config.rb', line 53 def product_name @product_name end |
#support_contact ⇒ Object (readonly)
Returns the value of attribute support_contact.
53 54 55 |
# File 'lib/clacky/brand_config.rb', line 53 def support_contact @support_contact end |
#support_qr_url ⇒ Object (readonly)
Returns the value of attribute support_qr_url.
53 54 55 |
# File 'lib/clacky/brand_config.rb', line 53 def support_qr_url @support_qr_url end |
#theme_color ⇒ Object (readonly)
Returns the value of attribute theme_color.
53 54 55 |
# File 'lib/clacky/brand_config.rb', line 53 def theme_color @theme_color end |
Class Method Details
.install_lock_for(slug) ⇒ Object
49 50 51 |
# File 'lib/clacky/brand_config.rb', line 49 def self.install_lock_for(slug) @install_locks_guard.synchronize { @install_locks[slug] ||= Mutex.new } end |
.load ⇒ Object
Load brand configuration from ~/.clacky/brand.yml. Returns an empty BrandConfig (no brand) if the file does not exist. Always ensures a stable device_id is present and persisted.
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 |
# File 'lib/clacky/brand_config.rb', line 95 def self.load if File.exist?(BRAND_FILE) data = YAML.safe_load(File.read(BRAND_FILE)) || {} else data = {} end instance = new(data) instance.ensure_device_id! instance rescue StandardError => e # A read/parse failure here is almost always a transient corruption (e.g. a # concurrent write caught mid-truncate). Do NOT touch brand.yml — overwriting # it with an empty config would permanently wipe the license, and even moving # it aside could lose a file that self-heals a few milliseconds later. Leave # the on-disk file untouched and return an in-memory-only config (device_id # generated but never saved); the next load usually succeeds. Clacky::Logger.error("[Brand] load failed, using in-memory fallback WITHOUT touching brand.yml: #{e.class}: #{e.}") instance = new({}) instance.ensure_device_id!(persist: false) instance end |
.load_upload_meta ⇒ Hash{String => Hash}
Load upload metadata for all published local skills.
1622 1623 1624 1625 1626 1627 1628 |
# File 'lib/clacky/brand_config.rb', line 1622 def self. return {} unless File.exist?(UPLOAD_META_FILE) JSON.parse(File.read(UPLOAD_META_FILE)) rescue StandardError {} end |
.record_upload!(skill_name, platform_version) ⇒ Object
Persist a single skill's upload record.
1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 |
# File 'lib/clacky/brand_config.rb', line 1633 def self.record_upload!(skill_name, platform_version) = [skill_name] = { "platform_version" => platform_version, "uploaded_at" => Time.now.utc.iso8601 } dir = File.dirname(UPLOAD_META_FILE) FileUtils.mkdir_p(dir) File.write(UPLOAD_META_FILE, JSON.generate()) rescue StandardError # Non-fatal — metadata write failure should not break the upload flow end |
.version_older?(installed, latest) ⇒ Boolean
Compare two semver strings. Returns true when installed is strictly
older than latest (i.e. the server has a newer version available).
Returns false when installed >= latest, or when either version is blank/nil,
so a local dev build never shows a spurious "Update" badge.
1691 1692 1693 1694 1695 1696 1697 1698 |
# File 'lib/clacky/brand_config.rb', line 1691 def self.version_older?(installed, latest) return false if installed.to_s.strip.empty? || latest.to_s.strip.empty? Gem::Version.new(installed.to_s.strip) < Gem::Version.new(latest.to_s.strip) rescue ArgumentError # Unparseable version strings — treat as "not older" to avoid false positives false end |
Instance Method Details
#activate!(license_key) ⇒ Object
Activate the license against the OpenClacky Cloud API using HMAC proof. Returns a result hash: { success: bool, message: String, data: Hash }
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 |
# File 'lib/clacky/brand_config.rb', line 231 def activate!(license_key) @license_key = license_key.strip @device_id ||= generate_device_id user_id = parse_user_id_from_key(@license_key) key_hash = Digest::SHA256.hexdigest(@license_key) ts = Time.now.utc.to_i.to_s nonce = SecureRandom.hex(16) = "activate:#{key_hash}:#{user_id}:#{@device_id}:#{ts}:#{nonce}" proof = OpenSSL::HMAC.hexdigest("SHA256", @license_key, ) payload = { key_hash: key_hash, user_id: user_id.to_s, device_id: @device_id, timestamp: ts, nonce: nonce, proof: proof, device_info: device_info } response = api_post("/api/v1/licenses/activate", payload) if response[:success] && response[:data]["status"] == "active" data = response[:data] @license_activated_at = Time.now.utc @license_last_heartbeat = Time.now.utc @license_last_heartbeat_failure = nil @license_expires_at = parse_time(data["expires_at"]) server_device_id = data["device_id"].to_s.strip @device_id = server_device_id unless server_device_id.empty? # Decide whether the new key belongs to the SAME brand as the previously # activated one. If yes (e.g. trial → paid), keep the installed brand # skills — they are still decryptable and the user shouldn't have to # re-download. If no (switching brands), wipe them. prev_package_name = @package_name prev_product_name = @product_name new_dist = data["distribution"].is_a?(Hash) ? data["distribution"] : {} same_brand = brand_identity_match?(prev_package_name, prev_product_name, new_dist) # Clear ALL stale fields first, then apply fresh values from the new key. # Order matters: reset everything before re-assigning so no old value lingers. @product_name = nil @package_name = nil @logo_url = nil @support_contact = nil @support_qr_url = nil @theme_color = nil @homepage_url = nil @license_user_id = nil # Re-apply owner_user_id from the new activation response. # Only system (creator) licenses return a non-nil owner_user_id. # Brand-consumer keys return nil → @license_user_id stays nil → user_licensed? = false. owner_uid = data["owner_user_id"] @license_user_id = owner_uid.to_s.strip if owner_uid && !owner_uid.to_s.strip.empty? apply_distribution(data["distribution"]) # Skills from a different brand are encrypted with that brand's keys — # they cannot be decrypted with the new license and must be re-downloaded. # Same-brand re-activation (trial→paid, key rotation) preserves them. clear_brand_skills! unless same_brand save { success: true, message: "License activated successfully!", product_name: @product_name, user_id: @license_user_id, data: data } else @license_key = nil { success: false, message: response[:error] || "Activation failed", data: {} } end end |
#activate_mock!(license_key) ⇒ Object
Activate the license locally without calling the remote API. Used in brand-test mode for development and integration testing.
The mock derives a plausible product_name from the key's first segment (e.g. "0000002A" → user_id 42 → "Brand42") unless one is already set. A fixed 1-year expiry is written so the UI can display a realistic date.
Returns the same { success:, message:, product_name:, data: } shape as activate!
309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 |
# File 'lib/clacky/brand_config.rb', line 309 def activate_mock!(license_key) @license_key = license_key.strip # Pin a stable device_id for this activation. Once set (from a prior load or # a previous call), never regenerate — the same rule as activate!. @device_id ||= generate_device_id # Always derive product_name fresh from the key in mock mode, # so switching keys produces a different brand each time. user_id = parse_user_id_from_key(@license_key) new_product_name = "Brand#{user_id}" prev_product_name = @product_name same_brand = brand_identity_match?(@package_name, prev_product_name, { "product_name" => new_product_name }) @product_name = new_product_name @license_activated_at = Time.now.utc @license_last_heartbeat = Time.now.utc @license_last_heartbeat_failure = nil @license_expires_at = Time.now.utc + (365 * 86_400) # 1 year from now # Same-brand re-activation preserves installed skills; switching brands wipes them. clear_brand_skills! unless same_brand save { success: true, message: "License activated (mock mode).", product_name: @product_name, data: { status: "active", expires_at: @license_expires_at.iso8601 } } end |
#activated? ⇒ Boolean
Returns true when a license key has been stored (post-activation).
132 133 134 |
# File 'lib/clacky/brand_config.rb', line 132 def activated? !@license_key.nil? && !@license_key.strip.empty? end |
#brand_extension_detail!(id) ⇒ Object
Fetch detail for a single brand-private extension via the license-gated POST /api/v1/licenses/extension_detail endpoint. The public /api/v1/extensions/:id intentionally omits origin=self extensions, so activated brand users must use this signed request instead. Returns { success:, extension:, error: }.
817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 |
# File 'lib/clacky/brand_config.rb', line 817 def brand_extension_detail!(id) return { success: false, error: "License not activated" } unless activated? user_id = parse_user_id_from_key(@license_key) ts = Time.now.utc.to_i.to_s nonce = SecureRandom.hex(16) = "#{user_id}:#{@device_id}:#{ts}:#{nonce}" payload = { key_hash: Digest::SHA256.hexdigest(@license_key), user_id: user_id.to_s, device_id: @device_id, timestamp: ts, nonce: nonce, signature: OpenSSL::HMAC.hexdigest("SHA256", @license_key, ), id: id.to_s } response = api_post("/api/v1/licenses/extension", payload) if response[:success] { success: true, extension: response[:data]["extension"] } else { success: false, error: response[:error] || "Extension not found" } end rescue StandardError => e { success: false, error: "Network error: #{e.}" } end |
#brand_extensions_registry_path ⇒ Object
Path to the JSON registry tracking installed brand extension versions.
944 945 946 |
# File 'lib/clacky/brand_config.rb', line 944 def brand_extensions_registry_path File.join(File.(Clacky::ExtensionLoader::INSTALLED_DIR), "brand_extensions.json") end |
#brand_skills_dir ⇒ Object
Path to the directory where brand skills are installed.
1365 1366 1367 |
# File 'lib/clacky/brand_config.rb', line 1365 def brand_skills_dir File.join(CONFIG_DIR, "brand_skills") end |
#branded? ⇒ Boolean
Returns true when this installation has a product name configured.
127 128 129 |
# File 'lib/clacky/brand_config.rb', line 127 def branded? !@product_name.nil? && !@product_name.strip.empty? end |
#clear_brand_skills! ⇒ Object
Remove all locally installed brand skills (encrypted files + metadata). Called on license activation so stale skills from a previous brand cannot linger — they are encrypted with that brand's keys and are inaccessible under the new license anyway.
1373 1374 1375 1376 1377 1378 1379 1380 |
# File 'lib/clacky/brand_config.rb', line 1373 def clear_brand_skills! dir = brand_skills_dir return unless Dir.exist?(dir) FileUtils.rm_rf(dir) # Also clear in-memory decryption key cache so no stale keys survive @decryption_keys.clear if @decryption_keys end |
#deactivate! ⇒ Object
Remove the local license binding and wipe all brand-related fields from disk. Brand skills installed from this license are also cleared. Returns { success: true }.
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 |
# File 'lib/clacky/brand_config.rb', line 207 def deactivate! clear_brand_skills! FileUtils.rm_f(BRAND_FILE) # Reset all in-memory state so this instance is clean after the call. @product_name = nil @package_name = nil @logo_url = nil @support_contact = nil @support_qr_url = nil @theme_color = nil @homepage_url = nil @license_key = nil @license_activated_at = nil @license_expires_at = nil @license_last_heartbeat = nil @license_user_id = nil @device_id = nil @distribution_last_refreshed_at = nil @license_last_heartbeat_failure = nil { success: true } end |
#decrypt_all_scripts(skill_dir, dest_dir) ⇒ Array<String>
Decrypt all supporting script files for a skill into a temporary directory.
Scans skill_dir recursively for *.enc files, skipping SKILL.md.enc and
MANIFEST.enc.json. Each file is decrypted in memory and written to the
corresponding relative path under dest_dir. The decryption key is fetched
once (cached) for all files belonging to the same skill version.
For mock/plain skills (no MANIFEST.enc.json) the raw bytes are used as-is.
1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 |
# File 'lib/clacky/brand_config.rb', line 1500 def decrypt_all_scripts(skill_dir, dest_dir) raise "License not activated — cannot decrypt brand skill" unless activated? manifest_path = File.join(skill_dir, "MANIFEST.enc.json") manifest = File.exist?(manifest_path) ? JSON.parse(File.read(manifest_path)) : nil written = [] # Find all .enc files that are not SKILL.md.enc or the manifest itself Dir.glob(File.join(skill_dir, "**", "*.enc")).each do |enc_path| basename = File.basename(enc_path) next if basename == "SKILL.md.enc" next if basename == "MANIFEST.enc.json" # Relative path from skill_dir, stripping the .enc suffix rel_enc = enc_path.sub("#{skill_dir}/", "") # e.g. "scripts/analyze.rb.enc" rel_plain = rel_enc.sub(/\.enc\z/, "") # e.g. "scripts/analyze.rb" plaintext = if manifest # Read manifest entry using the relative plain path = manifest["files"] && manifest["files"][rel_plain] raise "File '#{rel_plain}' not found in MANIFEST.enc.json" unless skill_id = manifest["skill_id"] skill_version_id = manifest["skill_version_id"] key = fetch_decryption_key(skill_id: skill_id, skill_version_id: skill_version_id) ciphertext = File.binread(enc_path) if ciphertext.nil? || ciphertext.empty? # AES-GCM of empty data still produces 16+ bytes (auth tag + IV). # A 0-byte file means the skill package is corrupted; skip # decryption and produce an empty output so the skill can still run. "" else pt = aes_gcm_decrypt(key, ciphertext, ["iv"], ["tag"]) # Integrity check actual = Digest::SHA256.hexdigest(pt) expected = ["original_checksum"] if expected && actual != expected raise "Checksum mismatch for #{rel_plain}: expected #{expected}, got #{actual}" end pt end else # Mock/plain skill: raw bytes File.binread(enc_path).force_encoding("UTF-8") end out_path = File.join(dest_dir, rel_plain) FileUtils.mkdir_p(File.dirname(out_path)) File.write(out_path, plaintext) # Preserve executable permission hint from extension File.chmod(0o700, out_path) written << rel_plain end written rescue Errno::ENOENT => e raise "Brand skill file not found: #{e.}" rescue JSON::ParserError => e raise "Invalid MANIFEST.enc.json: #{e.}" end |
#decrypt_skill_content(encrypted_path) ⇒ String
Decrypt an encrypted brand skill file and return its content in memory.
Security model:
- Skill files are AES-256-GCM encrypted. Each skill directory contains a
MANIFEST.enc.json that stores per-file IV, auth tag, checksum, and the
skill_version_id needed to request the decryption key from the server.
- Decryption keys are requested from the server once and cached in memory
(never written to disk). Subsequent calls for the same skill version are
served entirely from cache without network I/O.
- Decrypted content exists only in memory and is never written to disk.
Fallback for mock/plain skills:
When no MANIFEST.enc.json exists in the skill directory, the method falls
back to reading the .enc file as raw UTF-8 bytes (mock/dev mode).
1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 |
# File 'lib/clacky/brand_config.rb', line 1437 def decrypt_skill_content(encrypted_path) raise "License not activated — cannot decrypt brand skill" unless activated? skill_dir = File.dirname(encrypted_path) manifest_path = File.join(skill_dir, "MANIFEST.enc.json") # Fall back to plain-bytes mode when no MANIFEST present (mock skills). unless File.exist?(manifest_path) raw = File.binread(encrypted_path) return raw.force_encoding("UTF-8") end # Read and parse the manifest manifest = JSON.parse(File.read(manifest_path)) skill_id = manifest["skill_id"] skill_version_id = manifest["skill_version_id"] raise "MANIFEST.enc.json missing skill_id" unless skill_id raise "MANIFEST.enc.json missing skill_version_id" unless skill_version_id # Derive the relative file path (e.g. "SKILL.md") from the .enc filename enc_basename = File.basename(encrypted_path) # "SKILL.md.enc" file_path = enc_basename.sub(/\.enc\z/, "") # "SKILL.md" = manifest["files"] && manifest["files"][file_path] raise "File '#{file_path}' not found in MANIFEST.enc.json" unless # Fetch decryption key — served from in-memory cache when available key = fetch_decryption_key(skill_id: skill_id, skill_version_id: skill_version_id) # Decrypt using AES-256-GCM ciphertext = File.binread(encrypted_path) plaintext = aes_gcm_decrypt(key, ciphertext, ["iv"], ["tag"]) # Integrity check actual = Digest::SHA256.hexdigest(plaintext) expected = ["original_checksum"] if expected && actual != expected raise "Checksum mismatch for #{file_path}: " \ "expected #{expected}, got #{actual}" end plaintext rescue Errno::ENOENT => e raise "Brand skill file not found: #{e.}" rescue JSON::ParserError => e raise "Invalid MANIFEST.enc.json: #{e.}" end |
#delete_brand_extension!(ext_id) ⇒ Object
Remove a single installed brand extension by id (files + registry entry).
971 972 973 974 975 976 977 978 979 980 981 982 983 984 |
# File 'lib/clacky/brand_config.rb', line 971 def delete_brand_extension!(ext_id) ext_dir = File.join(File.(Clacky::ExtensionLoader::INSTALLED_DIR), ext_id) FileUtils.rm_rf(ext_dir) if Dir.exist?(ext_dir) path = brand_extensions_registry_path if File.exist?(path) registry = JSON.parse(File.read(path)) registry.delete(ext_id) File.write(path, JSON.generate(registry)) end Clacky::ExtensionLoader.invalidate_cache! rescue StandardError # Deletion errors are non-fatal. end |
#delete_brand_skill!(skill_name) ⇒ void
This method returns an undefined value.
Remove a single locally installed brand skill by name.
Deletes the skill's directory from disk and removes its entry from brand_skills.json. Also evicts any cached decryption key for that skill so no stale key survives in memory.
This is called during background sync when a skill that was previously installed is no longer present in the remote catalogue (i.e. the brand administrator deleted it on the platform side).
1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 |
# File 'lib/clacky/brand_config.rb', line 1394 def delete_brand_skill!(skill_name) # Remove files from disk. skill_dir = File.join(brand_skills_dir, skill_name) FileUtils.rm_rf(skill_dir) if Dir.exist?(skill_dir) # Remove entry from brand_skills.json. json_path = File.join(brand_skills_dir, "brand_skills.json") if File.exist?(json_path) registry = JSON.parse(File.read(json_path)) registry.delete(skill_name) File.write(json_path, JSON.generate(registry)) end # Evict cached decryption key (keyed by skill_version_id strings). # We don't know the exact version id here, but we can drop any key whose # associated manifest lives inside the now-deleted directory (they are # already gone from disk). The simplest safe approach: clear the whole # in-memory cache — keys will be re-fetched on next access for surviving # skills. @decryption_keys&.clear rescue StandardError # Deletion errors are non-fatal — a stale skill directory is harmless # compared to aborting the entire sync operation. end |
#delete_extension!(ext_id) ⇒ Object
Soft-delete (unpublish) one of the creator's extensions by id/slug. Uses DELETE /api/v1/client/extensions/:id. Returns { success:, error: }.
713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 |
# File 'lib/clacky/brand_config.rb', line 713 def delete_extension!(ext_id) identity = Clacky::Identity.load return { success: false, error: "Device not bound to a platform account" } unless identity.bound? path = "/api/v1/client/extensions/#{URI.encode_www_form_component(ext_id)}" response = platform_client.delete( path, headers: { "Authorization" => "Bearer #{identity.device_token}" } ) if response[:success] { success: true } else { success: false, error: response[:error] || "Delete failed" } end end |
#distribution_refresh_due? ⇒ Boolean
Returns true when a public distribution refresh is due.
Refresh is needed for unactivated branded installs and for private platform sources that have not supplied their deployment brand yet. Activated licenses already get fresh distribution data via #heartbeat!.
Rate limit: once every HEARTBEAT_INTERVAL (24h), measured from the last SUCCESSFUL refresh. A failed refresh does not advance the clock so we'll keep trying on subsequent startups / status polls.
393 394 395 396 397 398 399 400 |
# File 'lib/clacky/brand_config.rb', line 393 def distribution_refresh_due? return false if activated? return false unless branded? || private_platform_source? return true if @distribution_last_refreshed_at.nil? elapsed = Time.now.utc - @distribution_last_refreshed_at elapsed >= HEARTBEAT_INTERVAL end |
#ensure_device_id!(persist: true) ⇒ Object
118 119 120 121 122 123 124 |
# File 'lib/clacky/brand_config.rb', line 118 def ensure_device_id!(persist: true) return if @device_id && !@device_id.strip.empty? Clacky::Logger.warn("[Brand] regenerating device_id (previous was blank; brand.yml may have been reset or corrupted)") @device_id = generate_device_id save if persist end |
#expired? ⇒ Boolean
Returns true when the license has passed its expiry date.
137 138 139 140 141 |
# File 'lib/clacky/brand_config.rb', line 137 def expired? return false if @license_expires_at.nil? Time.now.utc > @license_expires_at end |
#extension_detail!(id) ⇒ Object
Fetch a single public marketplace extension's detail (contributes + version history). Anonymous, no license required. Returns { success:, extension:, error: }.
800 801 802 803 804 805 806 807 808 809 810 |
# File 'lib/clacky/brand_config.rb', line 800 def extension_detail!(id) response = platform_client.get("/api/v1/extensions/#{URI.encode_www_form_component(id.to_s)}") if response[:success] { success: true, extension: response[:data]["extension"] } else { success: false, error: response[:error] || "Not found" } end rescue StandardError => e { success: false, error: "Network error: #{e.}" } end |
#fetch_brand_extensions! ⇒ Object
Fetch the extensions bundled into the activated license's distribution. Requires an activated license. Returns { success:, extensions: [], error: }. Each extension carries name + latest_version.download_url so install_brand_extension! can consume it directly.
854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 |
# File 'lib/clacky/brand_config.rb', line 854 def fetch_brand_extensions! return { success: false, error: "License not activated", extensions: [] } unless activated? user_id = parse_user_id_from_key(@license_key) ts = Time.now.utc.to_i.to_s nonce = SecureRandom.hex(16) = "#{user_id}:#{@device_id}:#{ts}:#{nonce}" payload = { key_hash: Digest::SHA256.hexdigest(@license_key), user_id: user_id.to_s, device_id: @device_id, timestamp: ts, nonce: nonce, signature: OpenSSL::HMAC.hexdigest("SHA256", @license_key, ) } response = api_post("/api/v1/licenses/extensions", payload) if response[:success] body = response[:data] installed = installed_brand_extensions extensions = (body["extensions"] || []).map do |ext| slug = ext["name"].to_s local = installed[slug] latest_ver = (ext["latest_version"] || {})["version"] || ext["version"] ext.merge( "installed_version" => local ? local["version"] : nil, "needs_update" => local ? version_older?(local["version"], latest_ver) : true ) end { success: true, extensions: extensions, expires_at: body["expires_at"] } else { success: false, error: response[:error] || "Failed to fetch extensions", extensions: [] } end end |
#fetch_brand_skills! ⇒ Object
Fetch the brand skills list from the OpenClacky Cloud API. Requires an activated license. Returns { success: bool, skills: [], error: }.
1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 |
# File 'lib/clacky/brand_config.rb', line 1066 def fetch_brand_skills! return { success: false, error: "License not activated", skills: [] } unless activated? user_id = parse_user_id_from_key(@license_key) key_hash = Digest::SHA256.hexdigest(@license_key) ts = Time.now.utc.to_i.to_s nonce = SecureRandom.hex(16) = "#{user_id}:#{@device_id}:#{ts}:#{nonce}" signature = OpenSSL::HMAC.hexdigest("SHA256", @license_key, ) payload = { key_hash: key_hash, user_id: user_id.to_s, device_id: @device_id, timestamp: ts, nonce: nonce, signature: signature } response = api_post("/api/v1/licenses/skills", payload) if response[:success] body = response[:data] # Merge local installed version info into each skill installed = installed_brand_skills skills = (body["skills"] || []).map do |skill| # Normalize name to valid skill name format; prefer the matching local installed dir name normalized = skill["name"].to_s.downcase.gsub(/[\s_]+/, "-").gsub(/[^a-z0-9-]/, "").gsub(/-+/, "-") name = installed.keys.find { |k| k == normalized } || normalized local = installed[name] # The authoritative "latest" version lives in latest_version.version when present, # falling back to the top-level version field for older API responses. latest_ver = (skill["latest_version"] || {})["version"] || skill["version"] # Only flag needs_update when the server has a strictly newer version than local. # If local >= latest (e.g. a dev build), suppress the update badge. needs_update = local ? version_older?(local["version"], latest_ver) : false skill.merge( "name" => name, "installed_version" => local ? local["version"] : nil, "needs_update" => needs_update ) end { success: true, skills: skills, expires_at: body["expires_at"] } else { success: false, error: response[:error] || "Failed to fetch skills", skills: [] } end end |
#fetch_free_skills! ⇒ Object
Fetch the list of free (unencrypted, published) skills available for the configured package_name. Anonymous endpoint — no license key required. This is what powers the "no serial number" free mode: a branded install that is not activated still gets the creator's free skills automatically.
Returns { success: bool, skills: [], error: }. Each skill in the returned array carries the same shape as fetch_brand_skills! (name, latest_version, description, etc.) so install_brand_skill! can consume it directly.
473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 |
# File 'lib/clacky/brand_config.rb', line 473 def fetch_free_skills! return { success: false, error: "Not branded", skills: [] } unless branded? if @package_name.nil? || @package_name.strip.empty? return { success: false, error: "package_name not configured", skills: [] } end encoded_pkg = URI.encode_www_form_component(@package_name.strip) response = platform_client.get("/api/v1/distributions/free_skills?package_name=#{encoded_pkg}") if response[:success] && response[:data].is_a?(Hash) installed = installed_brand_skills skills = (response[:data]["skills"] || []).map do |skill| normalized = skill["name"].to_s.downcase.gsub(/[\s_]+/, "-").gsub(/[^a-z0-9-]/, "").gsub(/-+/, "-") name = installed.keys.find { |k| k == normalized } || normalized local = installed[name] latest_ver = (skill["latest_version"] || {})["version"] || skill["version"] needs_update = local ? version_older?(local["version"], latest_ver) : false skill.merge( "name" => name, "installed_version" => local ? local["version"] : nil, "needs_update" => needs_update ) end { success: true, skills: skills, paid_skills_count: response[:data]["paid_skills_count"].to_i } else { success: false, error: response[:error] || "Failed to fetch free skills", skills: [], paid_skills_count: 0 } end end |
#fetch_my_extensions! ⇒ Object
Fetch the creator's own published extensions. Uses GET /api/v1/client/extensions (HMAC-signed, system license only). Returns { success: bool, extensions: [], error: }.
695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 |
# File 'lib/clacky/brand_config.rb', line 695 def fetch_my_extensions! identity = Clacky::Identity.load return { success: false, error: "Device not bound to a platform account", extensions: [] } unless identity.bound? response = platform_client.get( "/api/v1/client/extensions", headers: { "Authorization" => "Bearer #{identity.device_token}" } ) if response[:success] { success: true, extensions: response[:data]["extensions"] || [] } else { success: false, error: response[:error] || "Fetch failed", extensions: [] } end end |
#fetch_my_skills! ⇒ Object
Fetch the public store skills list from the OpenClacky Cloud API. Uses GET /api/v1/client/skills (HMAC-signed, system license only). Returns { success: bool, skills: [], error: }.
999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 |
# File 'lib/clacky/brand_config.rb', line 999 def fetch_my_skills! return { success: false, error: "License not activated", skills: [] } unless activated? return { success: false, error: "User license required", skills: [] } unless user_licensed? user_id = @license_user_id.to_s key_hash = Digest::SHA256.hexdigest(@license_key) ts = Time.now.utc.to_i.to_s nonce = SecureRandom.hex(16) = "#{user_id}:#{@device_id}:#{ts}:#{nonce}" signature = OpenSSL::HMAC.hexdigest("SHA256", @license_key, ) query = URI.encode_www_form( key_hash: key_hash, user_id: user_id, device_id: @device_id, timestamp: ts, nonce: nonce, signature: signature ) response = platform_client.get("/api/v1/client/skills?#{query}") if response[:success] skills = response[:data]["skills"] || [] { success: true, skills: skills } else { success: false, error: response[:error] || "Failed to fetch skills", skills: [] } end rescue StandardError => e { success: false, error: "Network error: #{e.}", skills: [] } end |
#fetch_store_skills! ⇒ Object
Each skill in the returned array is a hash with at minimum:
"name", "description", "icon", "repo"
1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 |
# File 'lib/clacky/brand_config.rb', line 1033 def fetch_store_skills! return { success: false, error: "License not activated", skills: [] } unless activated? user_id = parse_user_id_from_key(@license_key) key_hash = Digest::SHA256.hexdigest(@license_key) ts = Time.now.utc.to_i.to_s nonce = SecureRandom.hex(16) = "#{user_id}:#{@device_id}:#{ts}:#{nonce}" signature = OpenSSL::HMAC.hexdigest("SHA256", @license_key, ) payload = { key_hash: key_hash, user_id: user_id.to_s, device_id: @device_id, timestamp: ts, nonce: nonce, signature: signature, scope: "store" } response = api_post("/api/v1/licenses/skills", payload) if response[:success] body = response[:data] skills = body["skills"] || [] { success: true, skills: skills } else { success: false, error: response[:error] || "Failed to fetch store skills", skills: [] } end end |
#grace_period_exceeded? ⇒ Boolean
Returns true when heartbeats have been failing continuously for longer than the grace period. Only considers ACTUAL failure streaks — a user who hasn't launched the app in a week is NOT in violation, since no heartbeat attempt has actually failed.
160 161 162 163 164 165 166 167 168 169 170 |
# File 'lib/clacky/brand_config.rb', line 160 def grace_period_exceeded? if @license_last_heartbeat_failure.nil? Clacky::Logger.debug("[Brand] grace_period_exceeded? => false (no active failure streak)") return false end elapsed = Time.now.utc - @license_last_heartbeat_failure exceeded = elapsed >= HEARTBEAT_GRACE_PERIOD Clacky::Logger.debug("[Brand] grace_period_exceeded? failing_since=#{@license_last_heartbeat_failure.iso8601} elapsed=#{elapsed.to_i}s grace=#{HEARTBEAT_GRACE_PERIOD}s => #{exceeded}") exceeded end |
#heartbeat! ⇒ Object
Send a heartbeat to the API and update last_heartbeat timestamp. Returns a result hash: { success: bool, message: String }
342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 |
# File 'lib/clacky/brand_config.rb', line 342 def heartbeat! unless activated? Clacky::Logger.debug("[Brand] heartbeat! skipped — license not activated") return { success: false, message: "License not activated" } end Clacky::Logger.info("[Brand] heartbeat! sending — last_heartbeat=#{@license_last_heartbeat&.iso8601 || "nil"} expires_at=#{@license_expires_at&.iso8601 || "nil"}") user_id = parse_user_id_from_key(@license_key) key_hash = Digest::SHA256.hexdigest(@license_key) ts = Time.now.utc.to_i.to_s nonce = SecureRandom.hex(16) = "#{user_id}:#{@device_id}:#{ts}:#{nonce}" signature = OpenSSL::HMAC.hexdigest("SHA256", @license_key, ) payload = { key_hash: key_hash, user_id: user_id.to_s, device_id: @device_id, timestamp: ts, nonce: nonce, signature: signature } response = api_post("/api/v1/licenses/heartbeat", payload) if response[:success] @license_last_heartbeat = Time.now.utc @license_last_heartbeat_failure = nil @license_expires_at = parse_time(response[:data]["expires_at"]) if response[:data]["expires_at"] apply_distribution(response[:data]["distribution"]) save Clacky::Logger.info("[Brand] heartbeat! success — expires_at=#{@license_expires_at&.iso8601} last_heartbeat=#{@license_last_heartbeat.iso8601}") { success: true, message: "Heartbeat OK" } else @license_last_heartbeat_failure ||= Time.now.utc save Clacky::Logger.warn("[Brand] heartbeat! failed — #{response[:error]} (failing_since=#{@license_last_heartbeat_failure.iso8601})") { success: false, message: response[:error] || "Heartbeat failed" } end end |
#heartbeat_due? ⇒ Boolean
Returns true when a heartbeat should be sent (interval elapsed).
144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/clacky/brand_config.rb', line 144 def heartbeat_due? if @license_last_heartbeat.nil? Clacky::Logger.debug("[Brand] heartbeat_due? => true (never sent)") return true end elapsed = Time.now.utc - @license_last_heartbeat due = elapsed >= HEARTBEAT_INTERVAL Clacky::Logger.debug("[Brand] heartbeat_due? elapsed=#{elapsed.to_i}s interval=#{HEARTBEAT_INTERVAL}s => #{due}") due end |
#install_brand_extension!(ext_info) ⇒ Object
Install (or update) a single brand extension by downloading its zip into
the ExtensionLoader installed layer.
ext_info: a hash from fetch_brand_extensions! with at least
name + latest_version.download_url + version.
895 896 897 898 899 900 901 902 903 904 905 906 907 908 |
# File 'lib/clacky/brand_config.rb', line 895 def install_brand_extension!(ext_info) slug = ext_info["name"].to_s.strip version = (ext_info["latest_version"] || {})["version"] || ext_info["version"] url = (ext_info["latest_version"] || {})["download_url"] return { success: false, error: "Missing extension name" } if slug.empty? return { success: false, error: "No download URL" } if url.nil? || url.strip.empty? Clacky::ExtensionPackager.install(url, force: true) record_installed_extension(slug, version) { success: true, name: slug, version: version } rescue StandardError => e { success: false, error: e. } end |
#install_brand_skill!(skill_info, encrypted: true) ⇒ Object
Install (or update) a single brand skill by downloading and extracting its zip. skill_info: a hash from fetch_brand_skills! with at least name + latest_version.download_url + version encrypted: whether the ZIP contains AES-encrypted .enc files + MANIFEST.enc.json (true) or plaintext SKILL.md and supporting files (false, used by free-mode).
1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 |
# File 'lib/clacky/brand_config.rb', line 1118 def install_brand_skill!(skill_info, encrypted: true) require "net/http" require "uri" slug = skill_info["name"].to_s.strip version = (skill_info["latest_version"] || {})["version"] || skill_info["version"] url = (skill_info["latest_version"] || {})["download_url"] return { success: false, error: "Missing skill name" } if slug.empty? if url.nil? FileUtils.mkdir_p(File.join(brand_skills_dir, slug)) return { success: false, error: "No download URL" } end require "zip" # Serialize installs of the same slug so concurrent background syncs (one # per Agent) don't redundantly download the same ZIP or race on the same # destination directory. install_lock = self.class.install_lock_for(slug) install_lock.synchronize do dest_dir = File.join(brand_skills_dir, slug) # Download and extract into a unique staging directory, never touching # dest_dir until everything succeeds. A failed or corrupt download must # never destroy the already-installed version. stage_id = "#{Process.pid}.#{SecureRandom.hex(4)}" stage_dir = File.join(brand_skills_dir, ".staging-#{slug}-#{stage_id}") tmp_zip = File.join(brand_skills_dir, ".#{slug}-#{stage_id}.zip") FileUtils.mkdir_p(stage_dir) begin # Download the zip file to a temp path via PlatformHttpClient so the # primary → fallback host failover applies uniformly to every download. dl = platform_client.download_file(url, tmp_zip) raise dl[:error].to_s unless dl[:success] zip_size = File.size?(tmp_zip).to_i raise "Empty ZIP downloaded for #{slug}" if zip_size < 22 # min valid zip = empty central directory # Extract into stage_dir. # Auto-detect whether the zip has a single root folder to strip. # Uses get_input_stream instead of entry.extract to avoid rubyzip 3.x # path-safety restrictions on absolute destination paths. # Uses chunked read + size verification for robustness. Zip::File.open(tmp_zip) do |zip| entries = zip.entries.reject(&:directory?) top_dirs = entries.map { |e| e.name.split("/").first }.uniq has_root = top_dirs.length == 1 && entries.any? { |e| e.name.include?("/") } entries.each do |entry| rel_path = if has_root parts = entry.name.split("/") parts[1..].join("/") else entry.name end next if rel_path.nil? || rel_path.empty? out = File.join(stage_dir, rel_path) FileUtils.mkdir_p(File.dirname(out)) # Chunked copy with size verification written = 0 File.open(out, "wb") do |f| entry.get_input_stream do |input| while (chunk = input.read(65536)) f.write(chunk) written += chunk.bytesize end end end # Verify file size matches ZIP entry declaration if written != entry.size raise "Size mismatch for #{entry.name}: expected #{entry.size}, got #{written}" end end end # Everything extracted successfully — atomically swap staging into # place. Only now is the previous version removed. FileUtils.rm_rf(dest_dir) FileUtils.mv(stage_dir, dest_dir) record_installed_skill(slug, version, skill_info["description"], encrypted: encrypted, description_zh: skill_info["description_zh"], name_zh: skill_info["name_zh"]) { success: true, name: slug, version: version } rescue StandardError, ScriptError => e # Only clean up our own staging artifacts; the installed version in # dest_dir is left untouched so a failed update never loses a skill. { success: false, error: e. } ensure FileUtils.rm_f(tmp_zip) FileUtils.rm_rf(stage_dir) if Dir.exist?(stage_dir) end end end |
#install_free_skill!(skill_info) ⇒ Object
Install a single free (unencrypted) skill. Thin wrapper around install_brand_skill! that records the skill as encrypted: false so the loader reads SKILL.md directly without attempting decryption.
505 506 507 |
# File 'lib/clacky/brand_config.rb', line 505 def install_free_skill!(skill_info) install_brand_skill!(skill_info, encrypted: false) end |
#install_mock_brand_skill!(skill_info) ⇒ Hash
Install a mock brand skill for brand-test mode.
Writes a realistic (but unencrypted) SKILL.md.enc file to the brand skills directory so the full load → decrypt → invoke code-path can be exercised without a real server. The file format intentionally mirrors what the production server will deliver: a binary blob stored with a .enc extension.
In the current mock implementation the "encryption" is an identity transformation (plain UTF-8 bytes) because BrandConfig#decrypt_skill_content is also mocked. Both sides will be replaced together during backend integration.
1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 |
# File 'lib/clacky/brand_config.rb', line 1237 def install_mock_brand_skill!(skill_info) slug = skill_info["name"].to_s.strip version = (skill_info["latest_version"] || {})["version"] || skill_info["version"] || "1.0.0" name = slug description = skill_info["description"] || "A private brand skill." description_zh = skill_info["description_zh"] || "私有品牌技能。" emoji = skill_info["emoji"] || "⭐" return { success: false, error: "Missing skill name" } if slug.empty? dest_dir = File.join(brand_skills_dir, slug) FileUtils.mkdir_p(dest_dir) # Build a realistic SKILL.md that exercises argument substitution and # the privacy-protection code path. mock_content = <<~SKILL --- name: #{slug} description: "#{description}" --- # #{emoji} #{name} > This is a proprietary brand skill. Its contents are confidential. You are an expert assistant specialising in: **#{name}**. ## Instructions When the user asks you to use this skill, follow these steps: 1. Understand the user's request: $ARGUMENTS 2. Apply your expertise to deliver a high-quality result. 3. Summarise what you did and ask if the user needs adjustments. SKILL # Write as .enc (mock: plain bytes — real encryption added post-backend) enc_path = File.join(dest_dir, "SKILL.md.enc") File.binwrite(enc_path, mock_content.encode("UTF-8")) # encrypted: false — mock skills store plain bytes in .enc, no MANIFEST needed. record_installed_skill(slug, version, description, encrypted: false, description_zh: description_zh, name_zh: skill_info["name_zh"]) { success: true, name: slug, version: version } rescue StandardError => e { success: false, error: e. } end |
#installed_brand_extensions ⇒ Object
Installed brand extensions keyed by ext_id => { "version" => "..." }. Entries whose on-disk container no longer exists are pruned.
950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 |
# File 'lib/clacky/brand_config.rb', line 950 def installed_brand_extensions path = brand_extensions_registry_path return {} unless File.exist?(path) raw = JSON.parse(File.read(path)) valid = {} changed = false raw.each do |name, | if Dir.exist?(File.join(File.dirname(path), name)) valid[name] = else changed = true end end File.write(path, JSON.generate(valid)) if changed valid rescue StandardError {} end |
#installed_brand_skills ⇒ Object
Read the local brand_skills.json metadata, cross-validated against the actual file system. A skill is only considered installed when:
1. It has an entry in brand_skills.json, AND
2. Its skill directory exists under brand_skills_dir, AND
3. That directory contains at least one file (SKILL.md or SKILL.md.enc).
If the JSON record exists but the directory is missing or empty the entry is silently dropped from the result and the JSON file is cleaned up so subsequent installs start from a clean state.
Returns a hash keyed by name: { "version" => "1.0.0", "name" => "..." }
1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 |
# File 'lib/clacky/brand_config.rb', line 1577 def installed_brand_skills path = File.join(brand_skills_dir, "brand_skills.json") return {} unless File.exist?(path) raw = JSON.parse(File.read(path)) # Validate each entry against the actual file system. valid = {} changed = false raw.each do |name, | skill_dir = File.join(brand_skills_dir, name) has_files = Dir.exist?(skill_dir) && Dir.glob(File.join(skill_dir, "SKILL.md{,.enc}")).any? if has_files valid[name] = else # JSON record exists but files are missing — mark for cleanup. changed = true end end # Persist the cleaned-up JSON so stale records don't accumulate. if changed File.write(path, JSON.generate(valid)) end valid rescue StandardError {} end |
#refresh_distribution! ⇒ Object
Refresh public brand assets (logo, theme, homepage_url, support_*) for unactivated installs. Package installs use an exact package lookup; private platform sources without a local brand use the source default.
Motivation: install.sh --brand-name=X --command=X only writes
product_name + package_name to brand.yml. The rest of the distribution
is only delivered via the license activation / heartbeat flow, which
requires a license key. This method closes that gap by calling the
anonymous public lookup endpoint.
Behaviour:
* No-op when already activated, or when neither a package_name nor a
private platform source is available.
* On success: apply_distribution + save + stamp
@distribution_last_refreshed_at.
* When a block is provided, discard the response unless it still
confirms that the platform source is current.
* On failure: log and return without touching the timestamp (so we
retry on next trigger).
Returns { success: Boolean, message: String }.
423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 |
# File 'lib/clacky/brand_config.rb', line 423 def refresh_distribution! source_brand_lookup = private_platform_source? && (@package_name.nil? || @package_name.strip.empty?) unless branded? || source_brand_lookup return { success: false, message: "Not branded" } end if activated? return { success: false, message: "License activated — use heartbeat! instead" } end if !source_brand_lookup && (@package_name.nil? || @package_name.strip.empty?) return { success: false, message: "package_name not configured" } end if source_brand_lookup path = "/api/v1/distributions/lookup" else encoded_pkg = URI.encode_www_form_component(@package_name.strip) path = "/api/v1/distributions/lookup?package_name=#{encoded_pkg}" end Clacky::Logger.info( "[Brand] refresh_distribution! fetching — package_name=#{@package_name || "source-default"}" ) response = platform_client.get(path) if response[:success] && response[:data].is_a?(Hash) && response[:data]["distribution"].is_a?(Hash) if block_given? && !yield Clacky::Logger.info("[Brand] refresh_distribution! discarded — platform source changed") return { success: false, message: "Platform source changed during refresh" } end apply_distribution(response[:data]["distribution"]) @distribution_last_refreshed_at = Time.now.utc save Clacky::Logger.info("[Brand] refresh_distribution! success — product_name=#{@product_name}") { success: true, message: "Distribution refreshed" } else Clacky::Logger.warn("[Brand] refresh_distribution! failed — #{response[:error]}") { success: false, message: response[:error] || "Refresh failed" } end end |
#save ⇒ Object
Save current state to ~/.clacky/brand.yml
Writes atomically: content goes to a per-process temp file that is fsynced and then renamed over the target. rename is atomic within a filesystem, so concurrent processes (server + CLI, or multiple servers) never observe a truncated or half-written file — which previously caused load to fall back to an empty config and wipe the license.
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 |
# File 'lib/clacky/brand_config.rb', line 186 def save FileUtils.mkdir_p(CONFIG_DIR) Clacky::Logger.debug("[Brand] save: product_name=#{@product_name.inspect} license_key?=#{!@license_key.nil? && !@license_key.to_s.empty?} device_id?=#{!@device_id.nil? && !@device_id.to_s.empty?}") tmp_path = "#{BRAND_FILE}.#{Process.pid}.#{SecureRandom.hex(4)}.tmp" begin File.open(tmp_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f| f.write(to_yaml) f.flush f.fsync end File.rename(tmp_path, BRAND_FILE) rescue StandardError File.delete(tmp_path) if File.exist?(tmp_path) raise end end |
#search_extensions!(query: nil, sort: nil) ⇒ Object
Search the public extension marketplace. Anonymous — no license required. Uses GET /api/v1/extensions. Returns { success:, extensions: [], error: }.
781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 |
# File 'lib/clacky/brand_config.rb', line 781 def search_extensions!(query: nil, sort: nil) params = {} params["q"] = query if query && !query.to_s.strip.empty? params["sort"] = sort if sort && !sort.to_s.strip.empty? qs = params.empty? ? "" : "?#{URI.encode_www_form(params)}" response = platform_client.get("/api/v1/extensions#{qs}") if response[:success] { success: true, extensions: response[:data]["extensions"] || [] } else { success: false, error: response[:error] || "Search failed", extensions: [] } end rescue StandardError => e { success: false, error: "Network error: #{e.}", extensions: [] } end |
#sync_brand_extensions_async!(on_complete: nil) ⇒ Thread?
Synchronise brand extensions in the background for activated installs. Mirrors sync_brand_skills_async! but installs into the extension layer. Unlike brand skills, new extensions are auto-installed because a bundled extension is chosen by the brand administrator, not the end user.
916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 |
# File 'lib/clacky/brand_config.rb', line 916 def sync_brand_extensions_async!(on_complete: nil) return nil unless activated? return nil if ENV["CLACKY_TEST"] == "1" Thread.new do Thread.current.abort_on_exception = false begin result = fetch_brand_extensions! next unless result[:success] remote_names = result[:extensions].map { |e| e["name"] } installed_brand_extensions.each_key do |local_name| delete_brand_extension!(local_name) unless remote_names.include?(local_name) end to_install = result[:extensions].select { |e| e["needs_update"] } results = to_install.map { |ext_info| install_brand_extension!(ext_info) } Clacky::ExtensionLoader.invalidate_cache! unless results.empty? on_complete&.call(results) rescue StandardError # Background sync failures are intentionally swallowed. end end end |
#sync_brand_skills_async!(on_complete: nil, install_new: false) ⇒ Thread?
Synchronise brand skills in the background.
Fetches the remote skills list and installs any skill whose remote version differs from the locally installed version. The work runs in a daemon Thread so it never blocks the caller (typically Agent startup).
If the license is not activated the method returns immediately without spawning a thread.
install_new: when true, install ALL remote skills regardless of whether they were previously installed. Used on first activation so every brand skill is available immediately without the user clicking Install one-by-one.
1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 |
# File 'lib/clacky/brand_config.rb', line 1299 def sync_brand_skills_async!(on_complete: nil, install_new: false) return nil unless activated? return nil if ENV["CLACKY_TEST"] == "1" Thread.new do Thread.current.abort_on_exception = false begin result = fetch_brand_skills! next unless result[:success] # Remove locally installed skills that have been deleted on the remote. # Compare the set of remote skill names against what is installed locally # and delete any skill that no longer exists in the remote catalogue. remote_skill_names = result[:skills].map { |s| s["name"] } installed_brand_skills.each_key do |local_name| delete_brand_skill!(local_name) unless remote_skill_names.include?(local_name) end # When install_new is true (e.g. on first activation) install every # remote skill. Otherwise limit auto-sync to skills already installed # locally that have a newer version available — new skills must be # installed explicitly by the user from the Brand Skills panel. installed = installed_brand_skills skills_to_install = if install_new result[:skills].select { |s| s["needs_update"] || !installed.key?(s["name"]) } else result[:skills].select { |s| s["needs_update"] } end results = skills_to_install.map do |skill_info| install_brand_skill!(skill_info) end # Even when the version hasn't changed, display metadata (name_zh, # description_zh, description) may have been updated on the platform. # Patch brand_skills.json in-place without re-downloading the ZIP. result[:skills].each do |skill_info| name = skill_info["name"] next unless installed.key?(name) next if skill_info["needs_update"] # already being reinstalled above local = installed[name] next if local["name_zh"] == skill_info["name_zh"].to_s && local["description_zh"] == skill_info["description_zh"].to_s && local["description"] == skill_info["description"].to_s # Metadata changed — update brand_skills.json without reinstalling. record_installed_skill( name, local["version"], skill_info["description"].to_s, encrypted: local["encrypted"] != false, description_zh: skill_info["description_zh"].to_s, name_zh: skill_info["name_zh"].to_s ) end on_complete&.call(results) rescue StandardError # Background sync failures are intentionally swallowed — the agent # continues to work with whatever skills are already installed. end end end |
#sync_free_skills_async!(on_complete: nil) ⇒ Thread?
Synchronise free skills in the background for unactivated branded installs.
Mirrors sync_brand_skills_async! but uses the public free_skills endpoint so no license is required. Only runs when the install is branded and NOT activated — once a license is activated the regular brand-skill sync takes over (and may include additional encrypted skills).
517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 |
# File 'lib/clacky/brand_config.rb', line 517 def sync_free_skills_async!(on_complete: nil) return nil unless branded? return nil if activated? return nil if ENV["CLACKY_TEST"] == "1" Thread.new do Thread.current.abort_on_exception = false begin result = fetch_free_skills! next unless result[:success] remote_skill_names = result[:skills].map { |s| s["name"] } installed_brand_skills.each_key do |local_name| send(:delete_brand_skill!, local_name) unless remote_skill_names.include?(local_name) end installed = installed_brand_skills to_install = result[:skills].select { |s| installed[s["name"]].nil? || s["needs_update"] } results = to_install.map { |skill_info| install_free_skill!(skill_info) } on_complete&.call(results) rescue StandardError # Background sync failures are intentionally swallowed. end end end |
#to_h ⇒ Object
Returns a hash representation for JSON serialization (e.g. /api/brand).
1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 |
# File 'lib/clacky/brand_config.rb', line 1647 def to_h { product_name: @product_name, package_name: @package_name, logo_url: @logo_url, support_contact: @support_contact, support_qr_url: @support_qr_url, theme_color: @theme_color, homepage_url: @homepage_url, branded: branded?, activated: activated?, expired: expired?, license_expires_at: @license_expires_at&.iso8601, user_licensed: user_licensed?, license_user_id: @license_user_id } end |
#to_yaml ⇒ Object
1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 |
# File 'lib/clacky/brand_config.rb', line 1666 def to_yaml data = {} data["product_name"] = @product_name if @product_name data["package_name"] = @package_name if @package_name data["logo_url"] = @logo_url if @logo_url data["support_contact"] = @support_contact if @support_contact data["support_qr_url"] = @support_qr_url if @support_qr_url data["theme_color"] = @theme_color if @theme_color data["homepage_url"] = @homepage_url if @homepage_url data["license_key"] = @license_key if @license_key data["license_activated_at"] = @license_activated_at.iso8601 if @license_activated_at data["license_expires_at"] = @license_expires_at.iso8601 if @license_expires_at data["license_last_heartbeat"] = @license_last_heartbeat.iso8601 if @license_last_heartbeat data["device_id"] = @device_id if @device_id # Persist user_id so user-licensed features remain available across restarts data["license_user_id"] = @license_user_id if @license_user_id && !@license_user_id.strip.empty? data["distribution_last_refreshed_at"] = @distribution_last_refreshed_at.iso8601 if @distribution_last_refreshed_at data["license_last_heartbeat_failure"] = @license_last_heartbeat_failure.iso8601 if @license_last_heartbeat_failure YAML.dump(data) end |
#update_extension_readme!(ext_id, readme) ⇒ Object
Update an extension's readme text without publishing a new version. Uses PATCH /api/v1/client/extensions/:id. Returns { success:, extension:, error: }
733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 |
# File 'lib/clacky/brand_config.rb', line 733 def update_extension_readme!(ext_id, readme) identity = Clacky::Identity.load return { success: false, error: "Device not bound to a platform account" } unless identity.bound? path = "/api/v1/client/extensions/#{URI.encode_www_form_component(ext_id)}" payload = { "device_token" => identity.device_token, "readme" => readme.to_s } result = platform_client.patch(path, payload) if result[:success] { success: true, extension: result[:data]["extension"] } else body = result[:data] || {} { success: false, error: result[:error] || body["code"] || "Update readme failed" } end rescue StandardError => e { success: false, error: "Network error: #{e.}" } end |
#upload_extension!(ext_id, zip_data, force: false, status: nil, changelog: nil, readme: nil, origin: 'marketplace') ⇒ Object
Upload (publish) a packed extension ZIP to the platform.
ext_id: extension slug (matches ext.yml id)
zip_data: binary ZIP content produced by clacky ext pack
force: when true, PATCH an existing extension (new version) instead of POST
Returns { success: true, extension: ... } or { success: false, error: "...", already_exists: Boolean }.
651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 |
# File 'lib/clacky/brand_config.rb', line 651 def upload_extension!(ext_id, zip_data, force: false, status: nil, changelog: nil, readme: nil, origin: 'marketplace') identity = Clacky::Identity.load return { success: false, error: "Device not bound to a platform account" } unless identity.bound? path = if force "/api/v1/client/extensions/#{URI.encode_www_form_component(ext_id)}" else "/api/v1/client/extensions" end fields = { "device_token" => identity.device_token } fields["origin"] = origin.to_s if origin fields["status"] = status.to_s if status fields["changelog"] = changelog.to_s if changelog fields["readme"] = readme.to_s if readme body_bytes, boundary = build_multipart(fields, "extension_zip", "#{ext_id}.zip", zip_data) result = if force platform_client.multipart_patch(path, body_bytes, boundary, read_timeout: 60) else platform_client.multipart_post(path, body_bytes, boundary, read_timeout: 60) end if result[:success] { success: true, extension: result[:data]["extension"] } else body = result[:data] || {} code = body["code"] || body["error"] errors = body["errors"]&.join(", ") msg = result[:error] || [code, errors].compact.join(": ") msg = "Publish failed" if msg.to_s.strip.empty? already_exists = body["code"].to_s.include?("taken") || body["code"].to_s.include?("already") || result[:error].to_s.include?("HTTP 409") { success: false, error: msg, already_exists: already_exists } end rescue StandardError => e { success: false, error: "Network error: #{e.}" } end |
#upload_file!(data, filename:, content_type: "image/png") ⇒ Object
Upload any file to the platform's model-agnostic upload endpoint. Returns an orphan blob URL — no extension record required. Uses POST /api/v1/client/uploads. Parameters:
data (bytes) - raw file bytes
filename (string) - filename including extension
content_type (string) - MIME type
Returns { success:, url:, filename:, blob_key:, error: }
759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 |
# File 'lib/clacky/brand_config.rb', line 759 def upload_file!(data, filename:, content_type: "image/png") identity = Clacky::Identity.load return { success: false, error: "Device not bound to a platform account" } unless identity.bound? path = "/api/v1/client/uploads" fields = { "device_token" => identity.device_token } body_bytes, boundary = build_multipart(fields, "file", filename, data, content_type: content_type) result = platform_client.multipart_post(path, body_bytes, boundary, read_timeout: 60) if result[:success] d = result[:data] || {} { success: true, url: d["url"], filename: d["filename"], blob_key: d["blob_key"] } else body = result[:data] || {} { success: false, error: result[:error] || body["code"] || "File upload failed" } end rescue StandardError => e { success: false, error: "Network error: #{e.}" } end |
#upload_skill!(skill_name, zip_data, force: false, version_override: nil) ⇒ Object
Upload (publish) a custom skill ZIP to the OpenClacky Cloud API. Calls POST /api/v1/client/skills (system-license endpoint). zip_data is the raw binary content of the ZIP file. Returns { success: bool, error: String }. Upload a skill ZIP to the OpenClacky cloud. skill_name: skill name string (slug format) zip_data: binary ZIP content force: when true, use PATCH to overwrite an existing skill instead of POST
Returns { success: true, skill: ... } or { success: false, error: "...", already_exists: true/false }
555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 |
# File 'lib/clacky/brand_config.rb', line 555 def upload_skill!(skill_name, zip_data, force: false, version_override: nil) return { success: false, error: "License not activated" } unless activated? return { success: false, error: "User license required to upload skills" } unless user_licensed? # The client skills API uses @license_user_id (the platform owner user id), # NOT the user_id embedded in the license key structure. user_id = @license_user_id.to_s key_hash = Digest::SHA256.hexdigest(@license_key) ts = Time.now.utc.to_i.to_s nonce = SecureRandom.hex(16) = "#{user_id}:#{@device_id}:#{ts}:#{nonce}" signature = OpenSSL::HMAC.hexdigest("SHA256", @license_key, ) # POST /api/v1/client/skills → create (first upload) # PATCH /api/v1/client/skills/:name → update (force overwrite) path = if force "/api/v1/client/skills/#{URI.encode_www_form_component(skill_name)}" else "/api/v1/client/skills" end boundary = "----ClackySkillUpload#{SecureRandom.hex(8)}" crlf = "\r\n" # Build multipart body as a binary string so that null bytes in the ZIP # data are preserved. All parts are joined as binary before sending. parts = [] fields = { "key_hash" => key_hash, "user_id" => user_id, "device_id" => @device_id, "timestamp" => ts, "nonce" => nonce, "signature" => signature, "name" => skill_name.to_s } # Include version override when bumping an existing skill version fields["version"] = version_override.to_s if version_override fields.each do |field, value| parts << "--#{boundary}#{crlf}" parts << "Content-Disposition: form-data; name=\"#{field}\"#{crlf}#{crlf}" parts << value.to_s parts << crlf end # Binary file part parts << "--#{boundary}#{crlf}" parts << "Content-Disposition: form-data; name=\"skill_zip\"; filename=\"#{skill_name}.zip\"#{crlf}" parts << "Content-Type: application/zip#{crlf}#{crlf}" parts << zip_data.b parts << "#{crlf}--#{boundary}--#{crlf}" body_bytes = parts.map(&:b).join # Delegate sending (with retry + failover) to PlatformHttpClient. # Uploads can be slow so we allow a generous 60-second read timeout. result = if force platform_client.multipart_patch(path, body_bytes, boundary, read_timeout: 60) else platform_client.multipart_post(path, body_bytes, boundary, read_timeout: 60) end if result[:success] parsed = result[:data] { success: true, skill: parsed["skill"] } else # Propagate structured error from PlatformHttpClient body = result[:data] || {} code = body["code"] || body["error"] errors = body["errors"]&.join(", ") msg = result[:error] || [code, errors].compact.join(": ") msg = "Upload failed" if msg.to_s.strip.empty? # Detect "already exists" conflicts so the caller can offer an overwrite option. already_exists = body["code"].to_s.include?("name_taken") || body["code"].to_s.include?("already") || result[:error].to_s.include?("HTTP 409") { success: false, error: msg, already_exists: already_exists } end rescue StandardError => e { success: false, error: "Network error: #{e.}" } end |
#user_licensed? ⇒ Boolean
Returns true when the license is bound to a specific user (user_id present). User-licensed installations gain additional capabilities such as the ability to upload custom skills via the web UI.
175 176 177 |
# File 'lib/clacky/brand_config.rb', line 175 def user_licensed? activated? && !@license_user_id.nil? && !@license_user_id.to_s.strip.empty? end |