create-workframe 0.1.0 → 0.1.1

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 (206) hide show
  1. package/LICENSE +201 -201
  2. package/NOTICE +12 -12
  3. package/README.md +8 -92
  4. package/SECURITY.md +40 -40
  5. package/bin/workframe.js +329 -329
  6. package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +1 -1
  7. package/docs/workspace-instructions/WORKFRAME_ROUTING.md +8 -8
  8. package/package.json +3 -6
  9. package/profiles/architect/AGENTS.md +29 -29
  10. package/profiles/architect/SOUL.md +2 -2
  11. package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
  12. package/profiles/designer/AGENTS.md +26 -26
  13. package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
  14. package/profiles/dev/AGENTS.md +28 -28
  15. package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
  16. package/profiles/docs/AGENTS.md +27 -27
  17. package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
  18. package/profiles/research/AGENTS.md +26 -26
  19. package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
  20. package/profiles/visionary/AGENTS.md +25 -25
  21. package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
  22. package/profiles/workframe-agent/AGENTS.md +37 -37
  23. package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
  24. package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
  25. package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
  26. package/rules/workspace-README.md +5 -5
  27. package/scripts/apply-update-hermes.sh +17 -17
  28. package/scripts/apply-update-workframe.sh +77 -77
  29. package/scripts/bootstrap-workspace-link.sh +8 -8
  30. package/scripts/bundle-workframe-ui.mjs +3 -3
  31. package/scripts/compose-docker-host.sh +37 -37
  32. package/scripts/ensure-compose-host-paths.mjs +51 -51
  33. package/scripts/fix-zk-encryption-key.sh +35 -35
  34. package/scripts/lib/install-identity.mjs +212 -212
  35. package/scripts/restart-gateway-hermes.sh +12 -12
  36. package/scripts/set-compose-public-url.mjs +92 -92
  37. package/scripts/setup-stack-secrets.sh +50 -50
  38. package/scripts/sync-canonical-to-package.mjs +8 -7
  39. package/scripts/verify-public-deploy.sh +105 -105
  40. package/shared/WORKFRAME_AGENT_LIBRARY.md +17 -17
  41. package/shared/WORKFRAME_AGENT_OPERATIONS.md +15 -15
  42. package/shared/WORKFRAME_AGENT_PACKS.json +18 -18
  43. package/shared/WORKFRAME_AGENT_PACKS.yaml +8 -8
  44. package/shared/WORKFRAME_SKILL_CURATION.md +4 -4
  45. package/workframe-api/README.md +28 -28
  46. package/workframe-api/action_proxy.py +131 -131
  47. package/workframe-api/auth_rate_limit.py +49 -49
  48. package/workframe-api/credential_vault.py +445 -445
  49. package/workframe-api/data/avatar-catalog.json +41 -41
  50. package/workframe-api/email_sender.py +220 -220
  51. package/workframe-api/google_auth.py +90 -90
  52. package/workframe-api/install_api.py +359 -359
  53. package/workframe-api/internal_proxy_auth.py +150 -150
  54. package/workframe-api/llm_proxy.py +277 -277
  55. package/workframe-api/oidc_jwt.py +108 -108
  56. package/workframe-api/package.json +12 -13
  57. package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
  58. package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
  59. package/workframe-api/requirements.txt +2 -2
  60. package/workframe-api/site_meta.py +271 -271
  61. package/workframe-api/stack_config.py +427 -427
  62. package/workframe-api/time-bind-chat.py +99 -99
  63. package/workframe-api/turn_credentials.py +226 -226
  64. package/workframe-api/updates.py +417 -417
  65. package/workframe-api/vault_kek.py +159 -159
  66. package/workframe-api/zk_auth.py +633 -633
  67. package/workframe-supervisor/Dockerfile +11 -11
  68. package/workframe-supervisor/server.py +787 -787
  69. package/workframe-ui/docker/nginx.conf +85 -85
  70. package/workframe-ui/public/assets/{arc-CBDYvkAF.js → arc-COAT3laO.js} +1 -1
  71. package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +1 -0
  72. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-XnBRKeW0.js → architectureDiagram-3BPJPVTR-BFjWV24l.js} +1 -1
  73. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-VYHUfVhd.js → blockDiagram-GPEHLZMM-DSQLPfrj.js} +1 -1
  74. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BTjUcJpm.js → c4Diagram-AAUBKEIU-DKEHv1t2.js} +1 -1
  75. package/workframe-ui/public/assets/channel-g7r_RGaY.js +1 -0
  76. package/workframe-ui/public/assets/{chunk-2J33WTMH-w7uu7R-b.js → chunk-2J33WTMH-DHZg-DUi.js} +1 -1
  77. package/workframe-ui/public/assets/{chunk-3OPIFGDE-Cb9LtnDX.js → chunk-3OPIFGDE-BB-OYTfp.js} +1 -1
  78. package/workframe-ui/public/assets/{chunk-4BX2VUAB-DiQ-qCwH.js → chunk-4BX2VUAB-C93q0YIm.js} +1 -1
  79. package/workframe-ui/public/assets/{chunk-55IACEB6-C-mLFr7z.js → chunk-55IACEB6-MAYniqik.js} +1 -1
  80. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DOesfiCI.js → chunk-5ZQYHXKU-ChgN6YJs.js} +1 -1
  81. package/workframe-ui/public/assets/{chunk-727SXJPM-BJ3oBZuz.js → chunk-727SXJPM-B_FYwdAv.js} +1 -1
  82. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CCA6xpGs.js → chunk-AQP2D5EJ-1_Hw_h1A.js} +1 -1
  83. package/workframe-ui/public/assets/{chunk-BSJP7CBP-a0cMNFb2.js → chunk-BSJP7CBP-CFiDQ1Rv.js} +1 -1
  84. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-kuqN8EIY.js → chunk-CSCIHK7Q-DZ9UMTlB.js} +1 -1
  85. package/workframe-ui/public/assets/{chunk-FMBD7UC4-DyPgYHCg.js → chunk-FMBD7UC4-DlMlyFgw.js} +1 -1
  86. package/workframe-ui/public/assets/{chunk-KSCS5N6A-CdUuvR0V.js → chunk-KSCS5N6A-DHXtQ_Hf.js} +1 -1
  87. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-Dq9NoWmK.js → chunk-L5ZTLDWV-CuQzg-QG.js} +1 -1
  88. package/workframe-ui/public/assets/{chunk-LZXEDZCA-p74rddlO.js → chunk-LZXEDZCA-BHzjzCGg.js} +2 -2
  89. package/workframe-ui/public/assets/{chunk-ND2GUHAM-DBD2u1Gz.js → chunk-ND2GUHAM-DHXx05n2.js} +1 -1
  90. package/workframe-ui/public/assets/{chunk-NZK2D7GU-BeIeYFnd.js → chunk-NZK2D7GU-CV5pmDM_.js} +1 -1
  91. package/workframe-ui/public/assets/{chunk-O5CBEL6O-ClHc56ib.js → chunk-O5CBEL6O-6tkCHxsV.js} +1 -1
  92. package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +1 -0
  93. package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +1 -0
  94. package/workframe-ui/public/assets/{chunk-XPW4576I-EFr8R_1p.js → chunk-XPW4576I-BClwIiCp.js} +1 -1
  95. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +1 -0
  96. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +1 -0
  97. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-C7aPBODd.js → cose-bilkent-S5V4N54A-DOrGV6DQ.js} +1 -1
  98. package/workframe-ui/public/assets/{dagre-BM42HDAG-BdU1Rv-H.js → dagre-BM42HDAG-DXTPvJkX.js} +1 -1
  99. package/workframe-ui/public/assets/{diagram-2AECGRRQ-DWowSo85.js → diagram-2AECGRRQ-xX_v-pbf.js} +1 -1
  100. package/workframe-ui/public/assets/{diagram-5GNKFQAL-MnxBbceO.js → diagram-5GNKFQAL-Cd2pXbBe.js} +1 -1
  101. package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQaLRXFf.js → diagram-KO2AKTUF-Df3XvUtk.js} +1 -1
  102. package/workframe-ui/public/assets/{diagram-LMA3HP47-CQaBud9k.js → diagram-LMA3HP47-CsijIPaD.js} +1 -1
  103. package/workframe-ui/public/assets/{diagram-OG6HWLK6-D8bAXbY9.js → diagram-OG6HWLK6-aq5fmfHd.js} +1 -1
  104. package/workframe-ui/public/assets/{dist-DGpTLHr_.js → dist-D1c0mkbB.js} +1 -1
  105. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-1E-xSvBK.js → erDiagram-TEJ5UH35-DnFysVRY.js} +1 -1
  106. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +1 -0
  107. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CgOVD5hu.js → flowDiagram-I6XJVG4X-C6Ebi3su.js} +1 -1
  108. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-JFYAIauo.js → ganttDiagram-6RSMTGT7-BQXQtUpa.js} +1 -1
  109. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-B9REenIl.js → gitGraph-WXDBUCRP-Dt0zIs_M.js} +1 -1
  110. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BQ7NcMSn.js → gitGraphDiagram-PVQCEYII-BF8gHzRn.js} +1 -1
  111. package/workframe-ui/public/assets/index-DpoUZAxh.css +1 -0
  112. package/workframe-ui/public/assets/{index-Dnw6vjqb.js → index-lRpzpNPT.js} +2 -2
  113. package/workframe-ui/public/assets/{info-J43DQDTF-CL6-eTjH.js → info-J43DQDTF-CSmszQJT.js} +1 -1
  114. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-LJTODW4W.js → infoDiagram-5YYISTIA-CVTKGW6p.js} +1 -1
  115. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-bchrQVuo.js → ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js} +1 -1
  116. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DkrvYuxP.js → journeyDiagram-JHISSGLW-r3wD68_T.js} +1 -1
  117. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DFRbj0IG.js → kanban-definition-UN3LZRKU-Il8VglqN.js} +1 -1
  118. package/workframe-ui/public/assets/{line-Vd48P7-O.js → line-oyjpfz2A.js} +1 -1
  119. package/workframe-ui/public/assets/{linear-Ckizh2G7.js → linear-Cf7p5tVp.js} +1 -1
  120. package/workframe-ui/public/assets/{mermaid-parser.core-Bkimsnqj.js → mermaid-parser.core-YmbZ-AfY.js} +2 -2
  121. package/workframe-ui/public/assets/{mermaid.core-x0TvVuPo.js → mermaid.core-BFdCAqCo.js} +3 -3
  122. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-6ykAFPEz.js → mindmap-definition-RKZ34NQL-Cy2iCtEl.js} +1 -1
  123. package/workframe-ui/public/assets/{packet-YPE3B663-Dw3xgMDt.js → packet-YPE3B663-DwOBZL6K.js} +1 -1
  124. package/workframe-ui/public/assets/{pie-LRSECV5Y-DATysawG.js → pie-LRSECV5Y-04PPhnKK.js} +1 -1
  125. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-SJKD1S0S.js → pieDiagram-4H26LBE5-LxIpgHqi.js} +1 -1
  126. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-BrYDZX8q.js → quadrantDiagram-W4KKPZXB-0nBYfYm4.js} +1 -1
  127. package/workframe-ui/public/assets/{radar-GUYGQ44K-BmWYPCds.js → radar-GUYGQ44K-D2-vBqps.js} +1 -1
  128. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DwL9Mc8e.js → requirementDiagram-4Y6WPE33-DbuU0nlu.js} +1 -1
  129. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DYIFsL8h.js → sankeyDiagram-5OEKKPKP-B2hQ6B2x.js} +1 -1
  130. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-0-FPkFk8.js → sequenceDiagram-3UESZ5HK-BBrU30e1.js} +1 -1
  131. package/workframe-ui/public/assets/{src-B_od6b6h.js → src-BJEDmV70.js} +1 -1
  132. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BQCiBk6u.js → stateDiagram-AJRCARHV-7FGO4kkH.js} +1 -1
  133. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +1 -0
  134. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DS3tFcXj.js → timeline-definition-PNZ67QCA-ptDm4rCN.js} +1 -1
  135. package/workframe-ui/public/assets/{treeView-BLDUP644-DSyUCKLY.js → treeView-BLDUP644-CS6Z-0q8.js} +1 -1
  136. package/workframe-ui/public/assets/{treemap-LRROVOQU-CEZaNh5Y.js → treemap-LRROVOQU-DqV4Y2VA.js} +1 -1
  137. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CD-Vc9NF.js → vennDiagram-CIIHVFJN-C0UrZJYt.js} +1 -1
  138. package/workframe-ui/public/assets/{wardley-L42UT6IY-Drq5w1Mc.js → wardley-L42UT6IY-bNDN3_Sa.js} +1 -1
  139. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DouXDJoF.js → wardleyDiagram-YWT4CUSO-jWiJsefM.js} +1 -1
  140. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DDf_Lol5.js → xychartDiagram-2RQKCTM6-Dsh_fLCy.js} +1 -1
  141. package/workframe-ui/public/favicon.svg +7 -7
  142. package/workframe-ui/public/index.html +50 -50
  143. package/workframe-ui/public/workframe-config.json +3 -3
  144. package/scripts/security_audit.py +0 -156
  145. package/scripts/test-scaffold.mjs +0 -390
  146. package/workframe-api/tests/__init__.py +0 -0
  147. package/workframe-api/tests/db_setup.py +0 -13
  148. package/workframe-api/tests/test_admin_updates_gated.py +0 -30
  149. package/workframe-api/tests/test_agent_dm_bootstrap.py +0 -196
  150. package/workframe-api/tests/test_agent_profile_sync.py +0 -76
  151. package/workframe-api/tests/test_auth_email.py +0 -222
  152. package/workframe-api/tests/test_auth_hole_fix_selfcheck.py +0 -99
  153. package/workframe-api/tests/test_auth_rate_limit.py +0 -19
  154. package/workframe-api/tests/test_avatar_resolve.py +0 -77
  155. package/workframe-api/tests/test_child_soul_template.py +0 -71
  156. package/workframe-api/tests/test_credential_canary.py +0 -135
  157. package/workframe-api/tests/test_credential_isolation.py +0 -448
  158. package/workframe-api/tests/test_credential_resolution.py +0 -206
  159. package/workframe-api/tests/test_device_oauth.py +0 -108
  160. package/workframe-api/tests/test_doctor_repair.py +0 -103
  161. package/workframe-api/tests/test_ensure_profile_api.py +0 -77
  162. package/workframe-api/tests/test_gateway_compose_security.py +0 -136
  163. package/workframe-api/tests/test_install_secure_host.py +0 -39
  164. package/workframe-api/tests/test_internal_proxy_auth.py +0 -125
  165. package/workframe-api/tests/test_invite_runtime_bootstrap.py +0 -72
  166. package/workframe-api/tests/test_kanban_delegation.py +0 -185
  167. package/workframe-api/tests/test_llm_proxy.py +0 -155
  168. package/workframe-api/tests/test_login_access_policy.py +0 -183
  169. package/workframe-api/tests/test_mvp_model_bootstrap.py +0 -75
  170. package/workframe-api/tests/test_onboarding_bootstrap.py +0 -248
  171. package/workframe-api/tests/test_platform_auth.py +0 -47
  172. package/workframe-api/tests/test_profile_config_path.py +0 -56
  173. package/workframe-api/tests/test_profile_config_yaml_repair.py +0 -63
  174. package/workframe-api/tests/test_profile_create.py +0 -72
  175. package/workframe-api/tests/test_profile_identity_overlay.py +0 -61
  176. package/workframe-api/tests/test_profile_install_health.py +0 -45
  177. package/workframe-api/tests/test_profile_secret_policy.py +0 -57
  178. package/workframe-api/tests/test_profile_workspace_cwd.py +0 -34
  179. package/workframe-api/tests/test_provider_bootstrap.py +0 -75
  180. package/workframe-api/tests/test_provider_connect.py +0 -54
  181. package/workframe-api/tests/test_room_crud.py +0 -192
  182. package/workframe-api/tests/test_room_tenancy.py +0 -701
  183. package/workframe-api/tests/test_runtime_identity_backfill.py +0 -34
  184. package/workframe-api/tests/test_site_meta.py +0 -81
  185. package/workframe-api/tests/test_soul_stub.py +0 -42
  186. package/workframe-api/tests/test_space_member_sync.py +0 -99
  187. package/workframe-api/tests/test_stripe_stack_config.py +0 -37
  188. package/workframe-api/tests/test_supervisor_lifecycle.py +0 -52
  189. package/workframe-api/tests/test_turn_credential_vault.py +0 -125
  190. package/workframe-api/tests/test_updates.py +0 -176
  191. package/workframe-api/tests/test_user_cohort.py +0 -113
  192. package/workframe-api/tests/test_vault_envelope.py +0 -110
  193. package/workframe-api/tests/test_workspace_members.py +0 -183
  194. package/workframe-api/tests/test_workspace_messaging_sync.py +0 -125
  195. package/workframe-api/tests/test_workspace_provider_list.py +0 -57
  196. package/workframe-supervisor/tests/test_exec_guard.py +0 -42
  197. package/workframe-supervisor/tests/test_server_import.py +0 -21
  198. package/workframe-ui/public/assets/architecture-7EHR7CIX-CtbQKTuT.js +0 -1
  199. package/workframe-ui/public/assets/channel-Dy4Z4-jn.js +0 -1
  200. package/workframe-ui/public/assets/chunk-QZHKN3VN-CtBEchFK.js +0 -1
  201. package/workframe-ui/public/assets/chunk-WU5MYG2G-B9pBtriN.js +0 -1
  202. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BMAEA8jI.js +0 -1
  203. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BMAEA8jI.js +0 -1
  204. package/workframe-ui/public/assets/eventmodeling-FCH6USID-D75cstNT.js +0 -1
  205. package/workframe-ui/public/assets/index-DpAGxump.css +0 -1
  206. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-B89jAMFF.js +0 -1
