create-workframe 0.1.0 → 0.1.2

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 (198) hide show
  1. package/LICENSE +201 -201
  2. package/NOTICE +12 -12
  3. package/README.md +8 -92
  4. package/SECURITY.md +38 -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/bundle-workframe-ui.mjs +3 -3
  28. package/scripts/ensure-compose-host-paths.mjs +51 -51
  29. package/scripts/lib/install-identity.mjs +212 -212
  30. package/scripts/set-compose-public-url.mjs +92 -92
  31. package/scripts/sync-canonical-to-package.mjs +27 -9
  32. package/shared/WORKFRAME_AGENT_LIBRARY.md +17 -17
  33. package/shared/WORKFRAME_AGENT_OPERATIONS.md +15 -15
  34. package/shared/WORKFRAME_AGENT_PACKS.json +18 -18
  35. package/shared/WORKFRAME_AGENT_PACKS.yaml +8 -8
  36. package/shared/WORKFRAME_SKILL_CURATION.md +4 -4
  37. package/workframe-api/README.md +26 -28
  38. package/workframe-api/action_proxy.py +131 -131
  39. package/workframe-api/auth_rate_limit.py +49 -49
  40. package/workframe-api/credential_vault.py +445 -445
  41. package/workframe-api/data/avatar-catalog.json +41 -41
  42. package/workframe-api/email_sender.py +220 -220
  43. package/workframe-api/google_auth.py +90 -90
  44. package/workframe-api/install_api.py +359 -359
  45. package/workframe-api/internal_proxy_auth.py +150 -150
  46. package/workframe-api/llm_proxy.py +277 -277
  47. package/workframe-api/oidc_jwt.py +108 -108
  48. package/workframe-api/package.json +12 -13
  49. package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
  50. package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
  51. package/workframe-api/requirements.txt +2 -2
  52. package/workframe-api/site_meta.py +271 -271
  53. package/workframe-api/stack_config.py +427 -427
  54. package/workframe-api/time-bind-chat.py +99 -99
  55. package/workframe-api/turn_credentials.py +226 -226
  56. package/workframe-api/updates.py +417 -417
  57. package/workframe-api/vault_kek.py +159 -159
  58. package/workframe-api/zk_auth.py +633 -633
  59. package/workframe-supervisor/Dockerfile +11 -11
  60. package/workframe-supervisor/server.py +787 -787
  61. package/workframe-ui/docker/nginx.conf +85 -85
  62. package/workframe-ui/public/assets/{arc-CBDYvkAF.js → arc-COAT3laO.js} +1 -1
  63. package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +1 -0
  64. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-XnBRKeW0.js → architectureDiagram-3BPJPVTR-BFjWV24l.js} +1 -1
  65. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-VYHUfVhd.js → blockDiagram-GPEHLZMM-DSQLPfrj.js} +1 -1
  66. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BTjUcJpm.js → c4Diagram-AAUBKEIU-DKEHv1t2.js} +1 -1
  67. package/workframe-ui/public/assets/channel-g7r_RGaY.js +1 -0
  68. package/workframe-ui/public/assets/{chunk-2J33WTMH-w7uu7R-b.js → chunk-2J33WTMH-DHZg-DUi.js} +1 -1
  69. package/workframe-ui/public/assets/{chunk-3OPIFGDE-Cb9LtnDX.js → chunk-3OPIFGDE-BB-OYTfp.js} +1 -1
  70. package/workframe-ui/public/assets/{chunk-4BX2VUAB-DiQ-qCwH.js → chunk-4BX2VUAB-C93q0YIm.js} +1 -1
  71. package/workframe-ui/public/assets/{chunk-55IACEB6-C-mLFr7z.js → chunk-55IACEB6-MAYniqik.js} +1 -1
  72. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DOesfiCI.js → chunk-5ZQYHXKU-ChgN6YJs.js} +1 -1
  73. package/workframe-ui/public/assets/{chunk-727SXJPM-BJ3oBZuz.js → chunk-727SXJPM-B_FYwdAv.js} +1 -1
  74. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CCA6xpGs.js → chunk-AQP2D5EJ-1_Hw_h1A.js} +1 -1
  75. package/workframe-ui/public/assets/{chunk-BSJP7CBP-a0cMNFb2.js → chunk-BSJP7CBP-CFiDQ1Rv.js} +1 -1
  76. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-kuqN8EIY.js → chunk-CSCIHK7Q-DZ9UMTlB.js} +1 -1
  77. package/workframe-ui/public/assets/{chunk-FMBD7UC4-DyPgYHCg.js → chunk-FMBD7UC4-DlMlyFgw.js} +1 -1
  78. package/workframe-ui/public/assets/{chunk-KSCS5N6A-CdUuvR0V.js → chunk-KSCS5N6A-DHXtQ_Hf.js} +1 -1
  79. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-Dq9NoWmK.js → chunk-L5ZTLDWV-CuQzg-QG.js} +1 -1
  80. package/workframe-ui/public/assets/{chunk-LZXEDZCA-p74rddlO.js → chunk-LZXEDZCA-BHzjzCGg.js} +2 -2
  81. package/workframe-ui/public/assets/{chunk-ND2GUHAM-DBD2u1Gz.js → chunk-ND2GUHAM-DHXx05n2.js} +1 -1
  82. package/workframe-ui/public/assets/{chunk-NZK2D7GU-BeIeYFnd.js → chunk-NZK2D7GU-CV5pmDM_.js} +1 -1
  83. package/workframe-ui/public/assets/{chunk-O5CBEL6O-ClHc56ib.js → chunk-O5CBEL6O-6tkCHxsV.js} +1 -1
  84. package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +1 -0
  85. package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +1 -0
  86. package/workframe-ui/public/assets/{chunk-XPW4576I-EFr8R_1p.js → chunk-XPW4576I-BClwIiCp.js} +1 -1
  87. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +1 -0
  88. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +1 -0
  89. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-C7aPBODd.js → cose-bilkent-S5V4N54A-DOrGV6DQ.js} +1 -1
  90. package/workframe-ui/public/assets/{dagre-BM42HDAG-BdU1Rv-H.js → dagre-BM42HDAG-DXTPvJkX.js} +1 -1
  91. package/workframe-ui/public/assets/{diagram-2AECGRRQ-DWowSo85.js → diagram-2AECGRRQ-xX_v-pbf.js} +1 -1
  92. package/workframe-ui/public/assets/{diagram-5GNKFQAL-MnxBbceO.js → diagram-5GNKFQAL-Cd2pXbBe.js} +1 -1
  93. package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQaLRXFf.js → diagram-KO2AKTUF-Df3XvUtk.js} +1 -1
  94. package/workframe-ui/public/assets/{diagram-LMA3HP47-CQaBud9k.js → diagram-LMA3HP47-CsijIPaD.js} +1 -1
  95. package/workframe-ui/public/assets/{diagram-OG6HWLK6-D8bAXbY9.js → diagram-OG6HWLK6-aq5fmfHd.js} +1 -1
  96. package/workframe-ui/public/assets/{dist-DGpTLHr_.js → dist-D1c0mkbB.js} +1 -1
  97. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-1E-xSvBK.js → erDiagram-TEJ5UH35-DnFysVRY.js} +1 -1
  98. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +1 -0
  99. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CgOVD5hu.js → flowDiagram-I6XJVG4X-C6Ebi3su.js} +1 -1
  100. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-JFYAIauo.js → ganttDiagram-6RSMTGT7-BQXQtUpa.js} +1 -1
  101. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-B9REenIl.js → gitGraph-WXDBUCRP-Dt0zIs_M.js} +1 -1
  102. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BQ7NcMSn.js → gitGraphDiagram-PVQCEYII-BF8gHzRn.js} +1 -1
  103. package/workframe-ui/public/assets/index-DpoUZAxh.css +1 -0
  104. package/workframe-ui/public/assets/{index-Dnw6vjqb.js → index-lRpzpNPT.js} +2 -2
  105. package/workframe-ui/public/assets/{info-J43DQDTF-CL6-eTjH.js → info-J43DQDTF-CSmszQJT.js} +1 -1
  106. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-LJTODW4W.js → infoDiagram-5YYISTIA-CVTKGW6p.js} +1 -1
  107. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-bchrQVuo.js → ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js} +1 -1
  108. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DkrvYuxP.js → journeyDiagram-JHISSGLW-r3wD68_T.js} +1 -1
  109. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DFRbj0IG.js → kanban-definition-UN3LZRKU-Il8VglqN.js} +1 -1
  110. package/workframe-ui/public/assets/{line-Vd48P7-O.js → line-oyjpfz2A.js} +1 -1
  111. package/workframe-ui/public/assets/{linear-Ckizh2G7.js → linear-Cf7p5tVp.js} +1 -1
  112. package/workframe-ui/public/assets/{mermaid-parser.core-Bkimsnqj.js → mermaid-parser.core-YmbZ-AfY.js} +2 -2
  113. package/workframe-ui/public/assets/{mermaid.core-x0TvVuPo.js → mermaid.core-BFdCAqCo.js} +3 -3
  114. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-6ykAFPEz.js → mindmap-definition-RKZ34NQL-Cy2iCtEl.js} +1 -1
  115. package/workframe-ui/public/assets/{packet-YPE3B663-Dw3xgMDt.js → packet-YPE3B663-DwOBZL6K.js} +1 -1
  116. package/workframe-ui/public/assets/{pie-LRSECV5Y-DATysawG.js → pie-LRSECV5Y-04PPhnKK.js} +1 -1
  117. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-SJKD1S0S.js → pieDiagram-4H26LBE5-LxIpgHqi.js} +1 -1
  118. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-BrYDZX8q.js → quadrantDiagram-W4KKPZXB-0nBYfYm4.js} +1 -1
  119. package/workframe-ui/public/assets/{radar-GUYGQ44K-BmWYPCds.js → radar-GUYGQ44K-D2-vBqps.js} +1 -1
  120. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DwL9Mc8e.js → requirementDiagram-4Y6WPE33-DbuU0nlu.js} +1 -1
  121. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DYIFsL8h.js → sankeyDiagram-5OEKKPKP-B2hQ6B2x.js} +1 -1
  122. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-0-FPkFk8.js → sequenceDiagram-3UESZ5HK-BBrU30e1.js} +1 -1
  123. package/workframe-ui/public/assets/{src-B_od6b6h.js → src-BJEDmV70.js} +1 -1
  124. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BQCiBk6u.js → stateDiagram-AJRCARHV-7FGO4kkH.js} +1 -1
  125. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +1 -0
  126. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DS3tFcXj.js → timeline-definition-PNZ67QCA-ptDm4rCN.js} +1 -1
  127. package/workframe-ui/public/assets/{treeView-BLDUP644-DSyUCKLY.js → treeView-BLDUP644-CS6Z-0q8.js} +1 -1
  128. package/workframe-ui/public/assets/{treemap-LRROVOQU-CEZaNh5Y.js → treemap-LRROVOQU-DqV4Y2VA.js} +1 -1
  129. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CD-Vc9NF.js → vennDiagram-CIIHVFJN-C0UrZJYt.js} +1 -1
  130. package/workframe-ui/public/assets/{wardley-L42UT6IY-Drq5w1Mc.js → wardley-L42UT6IY-bNDN3_Sa.js} +1 -1
  131. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DouXDJoF.js → wardleyDiagram-YWT4CUSO-jWiJsefM.js} +1 -1
  132. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DDf_Lol5.js → xychartDiagram-2RQKCTM6-Dsh_fLCy.js} +1 -1
  133. package/workframe-ui/public/favicon.svg +7 -7
  134. package/workframe-ui/public/index.html +50 -50
  135. package/workframe-ui/public/workframe-config.json +3 -3
  136. package/scripts/security_audit.py +0 -156
  137. package/scripts/test-scaffold.mjs +0 -390
  138. package/workframe-api/tests/__init__.py +0 -0
  139. package/workframe-api/tests/db_setup.py +0 -13
  140. package/workframe-api/tests/test_admin_updates_gated.py +0 -30
  141. package/workframe-api/tests/test_agent_dm_bootstrap.py +0 -196
  142. package/workframe-api/tests/test_agent_profile_sync.py +0 -76
  143. package/workframe-api/tests/test_auth_email.py +0 -222
  144. package/workframe-api/tests/test_auth_hole_fix_selfcheck.py +0 -99
  145. package/workframe-api/tests/test_auth_rate_limit.py +0 -19
  146. package/workframe-api/tests/test_avatar_resolve.py +0 -77
  147. package/workframe-api/tests/test_child_soul_template.py +0 -71
  148. package/workframe-api/tests/test_credential_canary.py +0 -135
  149. package/workframe-api/tests/test_credential_isolation.py +0 -448
  150. package/workframe-api/tests/test_credential_resolution.py +0 -206
  151. package/workframe-api/tests/test_device_oauth.py +0 -108
  152. package/workframe-api/tests/test_doctor_repair.py +0 -103
  153. package/workframe-api/tests/test_ensure_profile_api.py +0 -77
  154. package/workframe-api/tests/test_gateway_compose_security.py +0 -136
  155. package/workframe-api/tests/test_install_secure_host.py +0 -39
  156. package/workframe-api/tests/test_internal_proxy_auth.py +0 -125
  157. package/workframe-api/tests/test_invite_runtime_bootstrap.py +0 -72
  158. package/workframe-api/tests/test_kanban_delegation.py +0 -185
  159. package/workframe-api/tests/test_llm_proxy.py +0 -155
  160. package/workframe-api/tests/test_login_access_policy.py +0 -183
  161. package/workframe-api/tests/test_mvp_model_bootstrap.py +0 -75
  162. package/workframe-api/tests/test_onboarding_bootstrap.py +0 -248
  163. package/workframe-api/tests/test_platform_auth.py +0 -47
  164. package/workframe-api/tests/test_profile_config_path.py +0 -56
  165. package/workframe-api/tests/test_profile_config_yaml_repair.py +0 -63
  166. package/workframe-api/tests/test_profile_create.py +0 -72
  167. package/workframe-api/tests/test_profile_identity_overlay.py +0 -61
  168. package/workframe-api/tests/test_profile_install_health.py +0 -45
  169. package/workframe-api/tests/test_profile_secret_policy.py +0 -57
  170. package/workframe-api/tests/test_profile_workspace_cwd.py +0 -34
  171. package/workframe-api/tests/test_provider_bootstrap.py +0 -75
  172. package/workframe-api/tests/test_provider_connect.py +0 -54
  173. package/workframe-api/tests/test_room_crud.py +0 -192
  174. package/workframe-api/tests/test_room_tenancy.py +0 -701
  175. package/workframe-api/tests/test_runtime_identity_backfill.py +0 -34
  176. package/workframe-api/tests/test_site_meta.py +0 -81
  177. package/workframe-api/tests/test_soul_stub.py +0 -42
  178. package/workframe-api/tests/test_space_member_sync.py +0 -99
  179. package/workframe-api/tests/test_stripe_stack_config.py +0 -37
  180. package/workframe-api/tests/test_supervisor_lifecycle.py +0 -52
  181. package/workframe-api/tests/test_turn_credential_vault.py +0 -125
  182. package/workframe-api/tests/test_updates.py +0 -176
  183. package/workframe-api/tests/test_user_cohort.py +0 -113
  184. package/workframe-api/tests/test_vault_envelope.py +0 -110
  185. package/workframe-api/tests/test_workspace_members.py +0 -183
  186. package/workframe-api/tests/test_workspace_messaging_sync.py +0 -125
  187. package/workframe-api/tests/test_workspace_provider_list.py +0 -57
  188. package/workframe-supervisor/tests/test_exec_guard.py +0 -42
  189. package/workframe-supervisor/tests/test_server_import.py +0 -21
  190. package/workframe-ui/public/assets/architecture-7EHR7CIX-CtbQKTuT.js +0 -1
  191. package/workframe-ui/public/assets/channel-Dy4Z4-jn.js +0 -1
  192. package/workframe-ui/public/assets/chunk-QZHKN3VN-CtBEchFK.js +0 -1
  193. package/workframe-ui/public/assets/chunk-WU5MYG2G-B9pBtriN.js +0 -1
  194. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BMAEA8jI.js +0 -1
  195. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BMAEA8jI.js +0 -1
  196. package/workframe-ui/public/assets/eventmodeling-FCH6USID-D75cstNT.js +0 -1
  197. package/workframe-ui/public/assets/index-DpAGxump.css +0 -1
  198. 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
+ )