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,359 +1,359 @@
1
- """Install / stack-setup API helpers."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- import re
7
- import sqlite3
8
- import urllib.error
9
- import urllib.parse
10
- import urllib.request
11
- from pathlib import Path
12
- from typing import Any
13
-
14
- import stack_config
15
- from email_sender import send_email_with_config
16
-
17
- HERMES_DATA = Path(os.environ.get("HERMES_DATA", "/opt/data"))
18
- NATIVE_PROFILE = os.environ.get("WORKFRAME_NATIVE_PROFILE", "workframe-agent").strip() or "workframe-agent"
19
-
20
-
21
- def _user_count(db_path: str) -> int:
22
- try:
23
- conn = sqlite3.connect(db_path, timeout=2.0)
24
- row = conn.execute("SELECT COUNT(*) FROM users").fetchone()
25
- conn.close()
26
- return int(row[0]) if row else 0
27
- except (sqlite3.Error, OSError):
28
- return 0
29
-
30
-
31
- def install_window_open(db_path: str) -> bool:
32
- """Open until operator marks install complete — users may exist mid-onboarding."""
33
- del db_path # ponytail: reserved for future per-install DB path checks
34
- return not bool(stack_config.get_stack_config().get("install_complete"))
35
-
36
-
37
- def _hermes_native_present() -> bool:
38
- for slug in (NATIVE_PROFILE, "workframe-agent"):
39
- if not slug:
40
- continue
41
- prof_dir = HERMES_DATA / "profiles" / slug
42
- if (prof_dir / "config.yaml").is_file() or (prof_dir / "profile.yaml").is_file():
43
- return True
44
- return False
45
-
46
-
47
- def _setup_complete(db_path: str) -> bool:
48
- try:
49
- conn = sqlite3.connect(db_path, timeout=2.0)
50
- row = conn.execute(
51
- "SELECT COUNT(*) FROM agent_profiles WHERE deleted_at IS NULL"
52
- ).fetchone()
53
- conn.close()
54
- return bool(row and row[0] > 0)
55
- except (sqlite3.Error, OSError):
56
- return _hermes_native_present()
57
-
58
-
59
- def install_status_payload(
60
- deployment_mode: str,
61
- secure_mode: bool,
62
- dev_unsafe: bool,
63
- db_path: str,
64
- ) -> dict[str, Any]:
65
- hermes = _hermes_native_present()
66
- setup = _setup_complete(db_path)
67
- smtp_ok = stack_config.smtp_configured()
68
- return {
69
- "ok": True,
70
- "phase": "ready" if hermes and setup else ("hermes" if not hermes else "workspace"),
71
- "hermes_present": hermes,
72
- "setup_complete": setup,
73
- "api_ok": True,
74
- "deployment_mode": deployment_mode,
75
- "mode": "dev_unsafe" if dev_unsafe else ("secure" if secure_mode else "dev_unsafe"),
76
- "smtp_configured": smtp_ok,
77
- "install_complete": bool(stack_config.get_stack_config().get("install_complete")),
78
- "install_window_open": install_window_open(db_path),
79
- "native_profile": NATIVE_PROFILE,
80
- }
81
-
82
-
83
- def smtp_test_send(to_email: str) -> dict[str, Any]:
84
- to_email = str(to_email or "").strip().lower()
85
- if not to_email or "@" not in to_email:
86
- raise ValueError("valid email required")
87
- cfg = stack_config.resolved_smtp()
88
- if not cfg.get("host"):
89
- raise ValueError("SMTP is not configured yet")
90
- subject = "Workframe test email"
91
- text = "If you received this, your Workframe SMTP settings are working."
92
- html = "<p>If you received this, your Workframe SMTP settings are working.</p>"
93
- send_email_with_config(to_email, subject, text, html, cfg)
94
- stack_config.patch_stack_config({"smtp": {"admin_email": to_email}})
95
- stack_config.mark_smtp_tested()
96
- return {"ok": True, "email_sent": True, "to": to_email}
97
-
98
-
99
- def _normalize_app_base_url(url: str) -> str:
100
- """Ensure app_base_url has a scheme for health checks and OAuth callbacks."""
101
- u = str(url or "").strip().rstrip("/")
102
- if not u:
103
- return ""
104
- if not u.lower().startswith(("http://", "https://")):
105
- u = f"https://{u}"
106
- return u
107
-
108
-
109
- def _hostname_only(url: str) -> str:
110
- u = _normalize_app_base_url(url)
111
- if not u:
112
- return ""
113
- try:
114
- return urllib.parse.urlparse(u).hostname or ""
115
- except Exception:
116
- return str(url or "").strip().lower()
117
-
118
-
119
- def dns_record_name(hostname: str) -> str:
120
- host = _hostname_only(hostname) or str(hostname or "").strip().lower().rstrip(".")
121
- if not host:
122
- return "@"
123
- parts = host.split(".")
124
- if len(parts) <= 2:
125
- return "@"
126
- return parts[0]
127
-
128
-
129
- def apex_domain(hostname: str) -> str:
130
- host = _hostname_only(hostname) or str(hostname or "").strip().lower().rstrip(".")
131
- if not host:
132
- return ""
133
- parts = host.split(".")
134
- if len(parts) <= 2:
135
- return host
136
- return ".".join(parts[-2:])
137
-
138
-
139
- def detect_public_ipv4() -> str | None:
140
- for url in ("https://api4.ipify.org", "https://ifconfig.me/ip"):
141
- try:
142
- with urllib.request.urlopen(url, timeout=4) as resp:
143
- ip = resp.read().decode("utf-8", errors="replace").strip()
144
- if ip and "." in ip:
145
- return ip
146
- except (urllib.error.URLError, OSError, TimeoutError, ValueError):
147
- continue
148
- return None
149
-
150
-
151
- def publish_hints_payload(public_url: str) -> dict[str, Any]:
152
- host = _hostname_only(public_url)
153
- apex = apex_domain(host)
154
- ui_port = int(os.environ.get("WORKFRAME_UI_PORT", "18644") or "18644")
155
- public_ip = detect_public_ipv4()
156
- project_root = (
157
- os.environ.get("WORKFRAME_HOST_PROJECT_ROOT", "").strip()
158
- or "/opt/workframe/ProjectX"
159
- )
160
- setup_command = (
161
- f"sudo bash {project_root}/scripts/workframe/setup-public-https.sh {host} {ui_port}"
162
- if host
163
- else ""
164
- )
165
- dns_name = dns_record_name(host)
166
- return {
167
- "ok": True,
168
- "hostname": host,
169
- "apex_domain": apex,
170
- "public_ipv4": public_ip,
171
- "ui_port": ui_port,
172
- "project_root": project_root,
173
- "health_url": f"https://{host}/api/health" if host else "",
174
- "dns": {
175
- "type": "A",
176
- "name": dns_name,
177
- "value": public_ip or "",
178
- "ttl": "600",
179
- },
180
- "dns_cname": {
181
- "type": "CNAME",
182
- "name": dns_name,
183
- "value": apex,
184
- "hint": "Optional: point the subdomain at your apex hostname instead of an IP. CNAME cannot target an IP address.",
185
- }
186
- if apex and dns_name != "@"
187
- else None,
188
- "registrar_links": [
189
- {
190
- "label": "GoDaddy DNS",
191
- "url": f"https://dcc.godaddy.com/manage/{apex}/dns" if apex else "https://dcc.godaddy.com/control/portfolio",
192
- },
193
- {
194
- "label": "Namecheap DNS",
195
- "url": f"https://ap.www.namecheap.com/Domains/DomainControlPanel/{apex}/advancedns"
196
- if apex
197
- else "https://www.namecheap.com/domains/",
198
- },
199
- {"label": "Cloudflare", "url": "https://dash.cloudflare.com"},
200
- ],
201
- "setup_command": setup_command,
202
- }
203
-
204
-
205
- def _loopback_hostname(hostname: str) -> bool:
206
- h = (hostname or "").strip().lower().rstrip(".")
207
- return h in ("127.0.0.1", "localhost", "::1") or h.endswith(".localhost")
208
-
209
-
210
- def _fetch_health(url: str, timeout: float = 8) -> dict[str, Any]:
211
- with urllib.request.urlopen(url, timeout=timeout) as resp:
212
- body = resp.read().decode("utf-8", errors="replace")
213
- ok = resp.status == 200 and '"ok"' in body
214
- return {"ok": ok, "status": resp.status, "checked_url": url}
215
-
216
-
217
- def _local_stack_health() -> dict[str, Any]:
218
- ui_port = int(os.environ.get("WORKFRAME_UI_PORT", "18644") or "18644")
219
- app_base = str(
220
- stack_config.get_stack_config().get("app_base_url")
221
- or os.environ.get("APP_BASE_URL", "")
222
- or f"http://127.0.0.1:{ui_port}",
223
- )
224
- host_header = _hostname_only(app_base) or "127.0.0.1"
225
- req = urllib.request.Request(
226
- "http://workframe-ui/api/health",
227
- headers={"Host": f"{host_header}:{ui_port}"},
228
- )
229
- try:
230
- with urllib.request.urlopen(req, timeout=8) as resp:
231
- body = resp.read().decode("utf-8", errors="replace")
232
- ok = resp.status == 200 and '"ok"' in body
233
- return {"ok": ok, "status": resp.status, "local_ok": ok, "checked_url": req.full_url}
234
- except Exception as exc:
235
- return {"local_ok": False, "error": str(exc)}
236
-
237
-
238
- def _url_test_hint(exc: Exception, *, local: dict[str, Any]) -> str:
239
- msg = str(exc).lower()
240
- ui_port = int(os.environ.get("WORKFRAME_UI_PORT", "18644") or "18644")
241
- if "connection refused" in msg or "errno 111" in msg:
242
- base = (
243
- f"Nothing is listening on HTTPS for that host yet. Use step 2 (Set up HTTPS on this server), "
244
- f"point DNS here, and open ports 80/443. Caddy proxies to 127.0.0.1:{ui_port}."
245
- )
246
- if local.get("local_ok"):
247
- return f"Workframe is healthy on this server. {base}"
248
- return base
249
- if "timed out" in msg or "timeout" in msg:
250
- return (
251
- "Timed out reaching that URL — DNS may not point to this server yet, or HTTPS is not ready. "
252
- "Add the A record, run Set up HTTPS, wait a minute, then retry."
253
- )
254
- if local.get("local_ok"):
255
- return "Workframe is running on this server; the public URL is not reachable from here yet."
256
- return "Check DNS, HTTPS (Caddy), and that the domain matches this server."
257
-
258
-
259
- def url_test(app_base_url: str) -> dict[str, Any]:
260
- url = _normalize_app_base_url(app_base_url)
261
- if not url:
262
- raise ValueError("app_base_url required")
263
- host = _hostname_only(url)
264
-
265
- if _loopback_hostname(host):
266
- try:
267
- local = _local_stack_health()
268
- if local.get("local_ok"):
269
- return {
270
- "ok": True,
271
- "status": local.get("status"),
272
- "url": url,
273
- "checked_url": local.get("checked_url"),
274
- "hint": "Loopback URL — verified via the UI proxy on this stack.",
275
- }
276
- return {
277
- "ok": False,
278
- "url": url,
279
- "error": str(local.get("error") or "local health check failed"),
280
- "hint": "Is the Workframe UI container running?",
281
- }
282
- except Exception as exc:
283
- return {
284
- "ok": False,
285
- "url": url,
286
- "error": str(exc),
287
- "hint": "Is the Workframe UI container running?",
288
- }
289
-
290
- health_url = f"{url.rstrip('/')}/api/health"
291
- local = _local_stack_health()
292
- try:
293
- return {**_fetch_health(health_url), "url": health_url}
294
- except urllib.error.HTTPError as exc:
295
- return {
296
- "ok": False,
297
- "status": exc.code,
298
- "url": health_url,
299
- "error": str(exc),
300
- "hint": _url_test_hint(exc, local=local),
301
- "local_ok": bool(local.get("local_ok")),
302
- }
303
- except Exception as exc:
304
- return {
305
- "ok": False,
306
- "url": health_url,
307
- "error": str(exc),
308
- "hint": _url_test_hint(exc, local=local),
309
- "local_ok": bool(local.get("local_ok")),
310
- }
311
-
312
-
313
- def smtp_error_hint(exc: Exception) -> str:
314
- msg = str(exc).lower()
315
- if "smtp login failed" in msg or "535" in msg or "badcredentials" in msg:
316
- return "Check SMTP username and app password. For Gmail, use an App Password—not your normal password."
317
- if "smtp password is required" in msg:
318
- return "Enter the SMTP app password. Compose uses SMTP_PASS; onboarding saves it in stack config."
319
- if "rejected from address" in msg:
320
- return (
321
- "Gmail rejected that From address for this login. Leave From blank to send as your login email, "
322
- "or add the address under Gmail Settings → Accounts → Send mail as."
323
- )
324
- if "530" in msg and "authentication required" in msg:
325
- return "SMTP authentication did not complete. Check username, app password, and port (465=SSL, 587=STARTTLS)."
326
- if "connect" in msg or "timed out" in msg or "unexpectedly closed" in msg:
327
- return "Check SMTP host and port. Port 465 needs SSL; port 587 uses STARTTLS."
328
- if "certificate" in msg or "ssl" in msg:
329
- return "Try toggling TLS/SSL settings to match your provider."
330
- return "Double-check host, port, username, password, and From address, then try again."
331
-
332
-
333
- def normalize_setup_https(host: str, port: int | str | None = None) -> tuple[str, int]:
334
- name = _hostname_only(host) or str(host or "").strip().lower()
335
- if not name or not re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?", name, re.IGNORECASE):
336
- raise ValueError("valid hostname required")
337
- ui_port = int(port or os.environ.get("WORKFRAME_UI_PORT", "18644") or "18644")
338
- return name, ui_port
339
-
340
-
341
- if __name__ == "__main__":
342
- assert dns_record_name("dev.alanborger.com") == "dev"
343
- assert dns_record_name("alanborger.com") == "@"
344
- assert apex_domain("dev.alanborger.com") == "alanborger.com"
345
- assert _loopback_hostname("127.0.0.1")
346
- assert _loopback_hostname("localhost")
347
- assert not _loopback_hostname("dev.example.com")
348
- import tempfile
349
- from pathlib import Path
350
-
351
- td = Path(tempfile.mkdtemp())
352
- os.environ["WORKFRAME_API_DATA_DIR"] = str(td)
353
- stack_config.patch_stack_config({"smtp": {"host": "smtp.test", "user": "a", "password": "b"}})
354
- assert not stack_config.smtp_tested()
355
- stack_config.mark_smtp_tested()
356
- assert stack_config.smtp_tested()
357
- stack_config.patch_stack_config({"smtp": {"host": "smtp.other"}})
358
- assert not stack_config.smtp_tested()
359
- print("install_api publish hints ok")
1
+ """Install / stack-setup API helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import sqlite3
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import stack_config
15
+ from email_sender import send_email_with_config
16
+
17
+ HERMES_DATA = Path(os.environ.get("HERMES_DATA", "/opt/data"))
18
+ NATIVE_PROFILE = os.environ.get("WORKFRAME_NATIVE_PROFILE", "workframe-agent").strip() or "workframe-agent"
19
+
20
+
21
+ def _user_count(db_path: str) -> int:
22
+ try:
23
+ conn = sqlite3.connect(db_path, timeout=2.0)
24
+ row = conn.execute("SELECT COUNT(*) FROM users").fetchone()
25
+ conn.close()
26
+ return int(row[0]) if row else 0
27
+ except (sqlite3.Error, OSError):
28
+ return 0
29
+
30
+
31
+ def install_window_open(db_path: str) -> bool:
32
+ """Open until operator marks install complete — users may exist mid-onboarding."""
33
+ del db_path # ponytail: reserved for future per-install DB path checks
34
+ return not bool(stack_config.get_stack_config().get("install_complete"))
35
+
36
+
37
+ def _hermes_native_present() -> bool:
38
+ for slug in (NATIVE_PROFILE, "workframe-agent"):
39
+ if not slug:
40
+ continue
41
+ prof_dir = HERMES_DATA / "profiles" / slug
42
+ if (prof_dir / "config.yaml").is_file() or (prof_dir / "profile.yaml").is_file():
43
+ return True
44
+ return False
45
+
46
+
47
+ def _setup_complete(db_path: str) -> bool:
48
+ try:
49
+ conn = sqlite3.connect(db_path, timeout=2.0)
50
+ row = conn.execute(
51
+ "SELECT COUNT(*) FROM agent_profiles WHERE deleted_at IS NULL"
52
+ ).fetchone()
53
+ conn.close()
54
+ return bool(row and row[0] > 0)
55
+ except (sqlite3.Error, OSError):
56
+ return _hermes_native_present()
57
+
58
+
59
+ def install_status_payload(
60
+ deployment_mode: str,
61
+ secure_mode: bool,
62
+ dev_unsafe: bool,
63
+ db_path: str,
64
+ ) -> dict[str, Any]:
65
+ hermes = _hermes_native_present()
66
+ setup = _setup_complete(db_path)
67
+ smtp_ok = stack_config.smtp_configured()
68
+ return {
69
+ "ok": True,
70
+ "phase": "ready" if hermes and setup else ("hermes" if not hermes else "workspace"),
71
+ "hermes_present": hermes,
72
+ "setup_complete": setup,
73
+ "api_ok": True,
74
+ "deployment_mode": deployment_mode,
75
+ "mode": "dev_unsafe" if dev_unsafe else ("secure" if secure_mode else "dev_unsafe"),
76
+ "smtp_configured": smtp_ok,
77
+ "install_complete": bool(stack_config.get_stack_config().get("install_complete")),
78
+ "install_window_open": install_window_open(db_path),
79
+ "native_profile": NATIVE_PROFILE,
80
+ }
81
+
82
+
83
+ def smtp_test_send(to_email: str) -> dict[str, Any]:
84
+ to_email = str(to_email or "").strip().lower()
85
+ if not to_email or "@" not in to_email:
86
+ raise ValueError("valid email required")
87
+ cfg = stack_config.resolved_smtp()
88
+ if not cfg.get("host"):
89
+ raise ValueError("SMTP is not configured yet")
90
+ subject = "Workframe test email"
91
+ text = "If you received this, your Workframe SMTP settings are working."
92
+ html = "<p>If you received this, your Workframe SMTP settings are working.</p>"
93
+ send_email_with_config(to_email, subject, text, html, cfg)
94
+ stack_config.patch_stack_config({"smtp": {"admin_email": to_email}})
95
+ stack_config.mark_smtp_tested()
96
+ return {"ok": True, "email_sent": True, "to": to_email}
97
+
98
+
99
+ def _normalize_app_base_url(url: str) -> str:
100
+ """Ensure app_base_url has a scheme for health checks and OAuth callbacks."""
101
+ u = str(url or "").strip().rstrip("/")
102
+ if not u:
103
+ return ""
104
+ if not u.lower().startswith(("http://", "https://")):
105
+ u = f"https://{u}"
106
+ return u
107
+
108
+
109
+ def _hostname_only(url: str) -> str:
110
+ u = _normalize_app_base_url(url)
111
+ if not u:
112
+ return ""
113
+ try:
114
+ return urllib.parse.urlparse(u).hostname or ""
115
+ except Exception:
116
+ return str(url or "").strip().lower()
117
+
118
+
119
+ def dns_record_name(hostname: str) -> str:
120
+ host = _hostname_only(hostname) or str(hostname or "").strip().lower().rstrip(".")
121
+ if not host:
122
+ return "@"
123
+ parts = host.split(".")
124
+ if len(parts) <= 2:
125
+ return "@"
126
+ return parts[0]
127
+
128
+
129
+ def apex_domain(hostname: str) -> str:
130
+ host = _hostname_only(hostname) or str(hostname or "").strip().lower().rstrip(".")
131
+ if not host:
132
+ return ""
133
+ parts = host.split(".")
134
+ if len(parts) <= 2:
135
+ return host
136
+ return ".".join(parts[-2:])
137
+
138
+
139
+ def detect_public_ipv4() -> str | None:
140
+ for url in ("https://api4.ipify.org", "https://ifconfig.me/ip"):
141
+ try:
142
+ with urllib.request.urlopen(url, timeout=4) as resp:
143
+ ip = resp.read().decode("utf-8", errors="replace").strip()
144
+ if ip and "." in ip:
145
+ return ip
146
+ except (urllib.error.URLError, OSError, TimeoutError, ValueError):
147
+ continue
148
+ return None
149
+
150
+
151
+ def publish_hints_payload(public_url: str) -> dict[str, Any]:
152
+ host = _hostname_only(public_url)
153
+ apex = apex_domain(host)
154
+ ui_port = int(os.environ.get("WORKFRAME_UI_PORT", "18644") or "18644")
155
+ public_ip = detect_public_ipv4()
156
+ project_root = (
157
+ os.environ.get("WORKFRAME_HOST_PROJECT_ROOT", "").strip()
158
+ or "/opt/workframe/repo"
159
+ )
160
+ setup_command = (
161
+ f"sudo bash {project_root}/scripts/workframe/setup-public-https.sh {host} {ui_port}"
162
+ if host
163
+ else ""
164
+ )
165
+ dns_name = dns_record_name(host)
166
+ return {
167
+ "ok": True,
168
+ "hostname": host,
169
+ "apex_domain": apex,
170
+ "public_ipv4": public_ip,
171
+ "ui_port": ui_port,
172
+ "project_root": project_root,
173
+ "health_url": f"https://{host}/api/health" if host else "",
174
+ "dns": {
175
+ "type": "A",
176
+ "name": dns_name,
177
+ "value": public_ip or "",
178
+ "ttl": "600",
179
+ },
180
+ "dns_cname": {
181
+ "type": "CNAME",
182
+ "name": dns_name,
183
+ "value": apex,
184
+ "hint": "Optional: point the subdomain at your apex hostname instead of an IP. CNAME cannot target an IP address.",
185
+ }
186
+ if apex and dns_name != "@"
187
+ else None,
188
+ "registrar_links": [
189
+ {
190
+ "label": "GoDaddy DNS",
191
+ "url": f"https://dcc.godaddy.com/manage/{apex}/dns" if apex else "https://dcc.godaddy.com/control/portfolio",
192
+ },
193
+ {
194
+ "label": "Namecheap DNS",
195
+ "url": f"https://ap.www.namecheap.com/Domains/DomainControlPanel/{apex}/advancedns"
196
+ if apex
197
+ else "https://www.namecheap.com/domains/",
198
+ },
199
+ {"label": "Cloudflare", "url": "https://dash.cloudflare.com"},
200
+ ],
201
+ "setup_command": setup_command,
202
+ }
203
+
204
+
205
+ def _loopback_hostname(hostname: str) -> bool:
206
+ h = (hostname or "").strip().lower().rstrip(".")
207
+ return h in ("127.0.0.1", "localhost", "::1") or h.endswith(".localhost")
208
+
209
+
210
+ def _fetch_health(url: str, timeout: float = 8) -> dict[str, Any]:
211
+ with urllib.request.urlopen(url, timeout=timeout) as resp:
212
+ body = resp.read().decode("utf-8", errors="replace")
213
+ ok = resp.status == 200 and '"ok"' in body
214
+ return {"ok": ok, "status": resp.status, "checked_url": url}
215
+
216
+
217
+ def _local_stack_health() -> dict[str, Any]:
218
+ ui_port = int(os.environ.get("WORKFRAME_UI_PORT", "18644") or "18644")
219
+ app_base = str(
220
+ stack_config.get_stack_config().get("app_base_url")
221
+ or os.environ.get("APP_BASE_URL", "")
222
+ or f"http://127.0.0.1:{ui_port}",
223
+ )
224
+ host_header = _hostname_only(app_base) or "127.0.0.1"
225
+ req = urllib.request.Request(
226
+ "http://workframe-ui/api/health",
227
+ headers={"Host": f"{host_header}:{ui_port}"},
228
+ )
229
+ try:
230
+ with urllib.request.urlopen(req, timeout=8) as resp:
231
+ body = resp.read().decode("utf-8", errors="replace")
232
+ ok = resp.status == 200 and '"ok"' in body
233
+ return {"ok": ok, "status": resp.status, "local_ok": ok, "checked_url": req.full_url}
234
+ except Exception as exc:
235
+ return {"local_ok": False, "error": str(exc)}
236
+
237
+
238
+ def _url_test_hint(exc: Exception, *, local: dict[str, Any]) -> str:
239
+ msg = str(exc).lower()
240
+ ui_port = int(os.environ.get("WORKFRAME_UI_PORT", "18644") or "18644")
241
+ if "connection refused" in msg or "errno 111" in msg:
242
+ base = (
243
+ f"Nothing is listening on HTTPS for that host yet. Use step 2 (Set up HTTPS on this server), "
244
+ f"point DNS here, and open ports 80/443. Caddy proxies to 127.0.0.1:{ui_port}."
245
+ )
246
+ if local.get("local_ok"):
247
+ return f"Workframe is healthy on this server. {base}"
248
+ return base
249
+ if "timed out" in msg or "timeout" in msg:
250
+ return (
251
+ "Timed out reaching that URL — DNS may not point to this server yet, or HTTPS is not ready. "
252
+ "Add the A record, run Set up HTTPS, wait a minute, then retry."
253
+ )
254
+ if local.get("local_ok"):
255
+ return "Workframe is running on this server; the public URL is not reachable from here yet."
256
+ return "Check DNS, HTTPS (Caddy), and that the domain matches this server."
257
+
258
+
259
+ def url_test(app_base_url: str) -> dict[str, Any]:
260
+ url = _normalize_app_base_url(app_base_url)
261
+ if not url:
262
+ raise ValueError("app_base_url required")
263
+ host = _hostname_only(url)
264
+
265
+ if _loopback_hostname(host):
266
+ try:
267
+ local = _local_stack_health()
268
+ if local.get("local_ok"):
269
+ return {
270
+ "ok": True,
271
+ "status": local.get("status"),
272
+ "url": url,
273
+ "checked_url": local.get("checked_url"),
274
+ "hint": "Loopback URL — verified via the UI proxy on this stack.",
275
+ }
276
+ return {
277
+ "ok": False,
278
+ "url": url,
279
+ "error": str(local.get("error") or "local health check failed"),
280
+ "hint": "Is the Workframe UI container running?",
281
+ }
282
+ except Exception as exc:
283
+ return {
284
+ "ok": False,
285
+ "url": url,
286
+ "error": str(exc),
287
+ "hint": "Is the Workframe UI container running?",
288
+ }
289
+
290
+ health_url = f"{url.rstrip('/')}/api/health"
291
+ local = _local_stack_health()
292
+ try:
293
+ return {**_fetch_health(health_url), "url": health_url}
294
+ except urllib.error.HTTPError as exc:
295
+ return {
296
+ "ok": False,
297
+ "status": exc.code,
298
+ "url": health_url,
299
+ "error": str(exc),
300
+ "hint": _url_test_hint(exc, local=local),
301
+ "local_ok": bool(local.get("local_ok")),
302
+ }
303
+ except Exception as exc:
304
+ return {
305
+ "ok": False,
306
+ "url": health_url,
307
+ "error": str(exc),
308
+ "hint": _url_test_hint(exc, local=local),
309
+ "local_ok": bool(local.get("local_ok")),
310
+ }
311
+
312
+
313
+ def smtp_error_hint(exc: Exception) -> str:
314
+ msg = str(exc).lower()
315
+ if "smtp login failed" in msg or "535" in msg or "badcredentials" in msg:
316
+ return "Check SMTP username and app password. For Gmail, use an App Password—not your normal password."
317
+ if "smtp password is required" in msg:
318
+ return "Enter the SMTP app password. Compose uses SMTP_PASS; onboarding saves it in stack config."
319
+ if "rejected from address" in msg:
320
+ return (
321
+ "Gmail rejected that From address for this login. Leave From blank to send as your login email, "
322
+ "or add the address under Gmail Settings → Accounts → Send mail as."
323
+ )
324
+ if "530" in msg and "authentication required" in msg:
325
+ return "SMTP authentication did not complete. Check username, app password, and port (465=SSL, 587=STARTTLS)."
326
+ if "connect" in msg or "timed out" in msg or "unexpectedly closed" in msg:
327
+ return "Check SMTP host and port. Port 465 needs SSL; port 587 uses STARTTLS."
328
+ if "certificate" in msg or "ssl" in msg:
329
+ return "Try toggling TLS/SSL settings to match your provider."
330
+ return "Double-check host, port, username, password, and From address, then try again."
331
+
332
+
333
+ def normalize_setup_https(host: str, port: int | str | None = None) -> tuple[str, int]:
334
+ name = _hostname_only(host) or str(host or "").strip().lower()
335
+ if not name or not re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?", name, re.IGNORECASE):
336
+ raise ValueError("valid hostname required")
337
+ ui_port = int(port or os.environ.get("WORKFRAME_UI_PORT", "18644") or "18644")
338
+ return name, ui_port
339
+
340
+
341
+ if __name__ == "__main__":
342
+ assert dns_record_name("dev.example.com") == "dev"
343
+ assert dns_record_name("example.com") == "@"
344
+ assert apex_domain("dev.example.com") == "example.com"
345
+ assert _loopback_hostname("127.0.0.1")
346
+ assert _loopback_hostname("localhost")
347
+ assert not _loopback_hostname("dev.example.com")
348
+ import tempfile
349
+ from pathlib import Path
350
+
351
+ td = Path(tempfile.mkdtemp())
352
+ os.environ["WORKFRAME_API_DATA_DIR"] = str(td)
353
+ stack_config.patch_stack_config({"smtp": {"host": "smtp.test", "user": "a", "password": "b"}})
354
+ assert not stack_config.smtp_tested()
355
+ stack_config.mark_smtp_tested()
356
+ assert stack_config.smtp_tested()
357
+ stack_config.patch_stack_config({"smtp": {"host": "smtp.other"}})
358
+ assert not stack_config.smtp_tested()
359
+ print("install_api publish hints ok")