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.
- package/LICENSE +201 -201
- package/NOTICE +12 -12
- package/README.md +8 -92
- package/SECURITY.md +40 -40
- package/bin/workframe.js +329 -329
- package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +1 -1
- package/docs/workspace-instructions/WORKFRAME_ROUTING.md +8 -8
- package/package.json +3 -6
- package/profiles/architect/AGENTS.md +29 -29
- package/profiles/architect/SOUL.md +2 -2
- package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/designer/AGENTS.md +26 -26
- package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/dev/AGENTS.md +28 -28
- package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/docs/AGENTS.md +27 -27
- package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/research/AGENTS.md +26 -26
- package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/visionary/AGENTS.md +25 -25
- package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/workframe-agent/AGENTS.md +37 -37
- package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
- package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
- package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
- package/rules/workspace-README.md +5 -5
- package/scripts/apply-update-hermes.sh +17 -17
- package/scripts/apply-update-workframe.sh +77 -77
- package/scripts/bootstrap-workspace-link.sh +8 -8
- package/scripts/bundle-workframe-ui.mjs +3 -3
- package/scripts/compose-docker-host.sh +37 -37
- package/scripts/ensure-compose-host-paths.mjs +51 -51
- package/scripts/fix-zk-encryption-key.sh +35 -35
- package/scripts/lib/install-identity.mjs +212 -212
- package/scripts/restart-gateway-hermes.sh +12 -12
- package/scripts/set-compose-public-url.mjs +92 -92
- package/scripts/setup-stack-secrets.sh +50 -50
- package/scripts/sync-canonical-to-package.mjs +8 -7
- package/scripts/verify-public-deploy.sh +105 -105
- package/shared/WORKFRAME_AGENT_LIBRARY.md +17 -17
- package/shared/WORKFRAME_AGENT_OPERATIONS.md +15 -15
- package/shared/WORKFRAME_AGENT_PACKS.json +18 -18
- package/shared/WORKFRAME_AGENT_PACKS.yaml +8 -8
- package/shared/WORKFRAME_SKILL_CURATION.md +4 -4
- package/workframe-api/README.md +28 -28
- package/workframe-api/action_proxy.py +131 -131
- package/workframe-api/auth_rate_limit.py +49 -49
- package/workframe-api/credential_vault.py +445 -445
- package/workframe-api/data/avatar-catalog.json +41 -41
- package/workframe-api/email_sender.py +220 -220
- package/workframe-api/google_auth.py +90 -90
- package/workframe-api/install_api.py +359 -359
- package/workframe-api/internal_proxy_auth.py +150 -150
- package/workframe-api/llm_proxy.py +277 -277
- package/workframe-api/oidc_jwt.py +108 -108
- package/workframe-api/package.json +12 -13
- package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
- package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
- package/workframe-api/requirements.txt +2 -2
- package/workframe-api/site_meta.py +271 -271
- package/workframe-api/stack_config.py +427 -427
- package/workframe-api/time-bind-chat.py +99 -99
- package/workframe-api/turn_credentials.py +226 -226
- package/workframe-api/updates.py +417 -417
- package/workframe-api/vault_kek.py +159 -159
- package/workframe-api/zk_auth.py +633 -633
- package/workframe-supervisor/Dockerfile +11 -11
- package/workframe-supervisor/server.py +787 -787
- package/workframe-ui/docker/nginx.conf +85 -85
- package/workframe-ui/public/assets/{arc-CBDYvkAF.js → arc-COAT3laO.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-XnBRKeW0.js → architectureDiagram-3BPJPVTR-BFjWV24l.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-VYHUfVhd.js → blockDiagram-GPEHLZMM-DSQLPfrj.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BTjUcJpm.js → c4Diagram-AAUBKEIU-DKEHv1t2.js} +1 -1
- package/workframe-ui/public/assets/channel-g7r_RGaY.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-w7uu7R-b.js → chunk-2J33WTMH-DHZg-DUi.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-Cb9LtnDX.js → chunk-3OPIFGDE-BB-OYTfp.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-DiQ-qCwH.js → chunk-4BX2VUAB-C93q0YIm.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-C-mLFr7z.js → chunk-55IACEB6-MAYniqik.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DOesfiCI.js → chunk-5ZQYHXKU-ChgN6YJs.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-BJ3oBZuz.js → chunk-727SXJPM-B_FYwdAv.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CCA6xpGs.js → chunk-AQP2D5EJ-1_Hw_h1A.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-a0cMNFb2.js → chunk-BSJP7CBP-CFiDQ1Rv.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-kuqN8EIY.js → chunk-CSCIHK7Q-DZ9UMTlB.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-DyPgYHCg.js → chunk-FMBD7UC4-DlMlyFgw.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-CdUuvR0V.js → chunk-KSCS5N6A-DHXtQ_Hf.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-Dq9NoWmK.js → chunk-L5ZTLDWV-CuQzg-QG.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-p74rddlO.js → chunk-LZXEDZCA-BHzjzCGg.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-DBD2u1Gz.js → chunk-ND2GUHAM-DHXx05n2.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-BeIeYFnd.js → chunk-NZK2D7GU-CV5pmDM_.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-ClHc56ib.js → chunk-O5CBEL6O-6tkCHxsV.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-EFr8R_1p.js → chunk-XPW4576I-BClwIiCp.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-C7aPBODd.js → cose-bilkent-S5V4N54A-DOrGV6DQ.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-BdU1Rv-H.js → dagre-BM42HDAG-DXTPvJkX.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-DWowSo85.js → diagram-2AECGRRQ-xX_v-pbf.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-MnxBbceO.js → diagram-5GNKFQAL-Cd2pXbBe.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQaLRXFf.js → diagram-KO2AKTUF-Df3XvUtk.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-CQaBud9k.js → diagram-LMA3HP47-CsijIPaD.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-D8bAXbY9.js → diagram-OG6HWLK6-aq5fmfHd.js} +1 -1
- package/workframe-ui/public/assets/{dist-DGpTLHr_.js → dist-D1c0mkbB.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-1E-xSvBK.js → erDiagram-TEJ5UH35-DnFysVRY.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CgOVD5hu.js → flowDiagram-I6XJVG4X-C6Ebi3su.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-JFYAIauo.js → ganttDiagram-6RSMTGT7-BQXQtUpa.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-B9REenIl.js → gitGraph-WXDBUCRP-Dt0zIs_M.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BQ7NcMSn.js → gitGraphDiagram-PVQCEYII-BF8gHzRn.js} +1 -1
- package/workframe-ui/public/assets/index-DpoUZAxh.css +1 -0
- package/workframe-ui/public/assets/{index-Dnw6vjqb.js → index-lRpzpNPT.js} +2 -2
- package/workframe-ui/public/assets/{info-J43DQDTF-CL6-eTjH.js → info-J43DQDTF-CSmszQJT.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-LJTODW4W.js → infoDiagram-5YYISTIA-CVTKGW6p.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-bchrQVuo.js → ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DkrvYuxP.js → journeyDiagram-JHISSGLW-r3wD68_T.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DFRbj0IG.js → kanban-definition-UN3LZRKU-Il8VglqN.js} +1 -1
- package/workframe-ui/public/assets/{line-Vd48P7-O.js → line-oyjpfz2A.js} +1 -1
- package/workframe-ui/public/assets/{linear-Ckizh2G7.js → linear-Cf7p5tVp.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Bkimsnqj.js → mermaid-parser.core-YmbZ-AfY.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-x0TvVuPo.js → mermaid.core-BFdCAqCo.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-6ykAFPEz.js → mindmap-definition-RKZ34NQL-Cy2iCtEl.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-Dw3xgMDt.js → packet-YPE3B663-DwOBZL6K.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DATysawG.js → pie-LRSECV5Y-04PPhnKK.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-SJKD1S0S.js → pieDiagram-4H26LBE5-LxIpgHqi.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-BrYDZX8q.js → quadrantDiagram-W4KKPZXB-0nBYfYm4.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-BmWYPCds.js → radar-GUYGQ44K-D2-vBqps.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DwL9Mc8e.js → requirementDiagram-4Y6WPE33-DbuU0nlu.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DYIFsL8h.js → sankeyDiagram-5OEKKPKP-B2hQ6B2x.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-0-FPkFk8.js → sequenceDiagram-3UESZ5HK-BBrU30e1.js} +1 -1
- package/workframe-ui/public/assets/{src-B_od6b6h.js → src-BJEDmV70.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BQCiBk6u.js → stateDiagram-AJRCARHV-7FGO4kkH.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DS3tFcXj.js → timeline-definition-PNZ67QCA-ptDm4rCN.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-DSyUCKLY.js → treeView-BLDUP644-CS6Z-0q8.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-CEZaNh5Y.js → treemap-LRROVOQU-DqV4Y2VA.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CD-Vc9NF.js → vennDiagram-CIIHVFJN-C0UrZJYt.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-Drq5w1Mc.js → wardley-L42UT6IY-bNDN3_Sa.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DouXDJoF.js → wardleyDiagram-YWT4CUSO-jWiJsefM.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DDf_Lol5.js → xychartDiagram-2RQKCTM6-Dsh_fLCy.js} +1 -1
- package/workframe-ui/public/favicon.svg +7 -7
- package/workframe-ui/public/index.html +50 -50
- package/workframe-ui/public/workframe-config.json +3 -3
- package/scripts/security_audit.py +0 -156
- package/scripts/test-scaffold.mjs +0 -390
- package/workframe-api/tests/__init__.py +0 -0
- package/workframe-api/tests/db_setup.py +0 -13
- package/workframe-api/tests/test_admin_updates_gated.py +0 -30
- package/workframe-api/tests/test_agent_dm_bootstrap.py +0 -196
- package/workframe-api/tests/test_agent_profile_sync.py +0 -76
- package/workframe-api/tests/test_auth_email.py +0 -222
- package/workframe-api/tests/test_auth_hole_fix_selfcheck.py +0 -99
- package/workframe-api/tests/test_auth_rate_limit.py +0 -19
- package/workframe-api/tests/test_avatar_resolve.py +0 -77
- package/workframe-api/tests/test_child_soul_template.py +0 -71
- package/workframe-api/tests/test_credential_canary.py +0 -135
- package/workframe-api/tests/test_credential_isolation.py +0 -448
- package/workframe-api/tests/test_credential_resolution.py +0 -206
- package/workframe-api/tests/test_device_oauth.py +0 -108
- package/workframe-api/tests/test_doctor_repair.py +0 -103
- package/workframe-api/tests/test_ensure_profile_api.py +0 -77
- package/workframe-api/tests/test_gateway_compose_security.py +0 -136
- package/workframe-api/tests/test_install_secure_host.py +0 -39
- package/workframe-api/tests/test_internal_proxy_auth.py +0 -125
- package/workframe-api/tests/test_invite_runtime_bootstrap.py +0 -72
- package/workframe-api/tests/test_kanban_delegation.py +0 -185
- package/workframe-api/tests/test_llm_proxy.py +0 -155
- package/workframe-api/tests/test_login_access_policy.py +0 -183
- package/workframe-api/tests/test_mvp_model_bootstrap.py +0 -75
- package/workframe-api/tests/test_onboarding_bootstrap.py +0 -248
- package/workframe-api/tests/test_platform_auth.py +0 -47
- package/workframe-api/tests/test_profile_config_path.py +0 -56
- package/workframe-api/tests/test_profile_config_yaml_repair.py +0 -63
- package/workframe-api/tests/test_profile_create.py +0 -72
- package/workframe-api/tests/test_profile_identity_overlay.py +0 -61
- package/workframe-api/tests/test_profile_install_health.py +0 -45
- package/workframe-api/tests/test_profile_secret_policy.py +0 -57
- package/workframe-api/tests/test_profile_workspace_cwd.py +0 -34
- package/workframe-api/tests/test_provider_bootstrap.py +0 -75
- package/workframe-api/tests/test_provider_connect.py +0 -54
- package/workframe-api/tests/test_room_crud.py +0 -192
- package/workframe-api/tests/test_room_tenancy.py +0 -701
- package/workframe-api/tests/test_runtime_identity_backfill.py +0 -34
- package/workframe-api/tests/test_site_meta.py +0 -81
- package/workframe-api/tests/test_soul_stub.py +0 -42
- package/workframe-api/tests/test_space_member_sync.py +0 -99
- package/workframe-api/tests/test_stripe_stack_config.py +0 -37
- package/workframe-api/tests/test_supervisor_lifecycle.py +0 -52
- package/workframe-api/tests/test_turn_credential_vault.py +0 -125
- package/workframe-api/tests/test_updates.py +0 -176
- package/workframe-api/tests/test_user_cohort.py +0 -113
- package/workframe-api/tests/test_vault_envelope.py +0 -110
- package/workframe-api/tests/test_workspace_members.py +0 -183
- package/workframe-api/tests/test_workspace_messaging_sync.py +0 -125
- package/workframe-api/tests/test_workspace_provider_list.py +0 -57
- package/workframe-supervisor/tests/test_exec_guard.py +0 -42
- package/workframe-supervisor/tests/test_server_import.py +0 -21
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CtbQKTuT.js +0 -1
- package/workframe-ui/public/assets/channel-Dy4Z4-jn.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-CtBEchFK.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-B9pBtriN.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BMAEA8jI.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BMAEA8jI.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-D75cstNT.js +0 -1
- package/workframe-ui/public/assets/index-DpAGxump.css +0 -1
- 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/
|
|
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.
|
|
343
|
-
assert dns_record_name("
|
|
344
|
-
assert apex_domain("dev.
|
|
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")
|