rosett-ai 1.3.3

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 (527) hide show
  1. checksums.yaml +7 -0
  2. data/.ai-provenance.yml +119 -0
  3. data/.debride_whitelist +186 -0
  4. data/.fasterer.yml +29 -0
  5. data/.mdl_style.rb +10 -0
  6. data/.mdlrc +3 -0
  7. data/.mutant.yml +49 -0
  8. data/.namespace-allowlist +42 -0
  9. data/.reek.yml +1040 -0
  10. data/.rosett-ai/config.yml +3 -0
  11. data/.rspec +5 -0
  12. data/.rubocop.yml +380 -0
  13. data/.ruby-version +1 -0
  14. data/.yamllint +51 -0
  15. data/.yardopts +12 -0
  16. data/AI-DISCLOSURE.md +48 -0
  17. data/CHANGELOG.md +519 -0
  18. data/CLAUDE.md +141 -0
  19. data/CONTRIBUTING.md +734 -0
  20. data/INSTALL.md +154 -0
  21. data/LICENSE +674 -0
  22. data/LICENSE.md +675 -0
  23. data/QUICKSTART.md +73 -0
  24. data/README.md +366 -0
  25. data/Rakefile +200 -0
  26. data/SECURITY.md +114 -0
  27. data/bin/rai +1 -0
  28. data/cliff.toml +52 -0
  29. data/conf/adopt_redactions.yml +8 -0
  30. data/conf/behaviour/.gitkeep +0 -0
  31. data/conf/compliance/cra_rules.yml +25 -0
  32. data/conf/compliance/license_rules.yml +20 -0
  33. data/conf/design/aaif_alignment.yml +181 -0
  34. data/conf/design/ab_testing.yml +172 -0
  35. data/conf/design/accessibility.yml +84 -0
  36. data/conf/design/ai_authorship.yml +210 -0
  37. data/conf/design/ai_provenance.yml +224 -0
  38. data/conf/design/ai_tool_configuration.yml +207 -0
  39. data/conf/design/architecture.yml +139 -0
  40. data/conf/design/autocompletion.yml +115 -0
  41. data/conf/design/backward_compatibility.yml +112 -0
  42. data/conf/design/behaviour_composition.yml +246 -0
  43. data/conf/design/build_rake_extraction.yml +57 -0
  44. data/conf/design/ci_pipeline.yml +100 -0
  45. data/conf/design/claude_code_configuration.yml +157 -0
  46. data/conf/design/compiler.yml +128 -0
  47. data/conf/design/comply.yml +153 -0
  48. data/conf/design/content_packs.yml +84 -0
  49. data/conf/design/desktop_integration.yml +289 -0
  50. data/conf/design/distribution.yml +216 -0
  51. data/conf/design/doctor.yml +184 -0
  52. data/conf/design/documentation.yml +152 -0
  53. data/conf/design/engine_architecture.yml +257 -0
  54. data/conf/design/error_handling.yml +103 -0
  55. data/conf/design/feature_flags.yml +142 -0
  56. data/conf/design/git_hooks.yml +165 -0
  57. data/conf/design/gui_plugins.yml +475 -0
  58. data/conf/design/i18n.yml +84 -0
  59. data/conf/design/integration_testing.yml +56 -0
  60. data/conf/design/licensing_system.yml +88 -0
  61. data/conf/design/lifecycle_management.yml +208 -0
  62. data/conf/design/mcp_integration.yml +207 -0
  63. data/conf/design/mcp_settings.yml +126 -0
  64. data/conf/design/migration.yml +56 -0
  65. data/conf/design/monitoring_observability.yml +194 -0
  66. data/conf/design/namespace_cleanup.yml +145 -0
  67. data/conf/design/plugin_test_segregation.yml +145 -0
  68. data/conf/design/policy_management.yml +229 -0
  69. data/conf/design/project_management.yml +183 -0
  70. data/conf/design/rai_mcp_asset_discovery.yml +164 -0
  71. data/conf/design/rai_mcp_server.yml +605 -0
  72. data/conf/design/release_management.yml +117 -0
  73. data/conf/design/retrofit.yml +199 -0
  74. data/conf/design/retrospective_analyzer.yml +79 -0
  75. data/conf/design/scope_hierarchy.yml +352 -0
  76. data/conf/design/security.yml +115 -0
  77. data/conf/design/session_retrospective.yml +85 -0
  78. data/conf/design/smart_ui_feedback.yml +89 -0
  79. data/conf/design/structured_logging.yml +148 -0
  80. data/conf/design/styles.yml +123 -0
  81. data/conf/design/test_peer_review.yml +89 -0
  82. data/conf/design/testing.yml +136 -0
  83. data/conf/design/threat_model.yml +108 -0
  84. data/conf/design/ui_framework.yml +111 -0
  85. data/conf/design/usage_optimization.yml +122 -0
  86. data/conf/design/version_management.yml +60 -0
  87. data/conf/design/workflow.yml +227 -0
  88. data/conf/mcp/server_defaults.yml +42 -0
  89. data/conf/mcp/trust.yml +21 -0
  90. data/conf/packaging/core.yml +12 -0
  91. data/conf/packaging/gtk4.yml +11 -0
  92. data/conf/packaging/qt6.yml +11 -0
  93. data/conf/policy/default_deny_list.yml +197 -0
  94. data/conf/review/cli-command-audit.yml +857 -0
  95. data/conf/review/design-docs.yml +1064 -0
  96. data/conf/review/design-questionnaire.yml +153 -0
  97. data/conf/review/questionnaire.yml +146 -0
  98. data/conf/review/rosett-ai-core.yml +2919 -0
  99. data/conf/schemas/ai_config_schema.json +73 -0
  100. data/conf/schemas/behaviour_schema.json +132 -0
  101. data/conf/schemas/compliance_rule_schema.json +63 -0
  102. data/conf/schemas/content_pack_manifest_schema.json +51 -0
  103. data/conf/schemas/design_schema.json +210 -0
  104. data/conf/schemas/engine_manifest_schema.json +144 -0
  105. data/conf/schemas/lockfile_schema.json +74 -0
  106. data/conf/schemas/mcp_server_schema.json +48 -0
  107. data/conf/schemas/packaging_schema.json +70 -0
  108. data/conf/schemas/policy_schema.json +85 -0
  109. data/conf/schemas/provenance_schema.json +84 -0
  110. data/conf/schemas/rai_config_schema.json +56 -0
  111. data/conf/schemas/rai_project_schema.json +20 -0
  112. data/conf/schemas/scope_hierarchy_schema.json +49 -0
  113. data/conf/schemas/target_schema.json +67 -0
  114. data/conf/schemas/tooling_schema.json +65 -0
  115. data/conf/schemas/workflow_schema.json +112 -0
  116. data/conf/targets/agents_md.yml +17 -0
  117. data/conf/targets/claude.yml +12 -0
  118. data/conf/tooling/tools.yml +58 -0
  119. data/dist/rosett-ai-mcp.service +48 -0
  120. data/dist/rosett-ai-mcp.yml.default +45 -0
  121. data/doc/AAIF_POSITIONING.md +58 -0
  122. data/doc/ADOPT.md +224 -0
  123. data/doc/AI_PROVENANCE.md +139 -0
  124. data/doc/ARCHITECTURE.md +920 -0
  125. data/doc/BEHAVIOUR.md +409 -0
  126. data/doc/BUILD.md +138 -0
  127. data/doc/CI_CD_RECIPES.md +171 -0
  128. data/doc/CLAUDE_SESSIONS_MOVED.md +16 -0
  129. data/doc/COMMAND_ANALYSIS.md +229 -0
  130. data/doc/CONFIGURATION.md +281 -0
  131. data/doc/DESIGN_AUDIT.md +235 -0
  132. data/doc/DESIGN_PEER_REVIEW.md +771 -0
  133. data/doc/DESKTOP.md +447 -0
  134. data/doc/ENGINES.md +567 -0
  135. data/doc/ENGINE_DEVELOPMENT_GUIDE.md +417 -0
  136. data/doc/FEATURE_AUDIT.md +218 -0
  137. data/doc/IMPLEMENTATION_PLAN.md +669 -0
  138. data/doc/INCIDENT_REPORT_2026-02-02.md +251 -0
  139. data/doc/MIGRATION_GUIDE.md +88 -0
  140. data/doc/PACKAGING.md +232 -0
  141. data/doc/PROJECT_DASHBOARD.md +153 -0
  142. data/doc/PULP_DEPLOYMENT.md +164 -0
  143. data/doc/QUALITY_FIX_SUMMARY.md +110 -0
  144. data/doc/QUICK_START.md +162 -0
  145. data/doc/REEK_CONFIGURATION.md +166 -0
  146. data/doc/REFERENCE.md +253 -0
  147. data/doc/REFERENCES.md +324 -0
  148. data/doc/SECURITY_REVIEW_CHECKLIST.md +72 -0
  149. data/doc/SESSION_2026-02-28_GTK4_HARDENING.md +359 -0
  150. data/doc/SETUP.md +202 -0
  151. data/doc/TEST_PEER_REVIEW.md +152 -0
  152. data/doc/THREAT_MODEL.md +230 -0
  153. data/doc/USAGE.md +545 -0
  154. data/doc/USER_MANUAL.md +585 -0
  155. data/doc/ai_test_review_checklist.md +110 -0
  156. data/doc/changes/2026-02-18-packaging-fpm.md +155 -0
  157. data/doc/changes/2026-02-19-testing-infrastructure.md +221 -0
  158. data/doc/changes/2026-02-20-security-implementation.md +281 -0
  159. data/doc/changes/2026-02-20-styles-implementation.md +220 -0
  160. data/doc/changes/2026-02-21-architecture-completion.md +95 -0
  161. data/doc/changes/2026-02-21-architecture-ui-layer.md +253 -0
  162. data/doc/changes/2026-02-21-cc-config-implementation.md +108 -0
  163. data/doc/changes/2026-02-21-ci-pipeline-implementation.md +214 -0
  164. data/doc/changes/2026-02-21-compiler-multi-target-pipeline.md +241 -0
  165. data/doc/changes/2026-02-21-config-design-show-commands.md +61 -0
  166. data/doc/changes/2026-02-21-design-implementation-overview.md +455 -0
  167. data/doc/changes/2026-02-21-lifecycle-management.md +196 -0
  168. data/doc/changes/2026-02-21-path-resolver.md +128 -0
  169. data/doc/changes/2026-02-24-ci-tmpdir-mutant-fetch.md +45 -0
  170. data/doc/changes/2026-03-01-ci-bundler-strategy.md +120 -0
  171. data/doc/changes/2026-03-20-security-hardening-phase2.md +163 -0
  172. data/doc/context/SESSION-HANDOFF.md +69 -0
  173. data/doc/context/ai-engine-usage-trends-2026.md +80 -0
  174. data/doc/context/plan-pluggable-engines.md +590 -0
  175. data/doc/decisions/001-flog-deferred.md +32 -0
  176. data/doc/decisions/002-path-resolution-strategy.md +158 -0
  177. data/doc/decisions/003-ui-adapter-selection.md +193 -0
  178. data/doc/decisions/004-design-document-validation.md +179 -0
  179. data/doc/decisions/005-package-splitting-strategy.md +200 -0
  180. data/doc/decisions/006-multi-engine-architecture.md +147 -0
  181. data/doc/decisions/007-engine-agnostic-pivot.md +219 -0
  182. data/doc/decisions/008-ci-bundler-strategy.md +129 -0
  183. data/doc/decisions/009-core-only-v1-release.md +60 -0
  184. data/doc/decisions/010-engine-debian-packaging.md +66 -0
  185. data/doc/decisions/011-context-aware-cli.md +71 -0
  186. data/doc/dependency_decisions.yml +247 -0
  187. data/doc/issues/001-wrapper-missing-environment-variables.md +197 -0
  188. data/doc/issues/002-embedded-ruby-wrong-prefix.md +217 -0
  189. data/doc/issues/003-smoke-test-false-positive.md +127 -0
  190. data/doc/issues/004-market-research-design-updates.md +109 -0
  191. data/doc/issues/005-compile-scope-coexistence.md +161 -0
  192. data/doc/locales/.gitkeep +0 -0
  193. data/doc/man/rai.1.ronn +505 -0
  194. data/doc/operations/packaging.md +133 -0
  195. data/doc/operations/rosett-ai-release.md +65 -0
  196. data/doc/reference/error-catalog.md +107 -0
  197. data/doc/reference/rosett-ai-technical-reference.pdf +0 -0
  198. data/doc/reference/src/Pictures/cover.jpg +0 -0
  199. data/doc/reference/src/Pictures/head1.jpg +0 -0
  200. data/doc/reference/src/Pictures/head2.jpg +0 -0
  201. data/doc/reference/src/Pictures/head3.jpg +0 -0
  202. data/doc/reference/src/Pictures/head4.jpg +0 -0
  203. data/doc/reference/src/Pictures/head5.jpg +0 -0
  204. data/doc/reference/src/Pictures/head6.jpg +0 -0
  205. data/doc/reference/src/Pictures/head7.jpg +0 -0
  206. data/doc/reference/src/Pictures/head8.jpg +0 -0
  207. data/doc/reference/src/StyleInd.ist +4 -0
  208. data/doc/reference/src/bibliography.bib +79 -0
  209. data/doc/reference/src/main.tex +1288 -0
  210. data/doc/reference/src/structure.tex +303 -0
  211. data/doc/rosett-ai-bookmarks.html +301 -0
  212. data/kitchen.yml +46 -0
  213. data/lib/rosett_ai/adopter/executor_resolver.rb +77 -0
  214. data/lib/rosett_ai/adopter/local_analysis_collector.rb +154 -0
  215. data/lib/rosett_ai/adopter/rule_adopter.rb +254 -0
  216. data/lib/rosett_ai/ai_config/config_compiler.rb +111 -0
  217. data/lib/rosett_ai/ai_config/context_window.rb +55 -0
  218. data/lib/rosett_ai/ai_config/cost_controls.rb +44 -0
  219. data/lib/rosett_ai/ai_config/fallback_chain.rb +64 -0
  220. data/lib/rosett_ai/ai_config/model_router.rb +121 -0
  221. data/lib/rosett_ai/ai_config/validator.rb +45 -0
  222. data/lib/rosett_ai/authorship/attribution_compiler.rb +99 -0
  223. data/lib/rosett_ai/authorship/disclosure_policy.rb +81 -0
  224. data/lib/rosett_ai/authorship/review_validator.rb +39 -0
  225. data/lib/rosett_ai/authorship/trailer_generator.rb +88 -0
  226. data/lib/rosett_ai/backup/compressor.rb +180 -0
  227. data/lib/rosett_ai/backup/destination.rb +91 -0
  228. data/lib/rosett_ai/behaviour/manager.rb +156 -0
  229. data/lib/rosett_ai/compiler/backend.rb +86 -0
  230. data/lib/rosett_ai/compiler/backends/agents_md_backend.rb +80 -0
  231. data/lib/rosett_ai/compiler/backends/claude_backend.rb +88 -0
  232. data/lib/rosett_ai/compiler/backends/generic_backend.rb +15 -0
  233. data/lib/rosett_ai/compiler/behaviour_compiler.rb +40 -0
  234. data/lib/rosett_ai/compiler/capability_checker.rb +104 -0
  235. data/lib/rosett_ai/compiler/compilation_pipeline.rb +361 -0
  236. data/lib/rosett_ai/compiler/compiled_output.rb +39 -0
  237. data/lib/rosett_ai/compiler/locale_compiler.rb +250 -0
  238. data/lib/rosett_ai/compiler/target_profile.rb +112 -0
  239. data/lib/rosett_ai/completion/generator.rb +101 -0
  240. data/lib/rosett_ai/completion/shells/bash_generator.rb +126 -0
  241. data/lib/rosett_ai/completion/shells/fish_generator.rb +78 -0
  242. data/lib/rosett_ai/completion/shells/zsh_generator.rb +126 -0
  243. data/lib/rosett_ai/comply/checkers/cra_checker.rb +102 -0
  244. data/lib/rosett_ai/comply/checkers/license_checker.rb +85 -0
  245. data/lib/rosett_ai/comply/checkers/spdx_header_checker.rb +98 -0
  246. data/lib/rosett_ai/comply/reporter.rb +113 -0
  247. data/lib/rosett_ai/comply/runner.rb +50 -0
  248. data/lib/rosett_ai/composition/circular_dependency_detector.rb +56 -0
  249. data/lib/rosett_ai/composition/composer.rb +158 -0
  250. data/lib/rosett_ai/composition/composition_result.rb +64 -0
  251. data/lib/rosett_ai/composition/conflict_detector.rb +53 -0
  252. data/lib/rosett_ai/composition/lockfile.rb +103 -0
  253. data/lib/rosett_ai/composition/merge_strategy.rb +131 -0
  254. data/lib/rosett_ai/composition/priority_sorter.rb +29 -0
  255. data/lib/rosett_ai/composition/scope_resolver.rb +55 -0
  256. data/lib/rosett_ai/config/compile_result.rb +37 -0
  257. data/lib/rosett_ai/config/compiler.rb +13 -0
  258. data/lib/rosett_ai/config/domain_transformer.rb +13 -0
  259. data/lib/rosett_ai/config/key_map.rb +13 -0
  260. data/lib/rosett_ai/config/masking_secret_resolver.rb +40 -0
  261. data/lib/rosett_ai/config/scope_router.rb +13 -0
  262. data/lib/rosett_ai/config/secret_resolver.rb +125 -0
  263. data/lib/rosett_ai/configuration.rb +119 -0
  264. data/lib/rosett_ai/content/content_client.rb +60 -0
  265. data/lib/rosett_ai/content/pack_installer.rb +117 -0
  266. data/lib/rosett_ai/content/pack_manifest.rb +50 -0
  267. data/lib/rosett_ai/content/pack_registry.rb +68 -0
  268. data/lib/rosett_ai/content_packs/manager.rb +50 -0
  269. data/lib/rosett_ai/dbus/compositor_detector.rb +77 -0
  270. data/lib/rosett_ai/dbus/focus_adapters/base.rb +59 -0
  271. data/lib/rosett_ai/dbus/focus_adapters/gnome_adapter.rb +172 -0
  272. data/lib/rosett_ai/dbus/focus_adapters/hyprland_adapter.rb +77 -0
  273. data/lib/rosett_ai/dbus/focus_adapters/i3_adapter.rb +65 -0
  274. data/lib/rosett_ai/dbus/focus_adapters/kwin_adapter.rb +103 -0
  275. data/lib/rosett_ai/dbus/focus_adapters/x11_adapter.rb +105 -0
  276. data/lib/rosett_ai/dbus/focus_monitor_interface.rb +103 -0
  277. data/lib/rosett_ai/dbus/manager_interface.rb +213 -0
  278. data/lib/rosett_ai/dbus/plugin_manager_interface.rb +169 -0
  279. data/lib/rosett_ai/dbus/rate_limiter.rb +89 -0
  280. data/lib/rosett_ai/dbus/service.rb +121 -0
  281. data/lib/rosett_ai/dbus/status_notifier_interface.rb +79 -0
  282. data/lib/rosett_ai/deprecation.rb +79 -0
  283. data/lib/rosett_ai/desktop/dbus_client.rb +259 -0
  284. data/lib/rosett_ai/desktop/gtk4_app.rb +371 -0
  285. data/lib/rosett_ai/desktop/gtk4_preferences.rb +331 -0
  286. data/lib/rosett_ai/desktop/gui_logger.rb +236 -0
  287. data/lib/rosett_ai/doctor/check.rb +92 -0
  288. data/lib/rosett_ai/doctor/checks/cache_health_check.rb +50 -0
  289. data/lib/rosett_ai/doctor/checks/dbus_availability_check.rb +39 -0
  290. data/lib/rosett_ai/doctor/checks/engine_detection_check.rb +46 -0
  291. data/lib/rosett_ai/doctor/checks/file_permission_check.rb +44 -0
  292. data/lib/rosett_ai/doctor/checks/gem_dependency_check.rb +55 -0
  293. data/lib/rosett_ai/doctor/checks/ruby_version_check.rb +50 -0
  294. data/lib/rosett_ai/doctor/checks/stale_config_nncc_check.rb +57 -0
  295. data/lib/rosett_ai/doctor/checks/stale_home_nncc_check.rb +59 -0
  296. data/lib/rosett_ai/doctor.rb +81 -0
  297. data/lib/rosett_ai/documentation/reference_compiler.rb +122 -0
  298. data/lib/rosett_ai/documentation/translator.rb +62 -0
  299. data/lib/rosett_ai/engines/base_config_compiler.rb +203 -0
  300. data/lib/rosett_ai/engines/detector.rb +63 -0
  301. data/lib/rosett_ai/engines/registry.rb +50 -0
  302. data/lib/rosett_ai/error_handler.rb +139 -0
  303. data/lib/rosett_ai/exit_codes.rb +76 -0
  304. data/lib/rosett_ai/feature_flags.rb +102 -0
  305. data/lib/rosett_ai/formatting.rb +33 -0
  306. data/lib/rosett_ai/gem_consistency_checker.rb +199 -0
  307. data/lib/rosett_ai/git_hooks/chain_detector.rb +86 -0
  308. data/lib/rosett_ai/git_hooks/installer.rb +175 -0
  309. data/lib/rosett_ai/git_hooks/script_generator.rb +125 -0
  310. data/lib/rosett_ai/gitlab/validators/supplementary_gitlab_ci_yaml_validator.rb +79 -0
  311. data/lib/rosett_ai/i18n/locale_resolver.rb +46 -0
  312. data/lib/rosett_ai/i18n/utf8_checker.rb +32 -0
  313. data/lib/rosett_ai/init/config_file_writer.rb +24 -0
  314. data/lib/rosett_ai/init/directory_builder.rb +38 -0
  315. data/lib/rosett_ai/init/file_copier.rb +95 -0
  316. data/lib/rosett_ai/init/global_initializer.rb +28 -0
  317. data/lib/rosett_ai/init/local_initializer.rb +27 -0
  318. data/lib/rosett_ai/init/mcp_registrar.rb +109 -0
  319. data/lib/rosett_ai/init/project_initializer.rb +38 -0
  320. data/lib/rosett_ai/licensing/license_key.rb +139 -0
  321. data/lib/rosett_ai/licensing/license_store.rb +64 -0
  322. data/lib/rosett_ai/licensing/license_validator.rb +60 -0
  323. data/lib/rosett_ai/licensing/tier.rb +42 -0
  324. data/lib/rosett_ai/mcp/admin/auditor.rb +88 -0
  325. data/lib/rosett_ai/mcp/admin/health_checker.rb +81 -0
  326. data/lib/rosett_ai/mcp/admin/registry.rb +100 -0
  327. data/lib/rosett_ai/mcp/admin/schema_validator.rb +63 -0
  328. data/lib/rosett_ai/mcp/enforcement/.gitkeep +0 -0
  329. data/lib/rosett_ai/mcp/enforcement/hook_generator.rb +197 -0
  330. data/lib/rosett_ai/mcp/enforcement/validator.rb +215 -0
  331. data/lib/rosett_ai/mcp/governance.rb +160 -0
  332. data/lib/rosett_ai/mcp/http_security_config.rb +158 -0
  333. data/lib/rosett_ai/mcp/instructions.rb +266 -0
  334. data/lib/rosett_ai/mcp/key_hasher.rb +66 -0
  335. data/lib/rosett_ai/mcp/keyfile.rb +221 -0
  336. data/lib/rosett_ai/mcp/middleware/authentication.rb +146 -0
  337. data/lib/rosett_ai/mcp/middleware/content_type.rb +56 -0
  338. data/lib/rosett_ai/mcp/middleware/cors.rb +83 -0
  339. data/lib/rosett_ai/mcp/middleware/origin_validation.rb +73 -0
  340. data/lib/rosett_ai/mcp/middleware/rate_limit.rb +106 -0
  341. data/lib/rosett_ai/mcp/middleware/request_size.rb +51 -0
  342. data/lib/rosett_ai/mcp/plugins.rb +143 -0
  343. data/lib/rosett_ai/mcp/prompts/compilation_prompt.rb +40 -0
  344. data/lib/rosett_ai/mcp/prompts/compliance_prompt.rb +41 -0
  345. data/lib/rosett_ai/mcp/prompts/diagnostics_prompt.rb +41 -0
  346. data/lib/rosett_ai/mcp/prompts/validation_prompt.rb +41 -0
  347. data/lib/rosett_ai/mcp/resources/behaviour_resource.rb +127 -0
  348. data/lib/rosett_ai/mcp/resources/config_resource.rb +72 -0
  349. data/lib/rosett_ai/mcp/resources/design_resource.rb +58 -0
  350. data/lib/rosett_ai/mcp/resources/hooks_resource.rb +74 -0
  351. data/lib/rosett_ai/mcp/resources/provenance_resource.rb +51 -0
  352. data/lib/rosett_ai/mcp/resources/rules_resource.rb +60 -0
  353. data/lib/rosett_ai/mcp/resources/schema_resource.rb +72 -0
  354. data/lib/rosett_ai/mcp/response_helper.rb +46 -0
  355. data/lib/rosett_ai/mcp/security_logger.rb +60 -0
  356. data/lib/rosett_ai/mcp/server.rb +212 -0
  357. data/lib/rosett_ai/mcp/settings/server_installer.rb +112 -0
  358. data/lib/rosett_ai/mcp/settings/trust_manager.rb +142 -0
  359. data/lib/rosett_ai/mcp/tools/adopt_tool.rb +70 -0
  360. data/lib/rosett_ai/mcp/tools/backup_tool.rb +64 -0
  361. data/lib/rosett_ai/mcp/tools/behaviour_display_tool.rb +72 -0
  362. data/lib/rosett_ai/mcp/tools/behaviour_list_tool.rb +56 -0
  363. data/lib/rosett_ai/mcp/tools/behaviour_manage_tool.rb +114 -0
  364. data/lib/rosett_ai/mcp/tools/behaviour_show_tool.rb +62 -0
  365. data/lib/rosett_ai/mcp/tools/compile_status_tool.rb +122 -0
  366. data/lib/rosett_ai/mcp/tools/compile_tool.rb +191 -0
  367. data/lib/rosett_ai/mcp/tools/comply_tool.rb +79 -0
  368. data/lib/rosett_ai/mcp/tools/config_compile_tool.rb +71 -0
  369. data/lib/rosett_ai/mcp/tools/config_status_tool.rb +79 -0
  370. data/lib/rosett_ai/mcp/tools/content_tool.rb +78 -0
  371. data/lib/rosett_ai/mcp/tools/context_query_tool.rb +156 -0
  372. data/lib/rosett_ai/mcp/tools/design_list_tool.rb +57 -0
  373. data/lib/rosett_ai/mcp/tools/design_show_tool.rb +69 -0
  374. data/lib/rosett_ai/mcp/tools/doctor_tool.rb +62 -0
  375. data/lib/rosett_ai/mcp/tools/documentation_status_tool.rb +45 -0
  376. data/lib/rosett_ai/mcp/tools/engines_tool.rb +84 -0
  377. data/lib/rosett_ai/mcp/tools/hook_install_tool.rb +190 -0
  378. data/lib/rosett_ai/mcp/tools/hook_preview_tool.rb +173 -0
  379. data/lib/rosett_ai/mcp/tools/hooks_status_tool.rb +84 -0
  380. data/lib/rosett_ai/mcp/tools/init_tool.rb +87 -0
  381. data/lib/rosett_ai/mcp/tools/license_status_tool.rb +44 -0
  382. data/lib/rosett_ai/mcp/tools/project_tool.rb +117 -0
  383. data/lib/rosett_ai/mcp/tools/provenance_tool.rb +97 -0
  384. data/lib/rosett_ai/mcp/tools/provenance_write_tool.rb +40 -0
  385. data/lib/rosett_ai/mcp/tools/retrofit_tool.rb +81 -0
  386. data/lib/rosett_ai/mcp/tools/rule_search_tool.rb +163 -0
  387. data/lib/rosett_ai/mcp/tools/schema_get_tool.rb +94 -0
  388. data/lib/rosett_ai/mcp/tools/tooling_tool.rb +86 -0
  389. data/lib/rosett_ai/mcp/tools/validate_tool.rb +105 -0
  390. data/lib/rosett_ai/mcp/tools/workflow_execute_tool.rb +74 -0
  391. data/lib/rosett_ai/mcp/tools/workflow_tool.rb +78 -0
  392. data/lib/rosett_ai/migration/detector.rb +117 -0
  393. data/lib/rosett_ai/migration/nncc_config_migrator.rb +94 -0
  394. data/lib/rosett_ai/migration/nncc_project_migrator.rb +90 -0
  395. data/lib/rosett_ai/migration/xdg_migrator.rb +123 -0
  396. data/lib/rosett_ai/package_manager/apt.rb +108 -0
  397. data/lib/rosett_ai/package_manager/base.rb +68 -0
  398. data/lib/rosett_ai/package_manager/gem_backend.rb +90 -0
  399. data/lib/rosett_ai/packaging/variant_config.rb +92 -0
  400. data/lib/rosett_ai/path_resolver.rb +115 -0
  401. data/lib/rosett_ai/plugins/contract.rb +43 -0
  402. data/lib/rosett_ai/plugins/engine_contract.rb +60 -0
  403. data/lib/rosett_ai/plugins/gui_contract.rb +74 -0
  404. data/lib/rosett_ai/plugins/mcp_contract.rb +48 -0
  405. data/lib/rosett_ai/plugins/registry.rb +150 -0
  406. data/lib/rosett_ai/policy/auditor.rb +41 -0
  407. data/lib/rosett_ai/policy/deny_list.rb +71 -0
  408. data/lib/rosett_ai/policy/opt_out_scanner.rb +37 -0
  409. data/lib/rosett_ai/policy/policy_compiler.rb +84 -0
  410. data/lib/rosett_ai/policy/protected_files.rb +47 -0
  411. data/lib/rosett_ai/policy/tier_hierarchy.rb +48 -0
  412. data/lib/rosett_ai/policy/validator.rb +35 -0
  413. data/lib/rosett_ai/profiler.rb +79 -0
  414. data/lib/rosett_ai/project/drift_detector.rb +126 -0
  415. data/lib/rosett_ai/project/manager.rb +115 -0
  416. data/lib/rosett_ai/project/sync_manager.rb +138 -0
  417. data/lib/rosett_ai/project/template_applier.rb +105 -0
  418. data/lib/rosett_ai/project_context.rb +82 -0
  419. data/lib/rosett_ai/provenance/entry.rb +63 -0
  420. data/lib/rosett_ai/provenance/file_source.rb +32 -0
  421. data/lib/rosett_ai/provenance/source.rb +62 -0
  422. data/lib/rosett_ai/provenance/store.rb +153 -0
  423. data/lib/rosett_ai/provenance/tracker.rb +62 -0
  424. data/lib/rosett_ai/provenance/trailer_generator.rb +43 -0
  425. data/lib/rosett_ai/provenance/validator.rb +45 -0
  426. data/lib/rosett_ai/quorum/collector.rb +59 -0
  427. data/lib/rosett_ai/quorum/comparator.rb +81 -0
  428. data/lib/rosett_ai/quorum/dispatcher.rb +57 -0
  429. data/lib/rosett_ai/quorum/strategies/adopt.rb +56 -0
  430. data/lib/rosett_ai/rai_config.rb +107 -0
  431. data/lib/rosett_ai/retrofit/base_parser.rb +66 -0
  432. data/lib/rosett_ai/retrofit/engine.rb +171 -0
  433. data/lib/rosett_ai/retrofit/parsers/agents_md_parser.rb +50 -0
  434. data/lib/rosett_ai/retrofit/parsers/claude_parser.rb +69 -0
  435. data/lib/rosett_ai/retrofit/parsers/cursor_parser.rb +82 -0
  436. data/lib/rosett_ai/retrofit/round_trip_validator.rb +65 -0
  437. data/lib/rosett_ai/retrofit/scanner.rb +47 -0
  438. data/lib/rosett_ai/retrofit/secret_detector.rb +87 -0
  439. data/lib/rosett_ai/secrets_resolver.rb +71 -0
  440. data/lib/rosett_ai/smart_feedback/suggester.rb +83 -0
  441. data/lib/rosett_ai/smart_feedback/thor_middleware.rb +84 -0
  442. data/lib/rosett_ai/structured_logger.rb +110 -0
  443. data/lib/rosett_ai/telemetry/json_lines_writer.rb +50 -0
  444. data/lib/rosett_ai/telemetry/log_rotator.rb +67 -0
  445. data/lib/rosett_ai/telemetry/provider.rb +26 -0
  446. data/lib/rosett_ai/telemetry/reporter.rb +144 -0
  447. data/lib/rosett_ai/telemetry.rb +47 -0
  448. data/lib/rosett_ai/text_sanitizer.rb +62 -0
  449. data/lib/rosett_ai/thor/cli.rb +269 -0
  450. data/lib/rosett_ai/thor/tasks/adopt.rb +250 -0
  451. data/lib/rosett_ai/thor/tasks/backup.rb +420 -0
  452. data/lib/rosett_ai/thor/tasks/behaviour.rb +474 -0
  453. data/lib/rosett_ai/thor/tasks/build.rb +1162 -0
  454. data/lib/rosett_ai/thor/tasks/compile.rb +415 -0
  455. data/lib/rosett_ai/thor/tasks/completion.rb +123 -0
  456. data/lib/rosett_ai/thor/tasks/comply.rb +82 -0
  457. data/lib/rosett_ai/thor/tasks/config.rb +265 -0
  458. data/lib/rosett_ai/thor/tasks/content.rb +193 -0
  459. data/lib/rosett_ai/thor/tasks/dbus.rb +321 -0
  460. data/lib/rosett_ai/thor/tasks/design.rb +258 -0
  461. data/lib/rosett_ai/thor/tasks/desktop.rb +129 -0
  462. data/lib/rosett_ai/thor/tasks/doctor.rb +127 -0
  463. data/lib/rosett_ai/thor/tasks/documentation.rb +321 -0
  464. data/lib/rosett_ai/thor/tasks/engines.rb +167 -0
  465. data/lib/rosett_ai/thor/tasks/hooks.rb +219 -0
  466. data/lib/rosett_ai/thor/tasks/init.rb +259 -0
  467. data/lib/rosett_ai/thor/tasks/license.rb +120 -0
  468. data/lib/rosett_ai/thor/tasks/mcp.rb +535 -0
  469. data/lib/rosett_ai/thor/tasks/migrate.rb +121 -0
  470. data/lib/rosett_ai/thor/tasks/plugins.rb +157 -0
  471. data/lib/rosett_ai/thor/tasks/project.rb +260 -0
  472. data/lib/rosett_ai/thor/tasks/provenance.rb +195 -0
  473. data/lib/rosett_ai/thor/tasks/release.rb +314 -0
  474. data/lib/rosett_ai/thor/tasks/retrofit.rb +90 -0
  475. data/lib/rosett_ai/thor/tasks/tooling.rb +308 -0
  476. data/lib/rosett_ai/thor/tasks/validate.rb +108 -0
  477. data/lib/rosett_ai/thor/tasks/workflow.rb +196 -0
  478. data/lib/rosett_ai/tooling/ci_yaml_validator.rb +37 -0
  479. data/lib/rosett_ai/tooling/version_checker.rb +35 -0
  480. data/lib/rosett_ai/ui/accessible_tui.rb +61 -0
  481. data/lib/rosett_ai/ui/base.rb +46 -0
  482. data/lib/rosett_ai/ui/gtk4.rb +98 -0
  483. data/lib/rosett_ai/ui/kde.rb +40 -0
  484. data/lib/rosett_ai/ui/qt6.rb +40 -0
  485. data/lib/rosett_ai/ui/registry.rb +60 -0
  486. data/lib/rosett_ai/ui/tty_helper.rb +74 -0
  487. data/lib/rosett_ai/ui/tui.rb +59 -0
  488. data/lib/rosett_ai/validators/behaviour_validator.rb +20 -0
  489. data/lib/rosett_ai/validators/design_validator.rb +17 -0
  490. data/lib/rosett_ai/validators/schema_validator.rb +84 -0
  491. data/lib/rosett_ai/validators/tooling_validator.rb +17 -0
  492. data/lib/rosett_ai/version.rb +8 -0
  493. data/lib/rosett_ai/version_consistency_checker.rb +129 -0
  494. data/lib/rosett_ai/workflow/audit_log.rb +86 -0
  495. data/lib/rosett_ai/workflow/engine.rb +142 -0
  496. data/lib/rosett_ai/workflow/manager.rb +82 -0
  497. data/lib/rosett_ai/workflow/schema_validator.rb +71 -0
  498. data/lib/rosett_ai/workflow/step_runner.rb +61 -0
  499. data/lib/rosett_ai/workflow/steps/prompt_step.rb +62 -0
  500. data/lib/rosett_ai/workflow/steps/rai_step.rb +74 -0
  501. data/lib/rosett_ai/workflow/steps/shell_step.rb +53 -0
  502. data/lib/rosett_ai/yaml_loader.rb +78 -0
  503. data/lib/rosett_ai.rb +221 -0
  504. data/lib/rubocop/cop/rosett_ai/shell_interpolation.rb +54 -0
  505. data/lib/rubocop/cop/rosett_ai/unsafe_const_get.rb +60 -0
  506. data/lib/rubocop/cop/rosett_ai/unsafe_send.rb +50 -0
  507. data/lib/rubocop/cop/rosett_ai/unsafe_yaml_load.rb +40 -0
  508. data/lib/rubocop/rosett_ai.rb +9 -0
  509. data/lib/scripts/generated/docker_hub_tags.rb +126 -0
  510. data/locales/.gitkeep +0 -0
  511. data/locales/ar.yml +579 -0
  512. data/locales/en.yml +571 -0
  513. data/locales/fr.yml +567 -0
  514. data/packaging/build-engine-deb.sh +81 -0
  515. data/packaging/scripts/postinst +17 -0
  516. data/packaging/scripts/postrm +19 -0
  517. data/packaging/scripts/prerm +10 -0
  518. data/packaging/wrapper.sh.template +38 -0
  519. data/rosett-ai.gemspec +63 -0
  520. data/rules/.gitkeep +0 -0
  521. data/scripts/publish/pulp_upload.sh +123 -0
  522. data/settings.json +29 -0
  523. data/share/applications/be.neatnerds.rosettai.desktop +29 -0
  524. data/share/dbus-1/interfaces/be.neatnerds.rosettai.xml +103 -0
  525. data/share/dbus-1/services/be.neatnerds.rosettai.service +3 -0
  526. data/share/templates/behaviour/criticalthinking.yml +69 -0
  527. metadata +810 -0