@@ -1,2 +1,2 @@
1
- cryptography>=41.0.0
2
- PyYAML>=6.0
1
+ cryptography>=41.0.0
2
+ PyYAML>=6.0
@@ -1,271 +1,271 @@
1
- """Public site metadata — OG tags, PWA manifest, link previews."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- from pathlib import Path
7
- from typing import Any, Callable
8
- from urllib.parse import urljoin
9
-
10
- import stack_config
11
-
12
- DEFAULT_TITLE = "Workframe"
13
- DEFAULT_SHORT_NAME = "Workframe"
14
- DEFAULT_DESCRIPTION = "Project chat and workspace for Hermes agent crews"
15
- DEFAULT_THEME_COLOR = "#0A0A0F"
16
- DEFAULT_OG_IMAGE = "/assets/branding/og-default.png"
17
- DEFAULT_FAVICON = "/favicon.svg"
18
-
19
- BRANDING_DIR_NAME = "site-branding"
20
- OG_FILENAME = "og-image"
21
- FAVICON_FILENAME = "favicon"
22
-
23
- _MIME_BY_SUFFIX = {
24
- ".png": "image/png",
25
- ".jpg": "image/jpeg",
26
- ".jpeg": "image/jpeg",
27
- ".webp": "image/webp",
28
- ".svg": "image/svg+xml",
29
- ".ico": "image/x-icon",
30
- }
31
-
32
-
33
- def branding_dir() -> Path:
34
- root = stack_config.DATA_DIR / BRANDING_DIR_NAME
35
- root.mkdir(parents=True, exist_ok=True)
36
- return root
37
-
38
-
39
- def _site_branding_raw() -> dict[str, Any]:
40
- raw = stack_config._read_raw()
41
- block = raw.get("site_branding")
42
- return block if isinstance(block, dict) else {}
43
-
44
-
45
- def site_branding_public() -> dict[str, Any]:
46
- block = _site_branding_raw()
47
- return {
48
- "title": str(block.get("title") or "").strip(),
49
- "description": str(block.get("description") or "").strip(),
50
- "theme_color": str(block.get("theme_color") or "").strip(),
51
- "has_og_image": _branding_asset_path("og").is_file(),
52
- "has_favicon": _branding_asset_path("favicon").is_file(),
53
- }
54
-
55
-
56
- def patch_site_branding(body: dict[str, Any]) -> None:
57
- raw = stack_config._read_raw()
58
- block = raw.get("site_branding") if isinstance(raw.get("site_branding"), dict) else {}
59
- for key in ("title", "description", "theme_color"):
60
- if key in body:
61
- block[key] = str(body.get(key) or "").strip()
62
- raw["site_branding"] = block
63
- stack_config._write_raw(raw)
64
-
65
-
66
- def _branding_asset_path(kind: str) -> Path:
67
- block = _site_branding_raw()
68
- rel = str(block.get(f"{kind}_file") or "").strip()
69
- if rel:
70
- candidate = stack_config.DATA_DIR / rel
71
- if candidate.is_file():
72
- return candidate
73
- for suffix in (".png", ".jpg", ".jpeg", ".webp", ".svg", ".ico"):
74
- candidate = branding_dir() / f"{OG_FILENAME if kind == 'og' else FAVICON_FILENAME}{suffix}"
75
- if candidate.is_file():
76
- return candidate
77
- return branding_dir() / f"{OG_FILENAME if kind == 'og' else FAVICON_FILENAME}.png"
78
-
79
-
80
- def save_branding_asset(kind: str, data: bytes, content_type: str = "") -> Path:
81
- if kind not in {"og", "favicon"}:
82
- raise ValueError("invalid_branding_kind")
83
- if not data:
84
- raise ValueError("empty_asset")
85
- suffix = _suffix_for_upload(content_type, data)
86
- dest = branding_dir() / f"{OG_FILENAME if kind == 'og' else FAVICON_FILENAME}{suffix}"
87
- dest.write_bytes(data)
88
- raw = stack_config._read_raw()
89
- block = raw.get("site_branding") if isinstance(raw.get("site_branding"), dict) else {}
90
- block[f"{kind}_file"] = f"{BRANDING_DIR_NAME}/{dest.name}"
91
- raw["site_branding"] = block
92
- stack_config._write_raw(raw)
93
- return dest
94
-
95
-
96
- def _suffix_for_upload(content_type: str, data: bytes) -> str:
97
- ctype = str(content_type or "").split(";")[0].strip().lower()
98
- if ctype == "image/png" or data[:8] == b"\x89PNG\r\n\x1a\n":
99
- return ".png"
100
- if ctype in {"image/jpeg", "image/jpg"} or data[:3] == b"\xff\xd8\xff":
101
- return ".jpg"
102
- if ctype == "image/webp" or data[:4] == b"RIFF" and data[8:12] == b"WEBP":
103
- return ".webp"
104
- if ctype == "image/svg+xml" or data.lstrip()[:5] == b"<svg " or b"<svg" in data[:200]:
105
- return ".svg"
106
- if ctype == "image/x-icon":
107
- return ".ico"
108
- return ".png"
109
-
110
-
111
- def _abs_url(base: str, path: str) -> str:
112
- base = str(base or "").strip().rstrip("/")
113
- path = str(path or "").strip()
114
- if not path:
115
- return ""
116
- if path.startswith("http://") or path.startswith("https://"):
117
- return path
118
- if not base:
119
- return path
120
- return urljoin(f"{base}/", path.lstrip("/"))
121
-
122
-
123
- def _browser_asset_url(base: str, path: str) -> str:
124
- """Same-origin static assets — relative paths avoid loopback leaks on HTTPS deploys."""
125
- path = str(path or "").strip()
126
- if path.startswith("/") and not path.startswith("//"):
127
- return path
128
- return _abs_url(base, path)
129
-
130
-
131
- def resolve_site_meta(
132
- *,
133
- app_base_url: str,
134
- install_complete: bool,
135
- workspace: dict[str, Any] | None = None,
136
- normalize_logo: Callable[[str], str] | None = None,
137
- ) -> dict[str, Any]:
138
- """Merge stack overrides, primary workspace identity, and Workframe defaults."""
139
- overrides = _site_branding_raw()
140
- ws = workspace or {}
141
-
142
- title = str(overrides.get("title") or "").strip()
143
- if not title and install_complete:
144
- title = str(ws.get("display_name") or "").strip()
145
- if not title:
146
- title = DEFAULT_TITLE
147
-
148
- description = str(overrides.get("description") or "").strip()
149
- if not description and install_complete:
150
- description = str(ws.get("description") or "").strip()
151
- if not description:
152
- description = DEFAULT_DESCRIPTION
153
-
154
- tagline = str(ws.get("tagline") or "").strip() if install_complete else ""
155
-
156
- theme_color = str(overrides.get("theme_color") or "").strip() or DEFAULT_THEME_COLOR
157
-
158
- og_path = _branding_asset_path("og")
159
- if og_path.is_file():
160
- og_image = _browser_asset_url(app_base_url, f"/api/public/branding/og{og_path.suffix}")
161
- elif install_complete:
162
- logo = str(ws.get("avatar_url") or "").strip()
163
- if logo and normalize_logo:
164
- logo = normalize_logo(logo)
165
- og_image = _browser_asset_url(app_base_url, logo) if logo else _browser_asset_url(app_base_url, DEFAULT_OG_IMAGE)
166
- else:
167
- og_image = _browser_asset_url(app_base_url, DEFAULT_OG_IMAGE)
168
-
169
- fav_path = _branding_asset_path("favicon")
170
- if fav_path.is_file():
171
- favicon = _browser_asset_url(app_base_url, f"/api/public/branding/favicon{fav_path.suffix}")
172
- else:
173
- favicon = _browser_asset_url(app_base_url, DEFAULT_FAVICON)
174
-
175
- short_name = title if len(title) <= 16 else title[:15].rstrip() + "…"
176
- canonical = app_base_url.rstrip("/") + "/" if app_base_url else "/"
177
-
178
- return {
179
- "ok": True,
180
- "install_complete": bool(install_complete),
181
- "title": title,
182
- "short_name": short_name,
183
- "description": description,
184
- "tagline": tagline,
185
- "theme_color": theme_color,
186
- "og_image": og_image,
187
- "favicon": favicon,
188
- "canonical_url": canonical,
189
- "manifest_url": _browser_asset_url(app_base_url, "/manifest.webmanifest"),
190
- "source": {
191
- "title": "stack" if overrides.get("title") else ("workspace" if install_complete and ws.get("display_name") else "default"),
192
- "description": "stack" if overrides.get("description") else ("workspace" if install_complete and ws.get("description") else "default"),
193
- "og_image": "upload" if og_path.is_file() else ("workspace_logo" if install_complete and ws.get("avatar_url") else "default"),
194
- "favicon": "upload" if fav_path.is_file() else "default",
195
- },
196
- }
197
-
198
-
199
- def manifest_payload(meta: dict[str, Any]) -> dict[str, Any]:
200
- icons = []
201
- favicon = str(meta.get("favicon") or "").strip()
202
- if favicon:
203
- icons.append(
204
- {
205
- "src": favicon,
206
- "sizes": "any",
207
- "type": "image/svg+xml" if favicon.endswith(".svg") else "image/png",
208
- "purpose": "any",
209
- },
210
- )
211
- return {
212
- "name": meta.get("title") or DEFAULT_TITLE,
213
- "short_name": meta.get("short_name") or DEFAULT_SHORT_NAME,
214
- "description": meta.get("description") or DEFAULT_DESCRIPTION,
215
- "start_url": "./",
216
- "display": "standalone",
217
- "background_color": meta.get("theme_color") or DEFAULT_THEME_COLOR,
218
- "theme_color": meta.get("theme_color") or DEFAULT_THEME_COLOR,
219
- "orientation": "any",
220
- "icons": icons or [{"src": "./favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any"}],
221
- }
222
-
223
-
224
- def link_preview_html(meta: dict[str, Any]) -> str:
225
- title = _html_escape(str(meta.get("title") or DEFAULT_TITLE))
226
- description = _html_escape(str(meta.get("description") or DEFAULT_DESCRIPTION))
227
- canonical = _html_escape(str(meta.get("canonical_url") or "/"))
228
- og_image = _html_escape(str(meta.get("og_image") or ""))
229
- theme = _html_escape(str(meta.get("theme_color") or DEFAULT_THEME_COLOR))
230
- favicon = _html_escape(str(meta.get("favicon") or DEFAULT_FAVICON))
231
- return f"""<!doctype html>
232
- <html lang="en">
233
- <head>
234
- <meta charset="utf-8" />
235
- <meta name="viewport" content="width=device-width, initial-scale=1" />
236
- <title>{title}</title>
237
- <meta name="description" content="{description}" />
238
- <meta name="theme-color" content="{theme}" />
239
- <link rel="icon" href="{favicon}" />
240
- <meta property="og:type" content="website" />
241
- <meta property="og:site_name" content="{title}" />
242
- <meta property="og:title" content="{title}" />
243
- <meta property="og:description" content="{description}" />
244
- <meta property="og:url" content="{canonical}" />
245
- <meta property="og:image" content="{og_image}" />
246
- <meta name="twitter:card" content="summary_large_image" />
247
- <meta name="twitter:title" content="{title}" />
248
- <meta name="twitter:description" content="{description}" />
249
- <meta name="twitter:image" content="{og_image}" />
250
- <meta http-equiv="refresh" content="0;url={canonical}" />
251
- </head>
252
- <body><p><a href="{canonical}">{title}</a></p></body>
253
- </html>
254
- """
255
-
256
-
257
- def branding_asset_bytes(kind: str) -> tuple[bytes, str] | None:
258
- path = _branding_asset_path(kind)
259
- if not path.is_file():
260
- return None
261
- ctype = _MIME_BY_SUFFIX.get(path.suffix.lower(), "application/octet-stream")
262
- return path.read_bytes(), ctype
263
-
264
-
265
- def _html_escape(value: str) -> str:
266
- return (
267
- value.replace("&", "&amp;")
268
- .replace("<", "&lt;")
269
- .replace(">", "&gt;")
270
- .replace('"', "&quot;")
271
- )
1
+ """Public site metadata — OG tags, PWA manifest, link previews."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+ from urllib.parse import urljoin
9
+
10
+ import stack_config
11
+
12
+ DEFAULT_TITLE = "Workframe"
13
+ DEFAULT_SHORT_NAME = "Workframe"
14
+ DEFAULT_DESCRIPTION = "Project chat and workspace for Hermes agent crews"
15
+ DEFAULT_THEME_COLOR = "#0A0A0F"
16
+ DEFAULT_OG_IMAGE = "/assets/branding/og-default.png"
17
+ DEFAULT_FAVICON = "/favicon.svg"
18
+
19
+ BRANDING_DIR_NAME = "site-branding"
20
+ OG_FILENAME = "og-image"
21
+ FAVICON_FILENAME = "favicon"
22
+
23
+ _MIME_BY_SUFFIX = {
24
+ ".png": "image/png",
25
+ ".jpg": "image/jpeg",
26
+ ".jpeg": "image/jpeg",
27
+ ".webp": "image/webp",
28
+ ".svg": "image/svg+xml",
29
+ ".ico": "image/x-icon",
30
+ }
31
+
32
+
33
+ def branding_dir() -> Path:
34
+ root = stack_config.DATA_DIR / BRANDING_DIR_NAME
35
+ root.mkdir(parents=True, exist_ok=True)
36
+ return root
37
+
38
+
39
+ def _site_branding_raw() -> dict[str, Any]:
40
+ raw = stack_config._read_raw()
41
+ block = raw.get("site_branding")
42
+ return block if isinstance(block, dict) else {}
43
+
44
+
45
+ def site_branding_public() -> dict[str, Any]:
46
+ block = _site_branding_raw()
47
+ return {
48
+ "title": str(block.get("title") or "").strip(),
49
+ "description": str(block.get("description") or "").strip(),
50
+ "theme_color": str(block.get("theme_color") or "").strip(),
51
+ "has_og_image": _branding_asset_path("og").is_file(),
52
+ "has_favicon": _branding_asset_path("favicon").is_file(),
53
+ }
54
+
55
+
56
+ def patch_site_branding(body: dict[str, Any]) -> None:
57
+ raw = stack_config._read_raw()
58
+ block = raw.get("site_branding") if isinstance(raw.get("site_branding"), dict) else {}
59
+ for key in ("title", "description", "theme_color"):
60
+ if key in body:
61
+ block[key] = str(body.get(key) or "").strip()
62
+ raw["site_branding"] = block
63
+ stack_config._write_raw(raw)
64
+
65
+
66
+ def _branding_asset_path(kind: str) -> Path:
67
+ block = _site_branding_raw()
68
+ rel = str(block.get(f"{kind}_file") or "").strip()
69
+ if rel:
70
+ candidate = stack_config.DATA_DIR / rel
71
+ if candidate.is_file():
72
+ return candidate
73
+ for suffix in (".png", ".jpg", ".jpeg", ".webp", ".svg", ".ico"):
74
+ candidate = branding_dir() / f"{OG_FILENAME if kind == 'og' else FAVICON_FILENAME}{suffix}"
75
+ if candidate.is_file():
76
+ return candidate
77
+ return branding_dir() / f"{OG_FILENAME if kind == 'og' else FAVICON_FILENAME}.png"
78
+
79
+
80
+ def save_branding_asset(kind: str, data: bytes, content_type: str = "") -> Path:
81
+ if kind not in {"og", "favicon"}:
82
+ raise ValueError("invalid_branding_kind")
83
+ if not data:
84
+ raise ValueError("empty_asset")
85
+ suffix = _suffix_for_upload(content_type, data)
86
+ dest = branding_dir() / f"{OG_FILENAME if kind == 'og' else FAVICON_FILENAME}{suffix}"
87
+ dest.write_bytes(data)
88
+ raw = stack_config._read_raw()
89
+ block = raw.get("site_branding") if isinstance(raw.get("site_branding"), dict) else {}
90
+ block[f"{kind}_file"] = f"{BRANDING_DIR_NAME}/{dest.name}"
91
+ raw["site_branding"] = block
92
+ stack_config._write_raw(raw)
93
+ return dest
94
+
95
+
96
+ def _suffix_for_upload(content_type: str, data: bytes) -> str:
97
+ ctype = str(content_type or "").split(";")[0].strip().lower()
98
+ if ctype == "image/png" or data[:8] == b"\x89PNG\r\n\x1a\n":
99
+ return ".png"
100
+ if ctype in {"image/jpeg", "image/jpg"} or data[:3] == b"\xff\xd8\xff":
101
+ return ".jpg"
102
+ if ctype == "image/webp" or data[:4] == b"RIFF" and data[8:12] == b"WEBP":
103
+ return ".webp"
104
+ if ctype == "image/svg+xml" or data.lstrip()[:5] == b"<svg " or b"<svg" in data[:200]:
105
+ return ".svg"
106
+ if ctype == "image/x-icon":
107
+ return ".ico"
108
+ return ".png"
109
+
110
+
111
+ def _abs_url(base: str, path: str) -> str:
112
+ base = str(base or "").strip().rstrip("/")
113
+ path = str(path or "").strip()
114
+ if not path:
115
+ return ""
116
+ if path.startswith("http://") or path.startswith("https://"):
117
+ return path
118
+ if not base:
119
+ return path
120
+ return urljoin(f"{base}/", path.lstrip("/"))
121
+
122
+
123
+ def _browser_asset_url(base: str, path: str) -> str:
124
+ """Same-origin static assets — relative paths avoid loopback leaks on HTTPS deploys."""
125
+ path = str(path or "").strip()
126
+ if path.startswith("/") and not path.startswith("//"):
127
+ return path
128
+ return _abs_url(base, path)
129
+
130
+
131
+ def resolve_site_meta(
132
+ *,
133
+ app_base_url: str,
134
+ install_complete: bool,
135
+ workspace: dict[str, Any] | None = None,
136
+ normalize_logo: Callable[[str], str] | None = None,
137
+ ) -> dict[str, Any]:
138
+ """Merge stack overrides, primary workspace identity, and Workframe defaults."""
139
+ overrides = _site_branding_raw()
140
+ ws = workspace or {}
141
+
142
+ title = str(overrides.get("title") or "").strip()
143
+ if not title and install_complete:
144
+ title = str(ws.get("display_name") or "").strip()
145
+ if not title:
146
+ title = DEFAULT_TITLE
147
+
148
+ description = str(overrides.get("description") or "").strip()
149
+ if not description and install_complete:
150
+ description = str(ws.get("description") or "").strip()
151
+ if not description:
152
+ description = DEFAULT_DESCRIPTION
153
+
154
+ tagline = str(ws.get("tagline") or "").strip() if install_complete else ""
155
+
156
+ theme_color = str(overrides.get("theme_color") or "").strip() or DEFAULT_THEME_COLOR
157
+
158
+ og_path = _branding_asset_path("og")
159
+ if og_path.is_file():
160
+ og_image = _browser_asset_url(app_base_url, f"/api/public/branding/og{og_path.suffix}")
161
+ elif install_complete:
162
+ logo = str(ws.get("avatar_url") or "").strip()
163
+ if logo and normalize_logo:
164
+ logo = normalize_logo(logo)
165
+ og_image = _browser_asset_url(app_base_url, logo) if logo else _browser_asset_url(app_base_url, DEFAULT_OG_IMAGE)
166
+ else:
167
+ og_image = _browser_asset_url(app_base_url, DEFAULT_OG_IMAGE)
168
+
169
+ fav_path = _branding_asset_path("favicon")
170
+ if fav_path.is_file():
171
+ favicon = _browser_asset_url(app_base_url, f"/api/public/branding/favicon{fav_path.suffix}")
172
+ else:
173
+ favicon = _browser_asset_url(app_base_url, DEFAULT_FAVICON)
174
+
175
+ short_name = title if len(title) <= 16 else title[:15].rstrip() + "…"
176
+ canonical = app_base_url.rstrip("/") + "/" if app_base_url else "/"
177
+
178
+ return {
179
+ "ok": True,
180
+ "install_complete": bool(install_complete),
181
+ "title": title,
182
+ "short_name": short_name,
183
+ "description": description,
184
+ "tagline": tagline,
185
+ "theme_color": theme_color,
186
+ "og_image": og_image,
187
+ "favicon": favicon,
188
+ "canonical_url": canonical,
189
+ "manifest_url": _browser_asset_url(app_base_url, "/manifest.webmanifest"),
190
+ "source": {
191
+ "title": "stack" if overrides.get("title") else ("workspace" if install_complete and ws.get("display_name") else "default"),
192
+ "description": "stack" if overrides.get("description") else ("workspace" if install_complete and ws.get("description") else "default"),
193
+ "og_image": "upload" if og_path.is_file() else ("workspace_logo" if install_complete and ws.get("avatar_url") else "default"),
194
+ "favicon": "upload" if fav_path.is_file() else "default",
195
+ },
196
+ }
197
+
198
+
199
+ def manifest_payload(meta: dict[str, Any]) -> dict[str, Any]:
200
+ icons = []
201
+ favicon = str(meta.get("favicon") or "").strip()
202
+ if favicon:
203
+ icons.append(
204
+ {
205
+ "src": favicon,
206
+ "sizes": "any",
207
+ "type": "image/svg+xml" if favicon.endswith(".svg") else "image/png",
208
+ "purpose": "any",
209
+ },
210
+ )
211
+ return {
212
+ "name": meta.get("title") or DEFAULT_TITLE,
213
+ "short_name": meta.get("short_name") or DEFAULT_SHORT_NAME,
214
+ "description": meta.get("description") or DEFAULT_DESCRIPTION,
215
+ "start_url": "./",
216
+ "display": "standalone",
217
+ "background_color": meta.get("theme_color") or DEFAULT_THEME_COLOR,
218
+ "theme_color": meta.get("theme_color") or DEFAULT_THEME_COLOR,
219
+ "orientation": "any",
220
+ "icons": icons or [{"src": "./favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any"}],
221
+ }
222
+
223
+
224
+ def link_preview_html(meta: dict[str, Any]) -> str:
225
+ title = _html_escape(str(meta.get("title") or DEFAULT_TITLE))
226
+ description = _html_escape(str(meta.get("description") or DEFAULT_DESCRIPTION))
227
+ canonical = _html_escape(str(meta.get("canonical_url") or "/"))
228
+ og_image = _html_escape(str(meta.get("og_image") or ""))
229
+ theme = _html_escape(str(meta.get("theme_color") or DEFAULT_THEME_COLOR))
230
+ favicon = _html_escape(str(meta.get("favicon") or DEFAULT_FAVICON))
231
+ return f"""<!doctype html>
232
+ <html lang="en">
233
+ <head>
234
+ <meta charset="utf-8" />
235
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
236
+ <title>{title}</title>
237
+ <meta name="description" content="{description}" />
238
+ <meta name="theme-color" content="{theme}" />
239
+ <link rel="icon" href="{favicon}" />
240
+ <meta property="og:type" content="website" />
241
+ <meta property="og:site_name" content="{title}" />
242
+ <meta property="og:title" content="{title}" />
243
+ <meta property="og:description" content="{description}" />
244
+ <meta property="og:url" content="{canonical}" />
245
+ <meta property="og:image" content="{og_image}" />
246
+ <meta name="twitter:card" content="summary_large_image" />
247
+ <meta name="twitter:title" content="{title}" />
248
+ <meta name="twitter:description" content="{description}" />
249
+ <meta name="twitter:image" content="{og_image}" />
250
+ <meta http-equiv="refresh" content="0;url={canonical}" />
251
+ </head>
252
+ <body><p><a href="{canonical}">{title}</a></p></body>
253
+ </html>
254
+ """
255
+
256
+
257
+ def branding_asset_bytes(kind: str) -> tuple[bytes, str] | None:
258
+ path = _branding_asset_path(kind)
259
+ if not path.is_file():
260
+ return None
261
+ ctype = _MIME_BY_SUFFIX.get(path.suffix.lower(), "application/octet-stream")
262
+ return path.read_bytes(), ctype
263
+
264
+
265
+ def _html_escape(value: str) -> str:
266
+ return (
267
+ value.replace("&", "&amp;")
268
+ .replace("<", "&lt;")
269
+ .replace(">", "&gt;")
270
+ .replace('"', "&quot;")
271
+ )