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,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")