@@ -0,0 +1,1162 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-License-Identifier: GPL-3.0-only
4
+ # Copyright (C) 2026 Hugo Antonio Sepulveda Manriquez / NeatNerds
5
+
6
+ require 'thor'
7
+ require 'terminal-table'
8
+ require 'rainbow'
9
+ require 'tty-spinner'
10
+ require 'fileutils'
11
+ require 'pathname'
12
+ require 'securerandom'
13
+ require 'time'
14
+ require_relative '../../formatting'
15
+
16
+ module RosettAi
17
+ module Thor
18
+ module Tasks
19
+ # Mutable build state: ID, log path, timings, and failure info.
20
+ BuildContext = Struct.new(
21
+ :build_id, :build_start, :build_log,
22
+ :stage_timings, :completed_stages,
23
+ :failed_stage, :failure_reason, :summary_printed
24
+ ) do
25
+ def self.create(log_dir)
26
+ id = SecureRandom.uuid
27
+ log_path = log_dir.join("build-#{id}.log")
28
+ FileUtils.mkdir_p(log_path.dirname)
29
+ ctx = new(
30
+ build_id: id,
31
+ build_start: Time.now,
32
+ build_log: log_path,
33
+ stage_timings: {},
34
+ completed_stages: [],
35
+ failed_stage: nil,
36
+ failure_reason: nil,
37
+ summary_printed: false
38
+ )
39
+ File.open(log_path, 'w', 0o644) do |f|
40
+ f.write("# rosett-ai build log\n# Build ID: #{id}\n" \
41
+ "# Started: #{ctx.build_start.iso8601}\n\n")
42
+ end
43
+ ctx
44
+ end
45
+ end
46
+
47
+ # Thor subcommand for building a .deb package using fpm + ruby-build.
48
+ #
49
+ # Produces a self-contained Debian package at /opt/rosett-ai with an
50
+ # embedded Ruby runtime (compiled via ruby-build), vendored gems,
51
+ # and maintainer scripts for proper APT lifecycle handling.
52
+ #
53
+ # Build tooling references:
54
+ # fpm — https://github.com/jordansissel/fpm
55
+ # ruby-build — https://github.com/rbenv/ruby-build
56
+ # dpkg-deb — https://man7.org/linux/man-pages/man1/dpkg-deb.1.html
57
+ # fakeroot — https://wiki.debian.org/FakeRoot
58
+ class BuildPackage < ::Thor
59
+ include RosettAi::Formatting
60
+
61
+ default_task :create
62
+
63
+ INSTALL_DIR = '/opt/rosett-ai'
64
+
65
+ # Files/directories excluded when syncing app source into the staging tree
66
+ APP_EXCLUDES = [
67
+ # Version control
68
+ '.git', '.gitignore',
69
+ # Bundler / gem build artifacts
70
+ '.bundle', '.binstubs', 'vendor/bundle', '*.gem',
71
+ 'Gemfile.dev', 'Gemfile.dev.lock', 'Gemfile.integration',
72
+ # CI/CD
73
+ '.gitlab-ci*',
74
+ # Build & packaging
75
+ 'coverage', 'tmp', 'pkg', 'spec', 'test', 'packaging',
76
+ # Dev tool configs
77
+ '.rspec', '.rubocop*', '.reek*',
78
+ '.fasterer.yml', '.gitleaks.toml', '.mutant.yml',
79
+ '.overcommit.yml', '.git-hooks',
80
+ '.mdlrc', '.mdl_style.rb', '.yamllint', '.yardopts',
81
+ '.project', '.rosett-ai',
82
+ # AI assistant state — never ship in package
83
+ '.claude', 'doc/claude-sessions',
84
+ 'cliff.toml', 'kitchen.yml', 'Rakefile'
85
+ ].freeze
86
+
87
+ # Build stages in execution order (for tracking)
88
+ BUILD_STAGES = [
89
+ :validate, :clean, :prepare, :compile_ruby,
90
+ :sync_app, :install_gems, :clean_build_artifacts,
91
+ :install_wrapper,
92
+ :install_config, :install_man, :install_build_info, :package
93
+ ].freeze
94
+
95
+ # Bundler/RubyGems environment variables that must be stripped
96
+ # when shelling out to the embedded Ruby. Without this, a parent
97
+ # `bundle exec` leaks RUBYOPT=-rbundler/setup and BUNDLE_* vars
98
+ # into child processes, causing the embedded Ruby to try loading
99
+ # the project's Gemfile (which it cannot resolve).
100
+ BUNDLER_ENV_VARS = [
101
+ 'BUNDLE_GEMFILE', 'BUNDLE_PATH', 'BUNDLE_BIN_PATH',
102
+ 'BUNDLE_APP_CONFIG',
103
+ 'BUNDLE_DEPLOYMENT', 'BUNDLE_WITHOUT', 'BUNDLE_FROZEN',
104
+ 'BUNDLE_DISABLE_SHARED_GEMS', 'BUNDLE_IGNORE_CONFIG',
105
+ 'BUNDLER_ORIG_BUNDLE_GEMFILE', 'BUNDLER_ORIG_BUNDLE_BIN_PATH',
106
+ 'BUNDLER_ORIG_BUNDLER_SETUP', 'BUNDLER_ORIG_BUNDLER_VERSION',
107
+ 'BUNDLER_ORIG_GEM_HOME', 'BUNDLER_ORIG_GEM_PATH',
108
+ 'BUNDLER_ORIG_MANPATH', 'BUNDLER_ORIG_PATH',
109
+ 'BUNDLER_ORIG_RB_USER_INSTALL',
110
+ 'BUNDLER_ORIG_RUBYLIB', 'BUNDLER_ORIG_RUBYOPT',
111
+ 'BUNDLER_VERSION',
112
+ 'BUNDLER_SETUP', 'GEM_HOME', 'GEM_PATH',
113
+ 'RUBYOPT', 'RUBYLIB'
114
+ ].freeze
115
+
116
+ # Hermetic system PATH — no workstation paths allowed.
117
+ # Native gem compilation needs basic system tools (make, gcc, ld)
118
+ # but must never reference /home/*, ~/.rbenv, or other user paths.
119
+ HERMETIC_SYSTEM_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
120
+
121
+ desc 'create', 'Build a .deb package using fpm + ruby-build'
122
+ long_desc <<~LONGDESC
123
+ Builds a self-contained Debian package at /opt/rosett-ai with an embedded
124
+ Ruby runtime (compiled via ruby-build), vendored gems, wrapper script,
125
+ man pages, and maintainer scripts.
126
+
127
+ Requires fakeroot, dpkg-deb, rsync, ruby-build, and fpm.
128
+ Internal command — only available when developing Rosett-AI itself.
129
+
130
+ EXAMPLES
131
+
132
+ raictl build package
133
+ raictl build package --clean --verbose
134
+ raictl build package --variant gtk4 --architecture arm64
135
+ raictl build package --ruby-version 3.3.10 --output-dir /tmp/pkg
136
+ LONGDESC
137
+ method_option :verbose,
138
+ type: :boolean,
139
+ default: false,
140
+ desc: 'Show detailed build output'
141
+ method_option :clean,
142
+ type: :boolean,
143
+ default: false,
144
+ desc: 'Clean staging directory before building'
145
+ method_option :ruby_version,
146
+ type: :string,
147
+ desc: 'Ruby version to compile (default: read from .ruby-version)'
148
+ method_option :architecture,
149
+ type: :string,
150
+ default: nil,
151
+ desc: 'Target architecture (default: auto-detect via dpkg)'
152
+ method_option :output_dir,
153
+ type: :string,
154
+ default: 'pkg',
155
+ desc: 'Directory for the produced .deb file'
156
+ method_option :build_iteration,
157
+ type: :numeric,
158
+ default: 1,
159
+ desc: 'Package build iteration (release number)'
160
+ method_option :variant,
161
+ type: :string,
162
+ default: 'core',
163
+ desc: 'Package variant (core, gtk4, qt6)'
164
+
165
+ def create
166
+ unless RosettAi.context.rai_internal?
167
+ raise ::Thor::Error,
168
+ ::I18n.t('rosett_ai.cli.build_package_internal_only')
169
+ end
170
+
171
+ init_build_context!
172
+ load_variant_config!
173
+ print_build_header
174
+
175
+ run_stage(:validate) { validate_environment! }
176
+ run_stage(:clean) { clean_staging! } if options[:clean]
177
+ run_stage(:prepare) { prepare_staging! }
178
+ run_stage(:compile_ruby) { compile_ruby! }
179
+ run_stage(:sync_app) { sync_application! }
180
+ run_stage(:install_gems) { install_gems! }
181
+ run_stage(:clean_build_artifacts) { clean_build_artifacts! }
182
+ run_stage(:install_wrapper) { install_wrapper! }
183
+ run_stage(:install_config) { install_default_config! }
184
+ run_stage(:install_man) { install_man_pages! }
185
+ run_stage(:install_build_info) { install_build_info! }
186
+ run_stage(:package) { build_package! }
187
+
188
+ print_build_summary(:success)
189
+ rescue ::Thor::Error => e
190
+ print_build_summary(:failed) if @ctx && !@ctx.summary_printed
191
+ raise e
192
+ end
193
+
194
+ private
195
+
196
+ # -- build context -----------------------------------------------------
197
+
198
+ def init_build_context!
199
+ @ctx = BuildContext.create(project_root.join('tmp'))
200
+ end
201
+
202
+ def load_variant_config!
203
+ @variant_config = RosettAi::Packaging::VariantConfig.load(options[:variant])
204
+ end
205
+
206
+ def log_to_file(message)
207
+ File.open(@ctx.build_log, 'a', 0o644) { |f| f.puts("[#{Time.now.iso8601}] #{message}") }
208
+ end
209
+
210
+ def run_stage(name)
211
+ stage_start = Time.now
212
+ log_to_file("STAGE #{name}: started")
213
+ yield
214
+ elapsed = Time.now - stage_start
215
+ @ctx.stage_timings[name] = elapsed
216
+ @ctx.completed_stages << name
217
+ log_to_file("STAGE #{name}: completed (#{format('%.1f', elapsed)}s)")
218
+ rescue ::Thor::Error => e
219
+ elapsed = Time.now - stage_start
220
+ @ctx.stage_timings[name] = elapsed
221
+ @ctx.failed_stage = name
222
+ log_to_file("STAGE #{name}: FAILED after #{format('%.1f', elapsed)}s")
223
+ log_to_file("FAILURE: #{@ctx.failure_reason}") if @ctx.failure_reason
224
+ raise e
225
+ end
226
+
227
+ def print_build_header
228
+ puts
229
+ puts Rainbow('raictl package build').bright
230
+ puts " Build ID: #{@ctx.build_id}"
231
+ puts " Log file: #{@ctx.build_log}"
232
+ puts " Variant: #{@variant_config.variant}"
233
+ puts " Package: #{@variant_config.name}"
234
+ puts " Version: #{RosettAi::VERSION}"
235
+ puts " Ruby version: #{ruby_version}"
236
+ puts " Architecture: #{architecture}"
237
+ puts " Output: #{project_root.join(options[:output_dir])}"
238
+ puts
239
+ end
240
+
241
+ # -- paths -------------------------------------------------------------
242
+
243
+ def project_root
244
+ @project_root ||= RosettAi.root
245
+ end
246
+
247
+ def staging_dir
248
+ @staging_dir ||= project_root.join('tmp', 'staging')
249
+ end
250
+
251
+ def staging_install
252
+ @staging_install ||= staging_dir.join('opt', 'rosett-ai')
253
+ end
254
+
255
+ def staging_embedded
256
+ @staging_embedded ||= staging_install.join('embedded')
257
+ end
258
+
259
+ def staging_app
260
+ @staging_app ||= staging_install.join('app')
261
+ end
262
+
263
+ def output_dir
264
+ @output_dir ||= project_root.join(options[:output_dir]).tap { |dir| FileUtils.mkdir_p(dir) }
265
+ end
266
+
267
+ def packaging_dir
268
+ @packaging_dir ||= project_root.join('packaging')
269
+ end
270
+
271
+ # -- validation --------------------------------------------------------
272
+
273
+ def validate_environment!
274
+ check_required_commands
275
+ check_ronn_available!
276
+ check_variant_adapter!
277
+ log_verbose('Build environment validated')
278
+ end
279
+
280
+ def check_required_commands
281
+ required = ['fakeroot', 'dpkg-deb', 'rsync']
282
+ required << 'ruby-build' unless ruby_already_compiled?
283
+
284
+ required.each do |cmd|
285
+ next if command_available?(cmd)
286
+
287
+ abort_build("'#{cmd}' is not installed. " \
288
+ 'Install build dependencies: apt-get install fakeroot dpkg-dev rsync; ' \
289
+ 'and ensure ruby-build is on PATH.')
290
+ end
291
+
292
+ return if command_available?('fpm')
293
+
294
+ abort_build("'fpm' is not installed. Run: bundle install (fpm is in the :build group)")
295
+ end
296
+
297
+ def check_ronn_available!
298
+ ronn_source = project_root.join('doc', 'man', 'rai.1.ronn')
299
+ return unless ronn_source.exist?
300
+
301
+ begin
302
+ require 'ronn'
303
+ rescue LoadError
304
+ abort_build("'ronn-ng' gem is required to compile man pages but is not installed. " \
305
+ 'Run: gem install ronn-ng (or add it to the :build group in Gemfile)')
306
+ end
307
+ end
308
+
309
+ def check_variant_adapter!
310
+ return if @variant_config.variant == 'core'
311
+
312
+ adapter = @variant_config.ui_adapter
313
+ adapter_path = project_root.join('lib', 'rosett_ai', 'ui', "#{adapter}.rb")
314
+ return if adapter_path.exist?
315
+
316
+ abort_build(<<~MSG.chomp)
317
+ Adapter file lib/rosett_ai/ui/#{adapter}.rb not found.
318
+ The #{adapter} UI adapter has not been implemented yet.
319
+ See P3 (ui_framework) for implementation schedule.
320
+ MSG
321
+ end
322
+
323
+ def command_available?(cmd)
324
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |dir|
325
+ File.executable?(File.join(dir, cmd))
326
+ end
327
+ end
328
+
329
+ # -- staging -----------------------------------------------------------
330
+
331
+ def clean_staging!
332
+ spinner = build_spinner('Cleaning staging directory...')
333
+ FileUtils.rm_rf(staging_dir)
334
+ spinner.success(Rainbow(' done').green)
335
+ log_verbose("Removed #{staging_dir}")
336
+ end
337
+
338
+ def prepare_staging!
339
+ log_verbose("Preparing staging tree at #{staging_dir}")
340
+ [
341
+ staging_embedded,
342
+ staging_app,
343
+ staging_install.join('bin'),
344
+ staging_install.join('etc')
345
+ ].each { |dir| FileUtils.mkdir_p(dir) }
346
+ end
347
+
348
+ # -- ruby-build --------------------------------------------------------
349
+
350
+ def ruby_version
351
+ @ruby_version ||= options[:ruby_version] || read_ruby_version_file
352
+ end
353
+
354
+ def read_ruby_version_file
355
+ version_file = project_root.join('.ruby-version')
356
+ abort_build('.ruby-version file not found and --ruby-version not given') unless version_file.exist?
357
+ version_file.read.strip
358
+ end
359
+
360
+ def compile_ruby!
361
+ if ruby_already_compiled?
362
+ log_verbose("Ruby #{ruby_version} already compiled in staging — skipping")
363
+ return
364
+ end
365
+
366
+ spinner = build_spinner("Compiling Ruby #{ruby_version}...")
367
+ success = run_clean_command(
368
+ 'ruby-build', ruby_version, staging_embedded.to_s
369
+ )
370
+ return spinner.success(Rainbow(' done').green) if success
371
+
372
+ spinner.error(Rainbow(' failed').red)
373
+ detect_ruby_build_log
374
+ abort_build('ruby-build failed — see build log for details.')
375
+ end
376
+
377
+ def detect_ruby_build_log
378
+ logs = Dir.glob('/tmp/ruby-build.*.log').sort_by { |f| File.mtime(f) }
379
+ return if logs.empty?
380
+
381
+ latest = logs.last
382
+ log_to_file("ruby-build log: #{latest}")
383
+ tail = File.readlines(latest).last(30).join
384
+ log_to_file("ruby-build tail output:\n#{tail}")
385
+ @ctx.failure_reason = "ruby-build failed. Compiler log: #{latest}"
386
+ end
387
+
388
+ def ruby_already_compiled?
389
+ ruby_bin = staging_embedded.join('bin', 'ruby')
390
+ return false unless ruby_bin.exist?
391
+
392
+ installed = IO.popen([ruby_bin.to_s, '-e', 'puts RUBY_VERSION'], &:read).strip
393
+ installed == ruby_version.sub(/\A(\d+\.\d+\.\d+).*/, '\1')
394
+ rescue StandardError
395
+ false
396
+ end
397
+
398
+ # -- application sync --------------------------------------------------
399
+
400
+ def sync_application!
401
+ spinner = build_spinner('Syncing application source...')
402
+ excludes = APP_EXCLUDES.flat_map { |e| ['--exclude', e] }
403
+ success = run_command(
404
+ 'rsync', '-a', '--delete',
405
+ *excludes,
406
+ "#{project_root}/",
407
+ "#{staging_app}/"
408
+ )
409
+ return spinner.success(Rainbow(' done').green) if success
410
+
411
+ spinner.error(Rainbow(' failed').red)
412
+ abort_build('rsync failed to sync application source.')
413
+ end
414
+
415
+ # -- gem installation --------------------------------------------------
416
+
417
+ def install_gems!
418
+ spinner = build_spinner('Installing gems (deployment mode)...')
419
+ embedded_bundle = staging_embedded.join('bin', 'bundle').to_s
420
+
421
+ env = embedded_env
422
+ cmds = [
423
+ [env, embedded_bundle, 'config', 'set', '--local', 'deployment', 'true'],
424
+ [env, embedded_bundle, 'config', 'set', '--local', 'path', 'vendor/bundle'],
425
+ [env, embedded_bundle, 'config', 'set', '--local', 'without', 'development test build desktop engines'],
426
+ [env, embedded_bundle, 'config', 'set', '--local', 'jobs', jobs_count.to_s],
427
+ [env, embedded_bundle, 'install']
428
+ ]
429
+
430
+ cmds.each do |cmd|
431
+ next if run_command_in(staging_app.to_s, *cmd)
432
+
433
+ spinner.error(Rainbow(' failed').red)
434
+ log_bundle_debug(env, embedded_bundle)
435
+ abort_build(<<~MSG.chomp)
436
+ Gem installation failed.
437
+ The embedded Ruby bundler could not resolve or install gems.
438
+ This usually means Bundler environment variables from the parent
439
+ process leaked into the build. Check the build log for details.
440
+ MSG
441
+ end
442
+
443
+ spinner.success(Rainbow(' done').green)
444
+ end
445
+
446
+ # -- build artifact cleanup --------------------------------------------
447
+
448
+ # Strips native-gem build debris (gem_make.out, mkmf.log, Makefile)
449
+ # that embeds workstation-specific paths from the staging tree.
450
+ # These files are not needed at runtime and their presence leaks
451
+ # the build host's directory layout into the .deb package.
452
+ def clean_build_artifacts!
453
+ spinner = build_spinner('Cleaning native gem build artifacts...')
454
+ count = remove_build_debris
455
+ log_to_file("clean_build_artifacts: removed #{count} files")
456
+ spinner.success(Rainbow(" done (#{count} files removed)").green)
457
+ end
458
+
459
+ def remove_build_debris
460
+ artifacts = collect_build_artifacts
461
+ artifacts.each { |f| remove_artifact(f) }
462
+ artifacts.size
463
+ end
464
+
465
+ def collect_build_artifacts
466
+ Dir.glob(staging_app.join('vendor', 'bundle', '**', '{gem_make.out,mkmf.log}')) +
467
+ Dir.glob(staging_app.join('vendor', 'bundle', '**', 'ext', '**', 'Makefile'))
468
+ end
469
+
470
+ def remove_artifact(path)
471
+ FileUtils.rm_f(path)
472
+ log_verbose("Removed build artifact: #{path}")
473
+ end
474
+
475
+ def log_bundle_debug(env, bundle_bin)
476
+ log_to_file('--- bundle environment debug ---')
477
+ log_to_file(" BUNDLE_GEMFILE: #{env['BUNDLE_GEMFILE']}")
478
+ log_to_file(" PATH (first entry): #{env['PATH'].to_s.split(':').first}")
479
+ log_to_file(" bundle binary: #{bundle_bin}")
480
+ log_to_file(" bundle exists: #{File.exist?(bundle_bin)}")
481
+ gemfile = staging_app.join('Gemfile')
482
+ log_to_file(" Gemfile exists: #{gemfile.exist?}")
483
+ lockfile = staging_app.join('Gemfile.lock')
484
+ log_to_file(" Gemfile.lock exists: #{lockfile.exist?}")
485
+
486
+ # Log which BUNDLE_ vars are still set in the current process
487
+ leaked = ENV.select { |k, _| k.start_with?('BUNDLE', 'GEM_', 'RUBY') }
488
+ log_to_file(' Env vars in parent process (potential leaks):')
489
+ leaked.each { |k, v| log_to_file(" #{k}=#{v}") }
490
+ log_to_file('--- end bundle debug ---')
491
+ end
492
+
493
+ # Returns an environment hash that:
494
+ # 1. Unsets all Bundler/RubyGems vars inherited from `bundle exec`
495
+ # 2. Sets PATH to prefer the embedded Ruby
496
+ # 3. Points BUNDLE_GEMFILE at the staging app's Gemfile
497
+ def embedded_env
498
+ env = clean_env.merge(
499
+ 'PATH' => "#{staging_embedded.join('bin')}:#{HERMETIC_SYSTEM_PATH}",
500
+ 'BUNDLE_GEMFILE' => staging_app.join('Gemfile').to_s
501
+ )
502
+ log_to_file("embedded_env: #{env.inspect}")
503
+ env
504
+ end
505
+
506
+ # Strips all Bundler/RubyGems variables. When passed to system(),
507
+ # nil values cause the variable to be unset in the child process.
508
+ def clean_env
509
+ BUNDLER_ENV_VARS.to_h { |var| [var, nil] }
510
+ end
511
+
512
+ def jobs_count
513
+ @jobs_count ||= begin
514
+ nproc = `nproc 2>/dev/null`.strip
515
+ nproc.empty? ? 4 : [nproc.to_i, 1].max
516
+ end
517
+ end
518
+
519
+ # -- wrapper + config --------------------------------------------------
520
+
521
+ def install_wrapper!
522
+ template = packaging_dir.join('wrapper.sh.template').read
523
+ ruby_abi = query_embedded_ruby('RbConfig::CONFIG["ruby_version"]')
524
+ ruby_arch = query_embedded_ruby('RbConfig::CONFIG["arch"]')
525
+
526
+ content = template
527
+ .gsub('@@RUBY_ABI@@', ruby_abi)
528
+ .gsub('@@RUBY_ARCH@@', ruby_arch)
529
+
530
+ dest = staging_install.join('bin', 'raictl')
531
+ File.write(dest, content)
532
+ FileUtils.chmod(0o755, dest)
533
+ log_verbose("Installed wrapper script to #{dest} (ABI=#{ruby_abi}, arch=#{ruby_arch})")
534
+ end
535
+
536
+ def query_embedded_ruby(expression)
537
+ ruby_bin = staging_embedded.join('bin', 'ruby').to_s
538
+ env = clean_env.merge(
539
+ 'LD_LIBRARY_PATH' => staging_embedded.join('lib').to_s
540
+ )
541
+ result = IO.popen([env, ruby_bin, '-e', "puts #{expression}"], &:read).strip
542
+ abort_build("Failed to query embedded Ruby for: #{expression}") if result.empty?
543
+ result
544
+ end
545
+
546
+ def install_default_config!
547
+ src = project_root.join('settings.json')
548
+ return unless src.exist?
549
+
550
+ # Ship default at /opt/rosett-ai/etc/ for reference
551
+ default_dest = staging_install.join('etc', 'settings.json.default')
552
+ FileUtils.cp(src, default_dest)
553
+ log_verbose("Installed default settings to #{default_dest}")
554
+
555
+ # Ship config at /etc/rosett-ai/ so dpkg treats it as a conffile
556
+ # (dpkg will prompt on upgrade if user has modified it)
557
+ etc_dir = staging_dir.join('etc', 'rosett-ai')
558
+ FileUtils.mkdir_p(etc_dir)
559
+ FileUtils.cp(src, etc_dir.join('settings.json'))
560
+ log_verbose("Installed conffile to #{etc_dir.join('settings.json')}")
561
+ end
562
+
563
+ # -- man pages ---------------------------------------------------------
564
+
565
+ def install_man_pages!
566
+ ronn_source = project_root.join('doc', 'man', 'rai.1.ronn')
567
+ return log_verbose('No man page source found — skipping') unless ronn_source.exist?
568
+
569
+ man_dir = staging_dir.join('usr', 'share', 'man', 'man1')
570
+ FileUtils.mkdir_p(man_dir)
571
+
572
+ require 'ronn'
573
+ doc = Ronn::Document.new(ronn_source.to_s)
574
+ roff_content = doc.to_roff
575
+
576
+ man_path = man_dir.join('rai.1')
577
+ File.write(man_path, roff_content)
578
+
579
+ # gzip the man page for dpkg conventions
580
+ system('gzip', '-f', man_path.to_s)
581
+ log_verbose("Installed man page to #{man_path}.gz")
582
+ end
583
+
584
+ # -- build info --------------------------------------------------------
585
+
586
+ def install_build_info!
587
+ info = build_info_content
588
+ dest = staging_install.join('etc', 'BUILD_INFO')
589
+ File.open(dest, 'w', 0o644) { |f| f.write(info) }
590
+ log_verbose("Installed build info to #{dest}")
591
+ end
592
+
593
+ def build_info_content
594
+ <<~INFO
595
+ version=#{RosettAi::VERSION}
596
+ commit=#{git_commit_sha}
597
+ branch=#{git_branch}
598
+ build_date=#{Time.now.utc.iso8601}
599
+ build_id=#{@ctx.build_id}
600
+ variant=#{@variant_config.variant}
601
+ ruby_version=#{ruby_version}
602
+ architecture=#{architecture}
603
+ builder=#{ENV.fetch('USER', 'unknown')}
604
+ INFO
605
+ end
606
+
607
+ def git_commit_sha
608
+ IO.popen(['git', '-C', project_root.to_s, 'rev-parse', '--short', 'HEAD'], err: File::NULL, &:read).strip
609
+ .then { |s| s.empty? ? 'unknown' : s }
610
+ rescue StandardError
611
+ 'unknown'
612
+ end
613
+
614
+ def git_branch
615
+ IO.popen(['git', '-C', project_root.to_s, 'rev-parse', '--abbrev-ref', 'HEAD'], err: File::NULL, &:read).strip
616
+ .then { |s| s.empty? ? 'unknown' : s }
617
+ rescue StandardError
618
+ 'unknown'
619
+ end
620
+
621
+ # -- fpm ---------------------------------------------------------------
622
+
623
+ def architecture
624
+ @architecture ||= options[:architecture] || detect_architecture
625
+ end
626
+
627
+ def detect_architecture
628
+ arch = `dpkg --print-architecture 2>/dev/null`.strip
629
+ if arch.empty?
630
+ RosettAi.logger.warn('dpkg --print-architecture failed; falling back to amd64')
631
+ log_to_file('WARNING: dpkg --print-architecture returned empty — defaulting to amd64')
632
+ puts Rainbow(' Warning: could not detect architecture via dpkg; defaulting to amd64').yellow
633
+ 'amd64'
634
+ else
635
+ arch
636
+ end
637
+ end
638
+
639
+ def package_filename
640
+ "#{@variant_config.name}_#{RosettAi::VERSION}-#{options[:build_iteration]}_#{architecture}.deb"
641
+ end
642
+
643
+ def build_package!
644
+ spinner = build_spinner('Building .deb package with fpm...')
645
+
646
+ fpm_args = [
647
+ 'fpm',
648
+ '-s', 'dir',
649
+ '-t', 'deb',
650
+ '--name', @variant_config.name,
651
+ '--version', RosettAi::VERSION,
652
+ '--iteration', options[:build_iteration].to_s,
653
+ '--maintainer', 'NeatNerds <query@neatnerds.be>',
654
+ '--vendor', 'NeatNerds',
655
+ '--description', @variant_config.description,
656
+ '--url', 'https://gitlab.neatnerds.be/neatnerds/NeatNerds-AI/rosett-ai',
657
+ '--license', 'GPL-3.0-only',
658
+ '--architecture', architecture,
659
+ '--category', 'devel',
660
+ *fpm_depends_args,
661
+ '--config-files', '/etc/rosett-ai/settings.json',
662
+ '--directories', '/opt/rosett-ai',
663
+ '--after-install', packaging_dir.join('scripts', 'postinst').to_s,
664
+ '--before-remove', packaging_dir.join('scripts', 'prerm').to_s,
665
+ '--after-remove', packaging_dir.join('scripts', 'postrm').to_s,
666
+ '--deb-compression', 'xz',
667
+ '--deb-field', 'Vcs-Git: https://gitlab.neatnerds.be/neatnerds/NeatNerds-AI/rosett-ai.git',
668
+ '--deb-field', "Build-Commit: #{git_commit_sha}",
669
+ '--deb-field', "Build-Branch: #{git_branch}",
670
+ '--deb-field', "Build-Date: #{Time.now.utc.iso8601}",
671
+ '--package', output_dir.join(package_filename).to_s,
672
+ '--force',
673
+ '-C', staging_dir.to_s,
674
+ '.'
675
+ ]
676
+
677
+ success = run_command(*fpm_args)
678
+ return spinner.success(Rainbow(' done').green) if success
679
+
680
+ spinner.error(Rainbow(' failed').red)
681
+ abort_build('fpm packaging failed.')
682
+ end
683
+
684
+ def fpm_depends_args
685
+ @variant_config.fpm_depends.flat_map { |dep| ['--depends', dep] }
686
+ end
687
+
688
+ # -- build summary -----------------------------------------------------
689
+
690
+ def print_build_summary(status)
691
+ @ctx.summary_printed = true
692
+ elapsed = Time.now - @ctx.build_start
693
+ puts
694
+
695
+ print_stages_table(status, elapsed)
696
+ print_failure_detail if status == :failed
697
+ print_artifacts_table if status == :success
698
+ print_build_footer(status, elapsed)
699
+ end
700
+
701
+ def print_stages_table(status, total_elapsed)
702
+ rows = BUILD_STAGES.map { |stage| build_stage_row(stage) }
703
+ rows.compact!
704
+
705
+ title_label, _, color = status_text(status)
706
+ table = ::Terminal::Table.new(
707
+ title: Rainbow("Build #{title_label}").send(color).bright,
708
+ headings: ['Stage', 'Status', 'Duration'],
709
+ rows: rows,
710
+ style: { border: :unicode_round }
711
+ )
712
+ puts table
713
+ puts " Total: #{format_duration(total_elapsed)}"
714
+ end
715
+
716
+ def build_stage_row(stage)
717
+ if @ctx.completed_stages.include?(stage)
718
+ elapsed = @ctx.stage_timings[stage]
719
+ [stage_label(stage), Rainbow('OK').green, format_duration(elapsed)]
720
+ elsif @ctx.failed_stage == stage
721
+ elapsed = @ctx.stage_timings[stage]
722
+ [stage_label(stage), Rainbow('FAILED').red, format_duration(elapsed)]
723
+ elsif skipped_stage?(stage)
724
+ nil
725
+ else
726
+ [stage_label(stage), Rainbow('--').faint, '--']
727
+ end
728
+ end
729
+
730
+ def skipped_stage?(stage)
731
+ stage == :clean && !options[:clean]
732
+ end
733
+
734
+ def stage_label(stage)
735
+ {
736
+ validate: 'Validate environment',
737
+ clean: 'Clean staging',
738
+ prepare: 'Prepare staging tree',
739
+ compile_ruby: "Compile Ruby #{ruby_version}",
740
+ sync_app: 'Sync application source',
741
+ install_gems: 'Install gems (deployment)',
742
+ install_wrapper: 'Install wrapper script',
743
+ install_config: 'Install default config',
744
+ install_man: 'Install man pages',
745
+ install_build_info: 'Install build info',
746
+ package: 'Build .deb with fpm'
747
+ }.fetch(stage, stage.to_s)
748
+ end
749
+
750
+ def print_failure_detail
751
+ puts
752
+ puts Rainbow('Failure Detail').red.bright
753
+ puts Rainbow(" Stage: #{stage_label(@ctx.failed_stage)}").red
754
+ puts Rainbow(" Reason: #{@ctx.failure_reason}").red if @ctx.failure_reason
755
+ puts
756
+ puts " Build log: #{@ctx.build_log}"
757
+ puts ' Re-run with --verbose for full command output.'
758
+ puts
759
+ end
760
+
761
+ def print_artifacts_table
762
+ packages = Dir.glob(output_dir.join('*.deb').to_s)
763
+ return puts Rainbow(' No .deb packages found in output directory.').yellow if packages.empty?
764
+
765
+ rows = packages.map { |pkg| [File.basename(pkg), format_size(File.size(pkg))] }
766
+ table = ::Terminal::Table.new(
767
+ title: Rainbow('Artifacts').bright,
768
+ headings: ['Package', 'Size'],
769
+ rows: rows,
770
+ style: { border: :unicode_round }
771
+ )
772
+ puts table
773
+ end
774
+
775
+ def print_build_footer(status, elapsed)
776
+ _, footer_label, color = status_text(status)
777
+ puts
778
+ puts " Build ID: #{@ctx.build_id}"
779
+ puts " Log file: #{@ctx.build_log}"
780
+ puts " Duration: #{format_duration(elapsed)}"
781
+ puts " Status: #{Rainbow(footer_label).send(color)}"
782
+ puts
783
+ end
784
+
785
+ # format_size and format_duration are provided by RosettAi::Formatting
786
+
787
+ # Returns [table_title, footer_label, color] for the given build status.
788
+ def status_text(status)
789
+ status == :success ? ['Complete', 'SUCCESS', :green] : ['Failed', 'FAILED', :red]
790
+ end
791
+
792
+ # -- helpers -----------------------------------------------------------
793
+
794
+ def build_spinner(message)
795
+ spinner = TTY::Spinner.new("[:spinner] #{message}", format: :dots, clear: !options[:verbose])
796
+ spinner.auto_spin
797
+ spinner
798
+ end
799
+
800
+ def run_command(*cmd)
801
+ if options[:verbose]
802
+ system(*cmd)
803
+ else
804
+ system(*cmd, [:out, :err] => File::NULL)
805
+ end
806
+ end
807
+
808
+ # Runs a command with a fully clean environment (no Bundler leakage).
809
+ # Used for ruby-build and any process that must not see the parent's bundle.
810
+ def run_clean_command(*cmd)
811
+ if options[:verbose]
812
+ system(clean_env, *cmd)
813
+ else
814
+ system(clean_env, *cmd, [:out, :err] => File::NULL)
815
+ end
816
+ end
817
+
818
+ def run_command_in(dir, env, *cmd)
819
+ if options[:verbose]
820
+ system(env, *cmd, chdir: dir)
821
+ else
822
+ system(env, *cmd, chdir: dir, [:out, :err] => File::NULL)
823
+ end
824
+ end
825
+
826
+ def abort_build(message)
827
+ @ctx.failure_reason = message
828
+ log_to_file("ABORT: #{message}")
829
+ raise ::Thor::Error, "Error: #{message}"
830
+ end
831
+
832
+ def log_verbose(message)
833
+ puts " #{message}" if options[:verbose]
834
+ end
835
+ end
836
+
837
+ # Thor subcommand for building a .deb package for an rosett-ai engine plugin.
838
+ #
839
+ # Replaces the per-engine packaging/build-deb.sh shell scripts with a
840
+ # unified Ruby implementation that auto-detects engine name and version
841
+ # from the gemspec, derives the Ruby ABI from the current runtime, and
842
+ # packages with fpm.
843
+ #
844
+ # Build tooling references:
845
+ # fpm — https://github.com/jordansissel/fpm
846
+ class BuildEngine < ::Thor
847
+ include RosettAi::Formatting
848
+
849
+ default_task :create
850
+
851
+ INSTALL_BASE = '/opt/rosett-ai/embedded/lib/ruby/gems'
852
+
853
+ desc 'create', 'Build a .deb package for an rosett-ai engine plugin'
854
+ long_desc <<~LONGDESC
855
+ Builds a .deb package for an rosett-ai engine plugin by auto-detecting
856
+ the engine name and version from the gemspec. The package installs
857
+ the gem into /opt/rosett-ai/embedded/ and depends on the Rosett-AI core package.
858
+
859
+ Requires gem and fpm. Run from the engine repo directory or use --path.
860
+
861
+ EXAMPLES
862
+
863
+ raictl build engine
864
+ raictl build engine --path ../rosett-ai-engine-claude --verbose
865
+ raictl build engine --rai-min-version 1.0.0 --output-dir /tmp/pkg
866
+ LONGDESC
867
+ method_option :verbose,
868
+ type: :boolean,
869
+ default: false,
870
+ desc: 'Show detailed build output'
871
+ method_option :path,
872
+ type: :string,
873
+ default: nil,
874
+ desc: 'Engine repo directory (default: current directory)'
875
+ method_option :output_dir,
876
+ type: :string,
877
+ default: 'pkg',
878
+ desc: 'Directory for the produced .deb file'
879
+ method_option :rai_min_version,
880
+ type: :string,
881
+ default: nil,
882
+ desc: 'Minimum Rosett-AI dependency version (default: current Rosett-AI version)'
883
+ method_option :build_iteration,
884
+ type: :numeric,
885
+ default: 1,
886
+ desc: 'Package build iteration (release number)'
887
+
888
+ def create
889
+ resolve_engine_dir!
890
+ detect_gemspec!
891
+ parse_gemspec!
892
+ print_engine_header
893
+
894
+ run_stage(:validate) { validate_engine_environment! }
895
+ run_stage(:gem_build) { build_gem! }
896
+ run_stage(:staging) { prepare_staging! }
897
+ run_stage(:gem_install) { install_gem_to_staging! }
898
+ run_stage(:package) { build_engine_package! }
899
+
900
+ print_engine_summary(:success)
901
+ rescue ::Thor::Error => e
902
+ print_engine_summary(:failed) unless @summary_printed
903
+ raise e
904
+ end
905
+
906
+ private
907
+
908
+ # -- resolution --------------------------------------------------------
909
+
910
+ def resolve_engine_dir!
911
+ dir = options[:path] || ENV.fetch('RAI_ORIGINAL_PWD', Dir.pwd)
912
+ @engine_dir = Pathname.new(dir).expand_path
913
+ abort_engine("Directory does not exist: #{@engine_dir}") unless @engine_dir.directory?
914
+ end
915
+
916
+ def detect_gemspec!
917
+ candidates = Dir.glob(@engine_dir.join('rosett-ai-engine-*.gemspec').to_s)
918
+ case candidates.length
919
+ when 0
920
+ abort_engine(::I18n.t('rosett_ai.cli.build_engine.no_gemspec', dir: @engine_dir))
921
+ when 1
922
+ @gemspec_path = Pathname.new(candidates.first)
923
+ else
924
+ abort_engine(::I18n.t('rosett_ai.cli.build_engine.multiple_gemspecs', dir: @engine_dir))
925
+ end
926
+ end
927
+
928
+ def parse_gemspec!
929
+ spec = Gem::Specification.load(@gemspec_path.to_s)
930
+ abort_engine("Failed to load gemspec: #{@gemspec_path}") unless spec
931
+
932
+ @engine_name = spec.name
933
+ @engine_version = spec.version.to_s
934
+ log_verbose("Detected engine: #{@engine_name} v#{@engine_version}")
935
+ end
936
+
937
+ # -- paths -------------------------------------------------------------
938
+
939
+ def ruby_abi
940
+ @ruby_abi ||= RbConfig::CONFIG['ruby_version']
941
+ end
942
+
943
+ def staging_dir
944
+ @staging_dir ||= @engine_dir.join('tmp', 'staging')
945
+ end
946
+
947
+ def gem_install_dir
948
+ @gem_install_dir ||= "#{INSTALL_BASE}/#{ruby_abi}"
949
+ end
950
+
951
+ def staging_gem_dir
952
+ @staging_gem_dir ||= staging_dir.join(gem_install_dir.delete_prefix('/'))
953
+ end
954
+
955
+ def output_dir
956
+ @output_dir ||= @engine_dir.join(options[:output_dir]).tap { |d| FileUtils.mkdir_p(d) }
957
+ end
958
+
959
+ def gem_file
960
+ @gem_file ||= @engine_dir.join('tmp', "#{@engine_name}-#{@engine_version}.gem")
961
+ end
962
+
963
+ def rai_min_version
964
+ @rai_min_version ||= options[:rai_min_version] || RosettAi::VERSION
965
+ end
966
+
967
+ # -- validation --------------------------------------------------------
968
+
969
+ def validate_engine_environment!
970
+ ['gem', 'fpm'].each do |cmd|
971
+ next if command_available?(cmd)
972
+
973
+ abort_engine("'#{cmd}' is not installed.")
974
+ end
975
+ log_verbose('Engine build environment validated')
976
+ end
977
+
978
+ def command_available?(cmd)
979
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |dir|
980
+ File.executable?(File.join(dir, cmd))
981
+ end
982
+ end
983
+
984
+ # -- build stages ------------------------------------------------------
985
+
986
+ def build_gem!
987
+ spinner = build_spinner("Building #{@engine_name}-#{@engine_version}.gem...")
988
+ FileUtils.mkdir_p(gem_file.dirname)
989
+
990
+ success = run_command(
991
+ 'gem', 'build', @gemspec_path.to_s,
992
+ '--output', gem_file.to_s,
993
+ chdir: @engine_dir.to_s
994
+ )
995
+ return spinner.success(Rainbow(' done').green) if success
996
+
997
+ spinner.error(Rainbow(' failed').red)
998
+ abort_engine('gem build failed.')
999
+ end
1000
+
1001
+ def prepare_staging!
1002
+ FileUtils.rm_rf(staging_dir)
1003
+ FileUtils.mkdir_p(staging_gem_dir)
1004
+ log_verbose("Staging tree at #{staging_dir}")
1005
+ end
1006
+
1007
+ def install_gem_to_staging!
1008
+ spinner = build_spinner('Installing gem into staging tree...')
1009
+ success = run_command(
1010
+ 'gem', 'install',
1011
+ '--local',
1012
+ '--install-dir', staging_gem_dir.to_s,
1013
+ '--no-document',
1014
+ '--ignore-dependencies',
1015
+ gem_file.to_s
1016
+ )
1017
+ return spinner.success(Rainbow(' done').green) if success
1018
+
1019
+ spinner.error(Rainbow(' failed').red)
1020
+ abort_engine('gem install into staging failed.')
1021
+ end
1022
+
1023
+ def build_engine_package!
1024
+ spinner = build_spinner('Building .deb package with fpm...')
1025
+
1026
+ fpm_args = [
1027
+ 'fpm',
1028
+ '-s', 'dir',
1029
+ '-t', 'deb',
1030
+ '--name', @engine_name,
1031
+ '--version', @engine_version,
1032
+ '--iteration', options[:build_iteration].to_s,
1033
+ '--maintainer', 'NeatNerds <query@neatnerds.be>',
1034
+ '--vendor', 'NeatNerds',
1035
+ '--description', "rosett-ai engine plugin: #{@engine_name}",
1036
+ '--url', "https://gitlab.neatnerds.be/neatnerds/#{@engine_name}",
1037
+ '--license', 'GPL-3.0-only',
1038
+ '--architecture', 'all',
1039
+ '--category', 'devel',
1040
+ '--depends', "rosett-ai (>= #{rai_min_version})",
1041
+ '--directories', gem_install_dir,
1042
+ '--deb-compression', 'xz',
1043
+ '--deb-field', "Vcs-Git: https://gitlab.neatnerds.be/neatnerds/#{@engine_name}.git",
1044
+ '--deb-field', "Build-Commit: #{engine_git_commit_sha}",
1045
+ '--deb-field', "Build-Branch: #{engine_git_branch}",
1046
+ '--deb-field', "Build-Date: #{Time.now.utc.iso8601}",
1047
+ '--package', output_dir.join(package_filename).to_s,
1048
+ '--force',
1049
+ '-C', staging_dir.to_s,
1050
+ '.'
1051
+ ]
1052
+
1053
+ success = run_command(*fpm_args)
1054
+ return spinner.success(Rainbow(' done').green) if success
1055
+
1056
+ spinner.error(Rainbow(' failed').red)
1057
+ abort_engine('fpm packaging failed.')
1058
+ end
1059
+
1060
+ def package_filename
1061
+ "#{@engine_name}_#{@engine_version}-#{options[:build_iteration]}_all.deb"
1062
+ end
1063
+
1064
+ # -- output ------------------------------------------------------------
1065
+
1066
+ def print_engine_header
1067
+ puts
1068
+ puts Rainbow('raictl engine build').bright
1069
+ puts " Engine: #{@engine_name}"
1070
+ puts " Version: #{@engine_version}"
1071
+ puts " Ruby ABI: #{ruby_abi}"
1072
+ puts " Source: #{@engine_dir}"
1073
+ puts " Output: #{output_dir}"
1074
+ puts " rai dep: >= #{rai_min_version}"
1075
+ puts
1076
+ end
1077
+
1078
+ def print_engine_summary(status)
1079
+ @summary_printed = true
1080
+ puts
1081
+
1082
+ if status == :success
1083
+ puts Rainbow('Build Complete').green.bright
1084
+ deb = output_dir.join(package_filename)
1085
+ if deb.exist?
1086
+ puts " Package: #{deb}"
1087
+ puts " Size: #{format_size(deb.size)}"
1088
+ end
1089
+ else
1090
+ puts Rainbow('Build Failed').red.bright
1091
+ puts " Reason: #{@failure_reason}" if @failure_reason
1092
+ end
1093
+ puts
1094
+ end
1095
+
1096
+ # -- stages tracking ---------------------------------------------------
1097
+
1098
+ def run_stage(name)
1099
+ log_verbose("Stage #{name}: started")
1100
+ yield
1101
+ log_verbose("Stage #{name}: completed")
1102
+ rescue ::Thor::Error
1103
+ @failed_stage = name
1104
+ raise
1105
+ end
1106
+
1107
+ # -- helpers -----------------------------------------------------------
1108
+
1109
+ def build_spinner(message)
1110
+ spinner = TTY::Spinner.new("[:spinner] #{message}", format: :dots, clear: !options[:verbose])
1111
+ spinner.auto_spin
1112
+ spinner
1113
+ end
1114
+
1115
+ def run_command(*cmd, chdir: nil)
1116
+ kwargs = {}
1117
+ kwargs[:chdir] = chdir if chdir
1118
+ kwargs[[:out, :err]] = File::NULL unless options[:verbose]
1119
+ system(*cmd, **kwargs)
1120
+ end
1121
+
1122
+ def engine_git_commit_sha
1123
+ IO.popen(['git', '-C', @engine_dir.to_s, 'rev-parse', '--short', 'HEAD'], err: File::NULL, &:read).strip
1124
+ .then { |s| s.empty? ? 'unknown' : s }
1125
+ rescue StandardError
1126
+ 'unknown'
1127
+ end
1128
+
1129
+ def engine_git_branch
1130
+ IO.popen(['git', '-C', @engine_dir.to_s, 'rev-parse', '--abbrev-ref', 'HEAD'], err: File::NULL, &:read).strip
1131
+ .then { |s| s.empty? ? 'unknown' : s }
1132
+ rescue StandardError
1133
+ 'unknown'
1134
+ end
1135
+
1136
+ def abort_engine(message)
1137
+ @failure_reason = message
1138
+ raise ::Thor::Error, "Error: #{message}"
1139
+ end
1140
+
1141
+ def log_verbose(message)
1142
+ puts " #{message}" if options[:verbose]
1143
+ end
1144
+
1145
+ # format_size is provided by RosettAi::Formatting
1146
+ end
1147
+
1148
+ # Parent Thor group for build subcommands
1149
+ class Build < ::Thor
1150
+ def self.exit_on_failure?
1151
+ true
1152
+ end
1153
+
1154
+ desc 'package', 'Build a .deb package using fpm + ruby-build'
1155
+ subcommand 'package', BuildPackage
1156
+
1157
+ desc 'engine', 'Build a .deb package for an rosett-ai engine plugin'
1158
+ subcommand 'engine', BuildEngine
1159
+ end
1160
+ end
1161
+ end
1162
+ end