create-workframe 0.1.5 → 0.1.7

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 (185) hide show
  1. package/.dockerignore +22 -22
  2. package/.gitignore +73 -73
  3. package/LICENSE +201 -201
  4. package/NOTICE +12 -12
  5. package/README.md +40 -40
  6. package/SECURITY.md +38 -38
  7. package/bin/create-workframe.js +2816 -2814
  8. package/bin/workframe.js +329 -329
  9. package/docs/workspace-instructions/WORKFRAME_DISCORD.md +20 -20
  10. package/docs/workspace-instructions/WORKFRAME_DOCUMENTS_AND_ARTIFACTS.md +20 -20
  11. package/docs/workspace-instructions/WORKFRAME_KANBAN.md +20 -20
  12. package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +21 -21
  13. package/docs/workspace-instructions/WORKFRAME_ROUTING.md +29 -29
  14. package/docs/workspace-instructions/WORKFRAME_TELEGRAM.md +19 -19
  15. package/package.json +64 -64
  16. package/profiles/README.md +15 -15
  17. package/profiles/architect/AGENTS.md +29 -29
  18. package/profiles/architect/SOUL.md +44 -44
  19. package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
  20. package/profiles/designer/AGENTS.md +26 -26
  21. package/profiles/designer/SOUL.md +31 -31
  22. package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
  23. package/profiles/dev/AGENTS.md +28 -28
  24. package/profiles/dev/SOUL.md +31 -31
  25. package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
  26. package/profiles/docs/AGENTS.md +27 -27
  27. package/profiles/docs/SOUL.md +30 -30
  28. package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
  29. package/profiles/research/AGENTS.md +26 -26
  30. package/profiles/research/SOUL.md +31 -31
  31. package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
  32. package/profiles/visionary/AGENTS.md +25 -25
  33. package/profiles/visionary/SOUL.md +31 -31
  34. package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
  35. package/profiles/workframe-agent/AGENTS.md +37 -37
  36. package/profiles/workframe-agent/SETUP.md +185 -185
  37. package/profiles/workframe-agent/SOUL.md +61 -61
  38. package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
  39. package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
  40. package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
  41. package/prompts/WORKFRAME_PROMPT_TEMPLATES.md +16 -16
  42. package/rules/.hermes.md +11 -11
  43. package/rules/AGENTS.md +22 -22
  44. package/rules/workspace-README.md +5 -5
  45. package/scripts/apply-update-workframe.sh +91 -21
  46. package/scripts/bundle-workframe-ui.mjs +83 -83
  47. package/scripts/create_workframe_scaffold.py +648 -648
  48. package/scripts/ensure-compose-host-paths.mjs +51 -51
  49. package/scripts/lib/install-identity.mjs +212 -212
  50. package/scripts/lib/workframe-registry.mjs +290 -290
  51. package/scripts/new-project.mjs +68 -68
  52. package/scripts/select_agent_pack.py +31 -31
  53. package/scripts/set-compose-public-url.mjs +92 -92
  54. package/scripts/sync-canonical-to-package.mjs +164 -164
  55. package/shared/WORKFRAME_AGENT_LIBRARY.md +31 -31
  56. package/shared/WORKFRAME_AGENT_OPERATIONS.md +29 -29
  57. package/shared/WORKFRAME_AGENT_PACKS.json +64 -64
  58. package/shared/WORKFRAME_AGENT_PACKS.yaml +20 -20
  59. package/shared/WORKFRAME_CHAT_PERMISSION_MODEL.md +20 -20
  60. package/shared/WORKFRAME_HANDOFF_SCHEMA.md +25 -25
  61. package/shared/WORKFRAME_SKILL_CURATION.md +27 -27
  62. package/workframe-api/Dockerfile +14 -14
  63. package/workframe-api/README.md +24 -24
  64. package/workframe-api/action_proxy.py +131 -131
  65. package/workframe-api/auth_rate_limit.py +49 -49
  66. package/workframe-api/credential_vault.py +445 -445
  67. package/workframe-api/data/avatar-catalog.json +41 -41
  68. package/workframe-api/data/logo-catalog.json +14 -14
  69. package/workframe-api/data/user-avatar-catalog.json +18 -18
  70. package/workframe-api/email_sender.py +220 -220
  71. package/workframe-api/google_auth.py +90 -90
  72. package/workframe-api/install_api.py +359 -359
  73. package/workframe-api/internal_proxy_auth.py +150 -150
  74. package/workframe-api/llm_proxy.py +280 -280
  75. package/workframe-api/oidc_jwt.py +108 -108
  76. package/workframe-api/package.json +12 -12
  77. package/workframe-api/platform_auth.py +194 -194
  78. package/workframe-api/profile_secret_policy.py +86 -86
  79. package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
  80. package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
  81. package/workframe-api/public/assets/index-DglUqFB_.js +8 -8
  82. package/workframe-api/public/index.html +12 -12
  83. package/workframe-api/requirements.txt +2 -2
  84. package/workframe-api/server.py +20295 -19866
  85. package/workframe-api/site_meta.py +271 -271
  86. package/workframe-api/stack_config.py +435 -427
  87. package/workframe-api/test_ensure_native_profile.py +29 -0
  88. package/workframe-api/test_oauth_llm.py +58 -0
  89. package/workframe-api/test_provider_model_surface.py +31 -0
  90. package/workframe-api/test_stack_config_install_smtp.py +51 -0
  91. package/workframe-api/time-bind-chat.py +99 -99
  92. package/workframe-api/turn_credentials.py +226 -226
  93. package/workframe-api/updates.py +506 -506
  94. package/workframe-api/vault_kek.py +159 -159
  95. package/workframe-api/zk_auth.py +633 -633
  96. package/workframe-supervisor/Dockerfile +11 -11
  97. package/workframe-supervisor/profile_secret_policy.py +76 -76
  98. package/workframe-supervisor/server.py +860 -860
  99. package/workframe-ui/docker/nginx.conf +85 -85
  100. package/workframe-ui/public/assets/{arc-CnriPN3G.js → arc-Cn-lt9fT.js} +1 -1
  101. package/workframe-ui/public/assets/architecture-7EHR7CIX-CCMNXHod.js +1 -0
  102. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DYYB1Ic2.js → architectureDiagram-3BPJPVTR-qXoGCqfH.js} +1 -1
  103. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-Bn-fFOyj.js → blockDiagram-GPEHLZMM-DoopFlWj.js} +1 -1
  104. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BPPDtAnD.js → c4Diagram-AAUBKEIU-BRa7hfzE.js} +1 -1
  105. package/workframe-ui/public/assets/channel-CtFV3sE3.js +1 -0
  106. package/workframe-ui/public/assets/{chunk-2J33WTMH-Bz2CJiC3.js → chunk-2J33WTMH-Cl33E2hh.js} +1 -1
  107. package/workframe-ui/public/assets/{chunk-3OPIFGDE-B8aMeE1B.js → chunk-3OPIFGDE-BRywYZQO.js} +1 -1
  108. package/workframe-ui/public/assets/{chunk-4BX2VUAB-bLFHQTuT.js → chunk-4BX2VUAB-CI3xzp-0.js} +1 -1
  109. package/workframe-ui/public/assets/{chunk-55IACEB6-BRxhx-lG.js → chunk-55IACEB6-Bkf3Ye3F.js} +1 -1
  110. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BLyOgEJ8.js → chunk-5ZQYHXKU-C5RN3hUT.js} +1 -1
  111. package/workframe-ui/public/assets/{chunk-727SXJPM-DiuvBl7G.js → chunk-727SXJPM-BaY0yDwt.js} +1 -1
  112. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-BE1_aBdC.js → chunk-AQP2D5EJ-VMMBaDqd.js} +1 -1
  113. package/workframe-ui/public/assets/{chunk-BSJP7CBP-BicGsmBs.js → chunk-BSJP7CBP-DlNZ1UV3.js} +1 -1
  114. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-C3VTns3d.js → chunk-CSCIHK7Q-tT2HyVy0.js} +1 -1
  115. package/workframe-ui/public/assets/{chunk-FMBD7UC4-DSiXY3bY.js → chunk-FMBD7UC4-CyACRaUT.js} +1 -1
  116. package/workframe-ui/public/assets/{chunk-KSCS5N6A-Cvw5s3fR.js → chunk-KSCS5N6A-seK5WAZs.js} +1 -1
  117. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-jJifGdIQ.js → chunk-L5ZTLDWV-r5cyG5AH.js} +1 -1
  118. package/workframe-ui/public/assets/{chunk-LZXEDZCA-Maee1aqo.js → chunk-LZXEDZCA-SnbeVxsJ.js} +2 -2
  119. package/workframe-ui/public/assets/{chunk-ND2GUHAM-C77N3VAM.js → chunk-ND2GUHAM-zbFgEXSJ.js} +1 -1
  120. package/workframe-ui/public/assets/{chunk-NZK2D7GU-BY68lrPB.js → chunk-NZK2D7GU-UhZc8kOk.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-O5CBEL6O-D_c4kWzh.js → chunk-O5CBEL6O-DHvu2iZ-.js} +1 -1
  122. package/workframe-ui/public/assets/chunk-QZHKN3VN-B1g7l1_B.js +1 -0
  123. package/workframe-ui/public/assets/chunk-WU5MYG2G-B_qfZ6QP.js +1 -0
  124. package/workframe-ui/public/assets/{chunk-XPW4576I-DlQgvqBp.js → chunk-XPW4576I-CCPNKbzG.js} +1 -1
  125. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CJoeCNq6.js +1 -0
  126. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CJoeCNq6.js +1 -0
  127. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CHCOHDSd.js → cose-bilkent-S5V4N54A-D1CLe6Y0.js} +1 -1
  128. package/workframe-ui/public/assets/{dagre-BM42HDAG-CKPcSYwU.js → dagre-BM42HDAG-DYHcGVs_.js} +1 -1
  129. package/workframe-ui/public/assets/{diagram-2AECGRRQ-C6qe0jP-.js → diagram-2AECGRRQ-DVUkJ-II.js} +1 -1
  130. package/workframe-ui/public/assets/{diagram-5GNKFQAL-DhQhrieU.js → diagram-5GNKFQAL-C9Y1Acc2.js} +1 -1
  131. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Bqf6Iw1r.js → diagram-KO2AKTUF-Db3zez-n.js} +1 -1
  132. package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt1ms2D2.js → diagram-LMA3HP47-BaOR5sIr.js} +1 -1
  133. package/workframe-ui/public/assets/{diagram-OG6HWLK6-C0Rrxe1i.js → diagram-OG6HWLK6-fLBzMY5W.js} +1 -1
  134. package/workframe-ui/public/assets/{dist-BLcLhTn6.js → dist-uZYeD3o1.js} +1 -1
  135. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-Cy1GbZVu.js → erDiagram-TEJ5UH35-BoCxDSyE.js} +1 -1
  136. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Bw910jho.js +1 -0
  137. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-DL3ALqDG.js → flowDiagram-I6XJVG4X-BnYa3CAj.js} +1 -1
  138. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-CMpwBhXQ.js → ganttDiagram-6RSMTGT7-Dm_e_Kaa.js} +1 -1
  139. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-C0mwHUXL.js → gitGraph-WXDBUCRP-CBAj8ntv.js} +1 -1
  140. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-6gzhWaF0.js → gitGraphDiagram-PVQCEYII-D5auJjJh.js} +1 -1
  141. package/workframe-ui/public/assets/index-DHLQNAXd.js +129 -0
  142. package/workframe-ui/public/assets/index-auJ0bLMU.css +1 -0
  143. package/workframe-ui/public/assets/{info-J43DQDTF-Bb_wZTxU.js → info-J43DQDTF-BNMADxE2.js} +1 -1
  144. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-601evYFV.js → infoDiagram-5YYISTIA-C8c45T4J.js} +1 -1
  145. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-BYtF-wHm.js → ishikawaDiagram-YF4QCWOH-CyWuHB4e.js} +1 -1
  146. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-BMizyHzC.js → journeyDiagram-JHISSGLW-B43pCuDT.js} +1 -1
  147. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CLLJsdnh.js → kanban-definition-UN3LZRKU-Dyn480BI.js} +1 -1
  148. package/workframe-ui/public/assets/{line-BFiSvu-q.js → line-CNtIbnmt.js} +1 -1
  149. package/workframe-ui/public/assets/{linear-D6HYZ18_.js → linear-pmSg_4uV.js} +1 -1
  150. package/workframe-ui/public/assets/{mermaid-parser.core-Cid5yDBQ.js → mermaid-parser.core-BX4tnojE.js} +2 -2
  151. package/workframe-ui/public/assets/{mermaid.core-DqIiju5Z.js → mermaid.core-BtpBva5o.js} +3 -3
  152. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-Bg7xJdsz.js → mindmap-definition-RKZ34NQL-DcMcB_69.js} +1 -1
  153. package/workframe-ui/public/assets/{packet-YPE3B663-1ebGt86D.js → packet-YPE3B663-KSzK9WlD.js} +1 -1
  154. package/workframe-ui/public/assets/{pie-LRSECV5Y-DJ9wOBC_.js → pie-LRSECV5Y-DLOH6Mg8.js} +1 -1
  155. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-BmK96Aus.js → pieDiagram-4H26LBE5-BhrX2aGk.js} +1 -1
  156. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-ByUqtWPZ.js → quadrantDiagram-W4KKPZXB-B5dDjbTX.js} +1 -1
  157. package/workframe-ui/public/assets/{radar-GUYGQ44K-DN-Hkt96.js → radar-GUYGQ44K-BPdjqZiu.js} +1 -1
  158. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-ByU2r8gb.js → requirementDiagram-4Y6WPE33-21Dq5e0N.js} +1 -1
  159. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-elyWa0GK.js → sankeyDiagram-5OEKKPKP-CiA99dbO.js} +1 -1
  160. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-CzZwsAiK.js → sequenceDiagram-3UESZ5HK-Bwpui7_x.js} +1 -1
  161. package/workframe-ui/public/assets/{src-5vgsM_dl.js → src-B3Axsi1U.js} +1 -1
  162. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-B0O5w5lJ.js → stateDiagram-AJRCARHV-C76qwtE8.js} +1 -1
  163. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-Dn0CDWJD.js +1 -0
  164. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-oyqa5w5y.js → timeline-definition-PNZ67QCA-CufInOyk.js} +1 -1
  165. package/workframe-ui/public/assets/{treeView-BLDUP644-MCV12-eP.js → treeView-BLDUP644-C4sxh0R-.js} +1 -1
  166. package/workframe-ui/public/assets/{treemap-LRROVOQU-DiA1P9n5.js → treemap-LRROVOQU-BjV72ANV.js} +1 -1
  167. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CO_4x7OR.js → vennDiagram-CIIHVFJN-DgsdR0Rl.js} +1 -1
  168. package/workframe-ui/public/assets/{wardley-L42UT6IY-CmQNv6Xv.js → wardley-L42UT6IY-DZ279G_t.js} +1 -1
  169. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-CJxDkbIj.js → wardleyDiagram-YWT4CUSO-Bde25kvz.js} +1 -1
  170. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-B02MxgzG.js → xychartDiagram-2RQKCTM6-B8VnS2Pb.js} +1 -1
  171. package/workframe-ui/public/favicon.svg +7 -7
  172. package/workframe-ui/public/icons.svg +24 -24
  173. package/workframe-ui/public/index.html +47 -47
  174. package/workframe-ui/public/manifest.webmanifest +18 -18
  175. package/workframe-ui/public/workframe-config.json +3 -3
  176. package/workframe-ui/public/assets/architecture-7EHR7CIX-CFhr7xAe.js +0 -1
  177. package/workframe-ui/public/assets/channel-Dok_-T0o.js +0 -1
  178. package/workframe-ui/public/assets/chunk-QZHKN3VN-C3wwLrTa.js +0 -1
  179. package/workframe-ui/public/assets/chunk-WU5MYG2G-GFFSg2kf.js +0 -1
  180. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DKbGfT5Z.js +0 -1
  181. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DKbGfT5Z.js +0 -1
  182. package/workframe-ui/public/assets/eventmodeling-FCH6USID-C_PrceHe.js +0 -1
  183. package/workframe-ui/public/assets/index-DyO6K0TD.css +0 -1
  184. package/workframe-ui/public/assets/index-yvpSUs8T.js +0 -133
  185. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-RXZRaSgT.js +0 -1
@@ -1,506 +1,506 @@
1
- """Admin stack updates — version checks + safe in-place apply (preserves runtime/DB)."""
2
- from __future__ import annotations
3
-
4
- import json
5
- import os
6
- import re
7
- import subprocess
8
- import urllib.error
9
- import urllib.parse
10
- import urllib.request
11
- from pathlib import Path
12
- from typing import Any
13
-
14
- HERMES_IMAGE = os.environ.get("WORKFRAME_HERMES_IMAGE", "nousresearch/hermes-agent")
15
- HERMES_TAG = os.environ.get("WORKFRAME_HERMES_TAG", "latest")
16
- NPM_PACKAGE = os.environ.get("WORKFRAME_NPM_PACKAGE", "create-workframe")
17
- RELEASES_URL = str(os.environ.get("WORKFRAME_RELEASES_URL", "")).strip()
18
- DOCKER_SOCK = os.environ.get("DOCKER_SOCK", "/var/run/docker.sock")
19
- GATEWAY_CONTAINER = os.environ.get("WORKFRAME_GATEWAY_CONTAINER", "workframe-gateway")
20
- API_VERSION = str(os.environ.get("WORKFRAME_API_VERSION", "")).strip()
21
-
22
-
23
- def _version_tuple(raw: str) -> tuple[int, ...]:
24
- text = re.sub(r"^workframe-api-", "", str(raw or "").strip())
25
- nums: list[int] = []
26
- for part in re.split(r"[.+_-]", text):
27
- if part.isdigit():
28
- nums.append(int(part))
29
- elif nums:
30
- break
31
- return tuple(nums)
32
-
33
-
34
- def _version_lt(current: str, latest: str) -> bool:
35
- cur = str(current or "").strip()
36
- lat = str(latest or "").strip()
37
- if not lat:
38
- return False
39
- if not cur:
40
- return True
41
- return _version_tuple(cur) < _version_tuple(lat)
42
-
43
-
44
- def _http_json(url: str, timeout: float = 12.0) -> dict[str, Any]:
45
- req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "workframe-api"})
46
- with urllib.request.urlopen(req, timeout=timeout) as resp:
47
- data = json.loads(resp.read().decode("utf-8"))
48
- return data if isinstance(data, dict) else {}
49
-
50
-
51
- def _npm_latest_version() -> str:
52
- data = _http_json(f"https://registry.npmjs.org/{urllib.parse.quote(NPM_PACKAGE)}/latest")
53
- return str(data.get("version") or "").strip()
54
-
55
-
56
- def _docker_hub_digest(repo: str, tag: str) -> str:
57
- url = f"https://hub.docker.com/v2/repositories/{repo}/tags/{urllib.parse.quote(tag)}"
58
- data = _http_json(url)
59
- # ponytail: tag digest matches docker pull :tag RepoDigests; images[0] may be arm64 on multi-arch repos
60
- top = str(data.get("digest") or "").strip()
61
- if top:
62
- return top
63
- for entry in data.get("images") or []:
64
- if not isinstance(entry, dict) or not entry.get("digest"):
65
- continue
66
- if entry.get("architecture") == "amd64" and entry.get("os") == "linux":
67
- return str(entry["digest"]).strip()
68
- for entry in data.get("images") or []:
69
- if isinstance(entry, dict) and entry.get("digest"):
70
- return str(entry["digest"]).strip()
71
- return ""
72
-
73
-
74
- def _docker_sock_request(method: str, path: str, body: bytes | None = None) -> tuple[int, Any]:
75
- import http.client
76
- import socket as pysocket
77
-
78
- if not Path(DOCKER_SOCK).exists():
79
- return 0, {"error": "docker_socket_missing"}
80
- conn = http.client.HTTPConnection("localhost", timeout=120)
81
- conn.sock = pysocket.socket(pysocket.AF_UNIX, pysocket.SOCK_STREAM)
82
- conn.sock.connect(DOCKER_SOCK)
83
- headers = {"Content-Type": "application/json"} if body else {}
84
- conn.request(method, path, body=body, headers=headers)
85
- resp = conn.getresponse()
86
- raw = resp.read()
87
- conn.close()
88
- if not raw:
89
- return resp.status, {}
90
- try:
91
- return resp.status, json.loads(raw.decode("utf-8"))
92
- except json.JSONDecodeError:
93
- return resp.status, raw.decode("utf-8", errors="replace")
94
-
95
-
96
- def _container_image_digest(name: str) -> tuple[str, str]:
97
- status, data = _docker_sock_request("GET", f"/containers/{name}/json")
98
- if status != 200 or not isinstance(data, dict):
99
- return "", ""
100
- image_id = str(data.get("Image") or "")
101
- ist, idata = _docker_sock_request("GET", f"/images/{image_id}/json")
102
- digest = ""
103
- ref = HERMES_IMAGE
104
- if ist == 200 and isinstance(idata, dict):
105
- digests = idata.get("RepoDigests") or []
106
- if digests:
107
- digest = str(digests[0]).split("@")[-1]
108
- tags = idata.get("RepoTags") or []
109
- if tags:
110
- ref = str(tags[0])
111
- return digest, ref
112
-
113
-
114
- def _read_installed_workframe_version(project_root: Path) -> dict[str, str]:
115
- out = {"api": API_VERSION, "package": "", "manifest_generator": ""}
116
- manifest = project_root / "workframe-manifest.json"
117
- if manifest.is_file():
118
- try:
119
- data = json.loads(manifest.read_text(encoding="utf-8"))
120
- out["package"] = str(data.get("package_version") or "")
121
- out["manifest_generator"] = str(data.get("generator") or "")
122
- except Exception: # noqa: BLE001
123
- pass
124
- if not out["api"]:
125
- try:
126
- import server as _server # noqa: WPS433
127
-
128
- out["api"] = str(getattr(_server, "VERSION", ""))
129
- except Exception: # noqa: BLE001
130
- pass
131
- if not out["package"]:
132
- out["package"] = out["api"]
133
- return out
134
-
135
-
136
- def _compose_dir() -> Path:
137
- for raw in (
138
- os.environ.get("WORKFRAME_HOST_COMPOSE_DIR", ""),
139
- os.environ.get("WORKFRAME_COMPOSE_DIR", ""),
140
- os.environ.get("WORKFRAME_PROJECT_ROOT", ""),
141
- "/compose",
142
- "/project",
143
- ):
144
- p = Path(str(raw or "").strip())
145
- if p.is_dir() and (p / "docker-compose.yml").is_file():
146
- return p
147
- return Path(".")
148
-
149
-
150
- def _project_root() -> Path:
151
- for raw in (os.environ.get("WORKFRAME_PROJECT_ROOT", ""), "/project", os.environ.get("WORKFRAME_COMPOSE_DIR", "")):
152
- p = Path(str(raw or "").strip())
153
- if p.is_dir() and (p / "workframe-manifest.json").is_file():
154
- return p
155
- for raw in (os.environ.get("WORKFRAME_PROJECT_ROOT", ""), "/project", os.environ.get("WORKFRAME_COMPOSE_DIR", "")):
156
- p = Path(str(raw or "").strip())
157
- if p.is_dir() and (p / "docker-compose.yml").is_file():
158
- return p
159
- return _compose_dir()
160
-
161
-
162
- def _script_path(name: str) -> Path | None:
163
- roots = [
164
- Path(f"/opt/install/scripts/{name}"),
165
- Path(f"/opt/install/scripts/workframe/{name}"),
166
- ]
167
- mode = str(os.environ.get("WORKFRAME_DEPLOYMENT_MODE") or "trusted_team").strip().lower()
168
- if mode == "single_user_local":
169
- roots.extend(
170
- [
171
- _project_root() / "scripts" / "workframe" / name,
172
- _project_root() / "scripts" / name,
173
- ],
174
- )
175
- for path in roots:
176
- if path.is_file():
177
- return path
178
- return None
179
-
180
-
181
- def _host_compose_ready() -> bool:
182
- host_raw = str(os.environ.get("WORKFRAME_HOST_COMPOSE_DIR", "")).strip()
183
- if not host_raw:
184
- return False
185
- host = Path(host_raw)
186
- if host.is_dir() and (host / "docker-compose.yml").is_file():
187
- return True
188
- # ponytail: Windows host paths are not visible inside the API container — trust /compose mount
189
- compose = _compose_dir()
190
- return compose.joinpath("docker-compose.yml").is_file()
191
-
192
-
193
- def _supervisor_configured() -> bool:
194
- return bool(os.environ.get("WORKFRAME_SUPERVISOR_URL", "").strip()) and bool(
195
- os.environ.get("WORKFRAME_SUPERVISOR_TOKEN", "").strip()
196
- )
197
-
198
-
199
- def _supervisor_gateway_image_digest() -> tuple[str, str]:
200
- """Read gateway image digest via workframe-supervisor (SECURE_MODE API has no docker.sock)."""
201
- base = str(os.environ.get("WORKFRAME_SUPERVISOR_URL", "")).rstrip("/")
202
- token = str(os.environ.get("WORKFRAME_SUPERVISOR_TOKEN", "")).strip()
203
- if not base or not token:
204
- return "", ""
205
- req = urllib.request.Request(
206
- f"{base}/v1/gateway.image",
207
- headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "User-Agent": "workframe-api"},
208
- )
209
- try:
210
- with urllib.request.urlopen(req, timeout=15.0) as resp:
211
- data = json.loads(resp.read().decode("utf-8"))
212
- except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
213
- return "", ""
214
- if not isinstance(data, dict) or not data.get("ok"):
215
- return "", ""
216
- return str(data.get("digest") or "").strip(), str(data.get("ref") or "").strip()
217
-
218
-
219
- def _admin_stack_updates_enabled() -> bool:
220
- if os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") == "1":
221
- return True
222
- return _supervisor_configured()
223
-
224
-
225
- def _docker_apply_ready() -> tuple[bool, str | None]:
226
- if not Path(DOCKER_SOCK).exists():
227
- return False, "Docker socket is not available to the API container."
228
- if not _compose_dir().joinpath("docker-compose.yml").is_file():
229
- return False, "docker-compose.yml was not found for this stack."
230
- if not _host_compose_ready():
231
- return False, (
232
- "Set WORKFRAME_HOST_COMPOSE_DIR to the host compose folder so updates run on the Docker host."
233
- )
234
- return True, None
235
-
236
-
237
- def _update_apply_channel() -> tuple[str, bool, str | None]:
238
- """Returns (channel, ready, reason). channel: api_docker | supervisor | none."""
239
- api_docker = Path(DOCKER_SOCK).exists()
240
- if api_docker:
241
- ok, reason = _docker_apply_ready()
242
- if ok:
243
- return "api_docker", True, None
244
- if not _supervisor_configured():
245
- return "none", False, reason
246
- if _supervisor_configured():
247
- if _script_path("apply-update-workframe.sh") is None and _script_path("apply-update-hermes.sh") is None:
248
- return "supervisor", False, "Stack update scripts are missing from this install."
249
- return "supervisor", True, None
250
- if api_docker:
251
- _, reason = _docker_apply_ready()
252
- return "none", False, reason
253
- return (
254
- "none",
255
- False,
256
- "In-place updates need workframe-supervisor or Docker on the stack host.",
257
- )
258
-
259
-
260
- def _product_state(*, update_available: bool, can_update: bool) -> str:
261
- if update_available and can_update:
262
- return "available"
263
- if update_available:
264
- return "blocked"
265
- return "current"
266
-
267
-
268
- def parse_hermes_version_output(text: str) -> str:
269
- """Extract semver from `hermes --version` stdout."""
270
- match = re.search(r"Hermes Agent v(\d+\.\d+\.\d+)", str(text or ""))
271
- return match.group(1) if match else ""
272
-
273
-
274
- def _read_hermes_agent_version() -> str:
275
- """Native Hermes semver via gateway exec (lazy import avoids server load cycle)."""
276
- try:
277
- import server as _server # noqa: WPS433
278
-
279
- return _server._hermes_agent_version()
280
- except Exception: # noqa: BLE001
281
- return ""
282
-
283
-
284
- def _releases_manifest() -> dict[str, Any]:
285
- if not RELEASES_URL:
286
- return {}
287
- try:
288
- return _http_json(RELEASES_URL)
289
- except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
290
- return {}
291
-
292
-
293
- def updates_available(*, desktop_version: str = "", hermes_agent_version: str = "") -> dict[str, Any]:
294
- compose_dir = _compose_dir()
295
- project_root = _project_root()
296
- api_docker = Path(DOCKER_SOCK).exists()
297
- supervisor_ok = _supervisor_configured()
298
- apply_channel, apply_ready, apply_reason = _update_apply_channel()
299
- installed = _read_installed_workframe_version(project_root)
300
-
301
- npm_latest = ""
302
- try:
303
- npm_latest = _npm_latest_version()
304
- except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
305
- pass
306
-
307
- releases = _releases_manifest()
308
- workframe_latest = str(releases.get("workframe") or releases.get("create_workframe") or npm_latest or "")
309
- desktop_latest = str(releases.get("desktop") or os.environ.get("WORKFRAME_DESKTOP_LATEST", "0.1.0"))
310
-
311
- installed_pkg = installed.get("package") or installed.get("api") or ""
312
- workframe_update = bool(workframe_latest and _version_lt(installed_pkg, workframe_latest))
313
-
314
- hermes_digest, hermes_ref = ("", "")
315
- if api_docker:
316
- hermes_digest, hermes_ref = _container_image_digest(GATEWAY_CONTAINER)
317
- elif supervisor_ok:
318
- hermes_digest, hermes_ref = _supervisor_gateway_image_digest()
319
- hermes_tag = hermes_ref.rsplit(":", 1)[-1] if hermes_ref and ":" in hermes_ref else HERMES_TAG
320
- hermes_latest_digest = ""
321
- try:
322
- hermes_latest_digest = _docker_hub_digest(HERMES_IMAGE, HERMES_TAG)
323
- except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
324
- pass
325
- hermes_image_known = bool(api_docker or supervisor_ok)
326
- hermes_update = bool(
327
- hermes_image_known
328
- and hermes_latest_digest
329
- and hermes_digest
330
- and hermes_digest != hermes_latest_digest,
331
- )
332
-
333
- desktop_installed = str(desktop_version or "").strip()
334
- desktop_update = bool(desktop_latest and desktop_installed and _version_lt(desktop_installed, desktop_latest))
335
-
336
- digest_short = hermes_latest_digest
337
- if len(digest_short) > 28:
338
- digest_short = digest_short[:28] + "…"
339
-
340
- hermes_script_ok = _script_path("apply-update-hermes.sh") is not None
341
- workframe_script_ok = _script_path("apply-update-workframe.sh") is not None
342
- hermes_can_update = bool(apply_ready and hermes_script_ok)
343
- workframe_can_update = bool(apply_ready and workframe_script_ok)
344
- hermes_reason = apply_reason
345
- if not hermes_reason and hermes_update and not hermes_script_ok:
346
- hermes_reason = "Hermes update script is missing from this install."
347
- workframe_reason = apply_reason
348
- if not workframe_reason and workframe_update and not workframe_script_ok:
349
- workframe_reason = "Workframe update script is missing from this install."
350
- if not workframe_reason and workframe_update and not workframe_latest:
351
- workframe_reason = "No published npm release to update to yet."
352
-
353
- agent_version = str(hermes_agent_version or "").strip() or _read_hermes_agent_version()
354
- hermes_current = agent_version or hermes_tag
355
-
356
- return {
357
- "ok": True,
358
- "docker_available": apply_ready,
359
- "docker_sock_on_api": api_docker,
360
- "supervisor_configured": supervisor_ok,
361
- "update_apply_channel": apply_channel if apply_ready else None,
362
- "update_apply_ready": apply_ready,
363
- "compose_dir": str(compose_dir),
364
- "project_root": str(project_root),
365
- "workframe": {
366
- "current": installed_pkg,
367
- "latest": workframe_latest,
368
- "update_available": workframe_update,
369
- "can_update": workframe_can_update,
370
- "state": _product_state(update_available=workframe_update, can_update=workframe_can_update),
371
- "reason": workframe_reason,
372
- "update_mode": "docker-compose-rebuild",
373
- "install_kind": "docker",
374
- "components": ["ui", "api", "supervisor"],
375
- },
376
- "hermes": {
377
- "current": hermes_current,
378
- "agent_version": agent_version,
379
- "image_tag": hermes_tag,
380
- "latest": "",
381
- "current_image": hermes_ref,
382
- "current_digest": hermes_digest[:28] + "…" if len(hermes_digest) > 28 else hermes_digest,
383
- "latest_digest": digest_short,
384
- "image": f"{HERMES_IMAGE}:{HERMES_TAG}",
385
- "update_available": hermes_update,
386
- "can_update": hermes_can_update,
387
- "state": _product_state(update_available=hermes_update, can_update=hermes_can_update),
388
- "reason": hermes_reason,
389
- "update_mode": "docker-compose-pull",
390
- "install_kind": "docker",
391
- "can_restart_gateway": bool(apply_ready and _script_path("restart-gateway-hermes.sh") is not None),
392
- },
393
- "desktop": {
394
- "current": desktop_installed,
395
- "latest": desktop_latest,
396
- "update_available": desktop_update,
397
- "can_update": False,
398
- "state": "available" if desktop_update else "current",
399
- "reason": "Desktop updates are distributed separately from the Docker stack.",
400
- "update_mode": "manual-download",
401
- "install_kind": "desktop",
402
- "download_url": str(releases.get("desktop_download_url") or ""),
403
- },
404
- }
405
-
406
-
407
- def _apply_env_for_target(target: str) -> dict[str, str]:
408
- env = os.environ.copy()
409
- env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
410
- env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
411
- if target in {"workframe", "all"}:
412
- latest = ""
413
- try:
414
- latest = str(updates_available().get("workframe", {}).get("latest") or "").strip()
415
- except Exception: # noqa: BLE001
416
- latest = ""
417
- if latest:
418
- env["WORKFRAME_UPDATE_ALLOW_NPM"] = "1"
419
- env["WORKFRAME_UPDATE_VERSION"] = latest
420
- return env
421
-
422
-
423
- def apply_update(target: str) -> dict[str, Any]:
424
- if not _admin_stack_updates_enabled():
425
- raise ValueError("admin_updates_disabled")
426
- target = str(target or "all").strip().lower()
427
- if target not in {"hermes", "workframe", "all"}:
428
- raise ValueError("invalid_update_target")
429
- _, apply_ready, apply_reason = _update_apply_channel()
430
- if not apply_ready:
431
- if not Path(DOCKER_SOCK).exists():
432
- raise ValueError("docker_unavailable")
433
- raise ValueError(str(apply_reason or "docker_apply_unavailable"))
434
- if not Path(DOCKER_SOCK).exists():
435
- raise ValueError("docker_unavailable")
436
-
437
- scripts: list[str] = []
438
- if target in {"hermes", "all"}:
439
- script = _script_path("apply-update-hermes.sh")
440
- if not script:
441
- raise ValueError("update_script_missing:hermes")
442
- scripts.append(str(script))
443
- if target in {"workframe", "all"}:
444
- script = _script_path("apply-update-workframe.sh")
445
- if not script:
446
- raise ValueError("update_script_missing:workframe")
447
- scripts.append(str(script))
448
-
449
- env = _apply_env_for_target(target)
450
-
451
- logs: list[str] = []
452
- for script in scripts:
453
- proc = subprocess.run(
454
- ["bash", script],
455
- capture_output=True,
456
- text=True,
457
- timeout=900,
458
- env=env,
459
- cwd=env["WORKFRAME_COMPOSE_DIR"],
460
- )
461
- logs.append(f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}")
462
- if proc.returncode != 0:
463
- raise ValueError(f"update_failed:{Path(script).name}")
464
-
465
- return {"ok": True, "target": target, "log": "\n".join(logs)[-12000:]}
466
-
467
-
468
- def restart_gateway() -> dict[str, Any]:
469
- if not _admin_stack_updates_enabled():
470
- raise ValueError("admin_updates_disabled")
471
- _, apply_ready, apply_reason = _update_apply_channel()
472
- if not apply_ready:
473
- if not Path(DOCKER_SOCK).exists():
474
- raise ValueError("docker_unavailable")
475
- raise ValueError(str(apply_reason or "docker_apply_unavailable"))
476
- if not Path(DOCKER_SOCK).exists():
477
- raise ValueError("docker_unavailable")
478
- script = _script_path("restart-gateway-hermes.sh")
479
- if not script:
480
- raise ValueError("restart_script_missing:gateway")
481
-
482
- env = os.environ.copy()
483
- env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
484
- env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
485
- proc = subprocess.run(
486
- ["bash", str(script)],
487
- capture_output=True,
488
- text=True,
489
- timeout=300,
490
- env=env,
491
- cwd=env["WORKFRAME_COMPOSE_DIR"],
492
- )
493
- log = f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}"
494
- if proc.returncode != 0:
495
- raise ValueError("restart_failed:gateway")
496
- return {"ok": True, "target": "gateway", "log": log[-12000:]}
497
-
498
-
499
- if __name__ == "__main__":
500
- assert _version_lt("0.1.0", "0.1.1")
501
- assert not _version_lt("0.1.0", "0.1.0")
502
- assert parse_hermes_version_output("Hermes Agent v0.17.0 (2026.6.19)") == "0.17.0"
503
- ch, ready, _ = _update_apply_channel()
504
- assert ch in {"api_docker", "supervisor", "none"}
505
- assert ready is False or ch != "none"
506
- print("updates module ok")
1
+ """Admin stack updates — version checks + safe in-place apply (preserves runtime/DB)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ HERMES_IMAGE = os.environ.get("WORKFRAME_HERMES_IMAGE", "nousresearch/hermes-agent")
15
+ HERMES_TAG = os.environ.get("WORKFRAME_HERMES_TAG", "latest")
16
+ NPM_PACKAGE = os.environ.get("WORKFRAME_NPM_PACKAGE", "create-workframe")
17
+ RELEASES_URL = str(os.environ.get("WORKFRAME_RELEASES_URL", "")).strip()
18
+ DOCKER_SOCK = os.environ.get("DOCKER_SOCK", "/var/run/docker.sock")
19
+ GATEWAY_CONTAINER = os.environ.get("WORKFRAME_GATEWAY_CONTAINER", "workframe-gateway")
20
+ API_VERSION = str(os.environ.get("WORKFRAME_API_VERSION", "")).strip()
21
+
22
+
23
+ def _version_tuple(raw: str) -> tuple[int, ...]:
24
+ text = re.sub(r"^workframe-api-", "", str(raw or "").strip())
25
+ nums: list[int] = []
26
+ for part in re.split(r"[.+_-]", text):
27
+ if part.isdigit():
28
+ nums.append(int(part))
29
+ elif nums:
30
+ break
31
+ return tuple(nums)
32
+
33
+
34
+ def _version_lt(current: str, latest: str) -> bool:
35
+ cur = str(current or "").strip()
36
+ lat = str(latest or "").strip()
37
+ if not lat:
38
+ return False
39
+ if not cur:
40
+ return True
41
+ return _version_tuple(cur) < _version_tuple(lat)
42
+
43
+
44
+ def _http_json(url: str, timeout: float = 12.0) -> dict[str, Any]:
45
+ req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "workframe-api"})
46
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
47
+ data = json.loads(resp.read().decode("utf-8"))
48
+ return data if isinstance(data, dict) else {}
49
+
50
+
51
+ def _npm_latest_version() -> str:
52
+ data = _http_json(f"https://registry.npmjs.org/{urllib.parse.quote(NPM_PACKAGE)}/latest")
53
+ return str(data.get("version") or "").strip()
54
+
55
+
56
+ def _docker_hub_digest(repo: str, tag: str) -> str:
57
+ url = f"https://hub.docker.com/v2/repositories/{repo}/tags/{urllib.parse.quote(tag)}"
58
+ data = _http_json(url)
59
+ # ponytail: tag digest matches docker pull :tag RepoDigests; images[0] may be arm64 on multi-arch repos
60
+ top = str(data.get("digest") or "").strip()
61
+ if top:
62
+ return top
63
+ for entry in data.get("images") or []:
64
+ if not isinstance(entry, dict) or not entry.get("digest"):
65
+ continue
66
+ if entry.get("architecture") == "amd64" and entry.get("os") == "linux":
67
+ return str(entry["digest"]).strip()
68
+ for entry in data.get("images") or []:
69
+ if isinstance(entry, dict) and entry.get("digest"):
70
+ return str(entry["digest"]).strip()
71
+ return ""
72
+
73
+
74
+ def _docker_sock_request(method: str, path: str, body: bytes | None = None) -> tuple[int, Any]:
75
+ import http.client
76
+ import socket as pysocket
77
+
78
+ if not Path(DOCKER_SOCK).exists():
79
+ return 0, {"error": "docker_socket_missing"}
80
+ conn = http.client.HTTPConnection("localhost", timeout=120)
81
+ conn.sock = pysocket.socket(pysocket.AF_UNIX, pysocket.SOCK_STREAM)
82
+ conn.sock.connect(DOCKER_SOCK)
83
+ headers = {"Content-Type": "application/json"} if body else {}
84
+ conn.request(method, path, body=body, headers=headers)
85
+ resp = conn.getresponse()
86
+ raw = resp.read()
87
+ conn.close()
88
+ if not raw:
89
+ return resp.status, {}
90
+ try:
91
+ return resp.status, json.loads(raw.decode("utf-8"))
92
+ except json.JSONDecodeError:
93
+ return resp.status, raw.decode("utf-8", errors="replace")
94
+
95
+
96
+ def _container_image_digest(name: str) -> tuple[str, str]:
97
+ status, data = _docker_sock_request("GET", f"/containers/{name}/json")
98
+ if status != 200 or not isinstance(data, dict):
99
+ return "", ""
100
+ image_id = str(data.get("Image") or "")
101
+ ist, idata = _docker_sock_request("GET", f"/images/{image_id}/json")
102
+ digest = ""
103
+ ref = HERMES_IMAGE
104
+ if ist == 200 and isinstance(idata, dict):
105
+ digests = idata.get("RepoDigests") or []
106
+ if digests:
107
+ digest = str(digests[0]).split("@")[-1]
108
+ tags = idata.get("RepoTags") or []
109
+ if tags:
110
+ ref = str(tags[0])
111
+ return digest, ref
112
+
113
+
114
+ def _read_installed_workframe_version(project_root: Path) -> dict[str, str]:
115
+ out = {"api": API_VERSION, "package": "", "manifest_generator": ""}
116
+ manifest = project_root / "workframe-manifest.json"
117
+ if manifest.is_file():
118
+ try:
119
+ data = json.loads(manifest.read_text(encoding="utf-8"))
120
+ out["package"] = str(data.get("package_version") or "")
121
+ out["manifest_generator"] = str(data.get("generator") or "")
122
+ except Exception: # noqa: BLE001
123
+ pass
124
+ if not out["api"]:
125
+ try:
126
+ import server as _server # noqa: WPS433
127
+
128
+ out["api"] = str(getattr(_server, "VERSION", ""))
129
+ except Exception: # noqa: BLE001
130
+ pass
131
+ if not out["package"]:
132
+ out["package"] = out["api"]
133
+ return out
134
+
135
+
136
+ def _compose_dir() -> Path:
137
+ for raw in (
138
+ os.environ.get("WORKFRAME_HOST_COMPOSE_DIR", ""),
139
+ os.environ.get("WORKFRAME_COMPOSE_DIR", ""),
140
+ os.environ.get("WORKFRAME_PROJECT_ROOT", ""),
141
+ "/compose",
142
+ "/project",
143
+ ):
144
+ p = Path(str(raw or "").strip())
145
+ if p.is_dir() and (p / "docker-compose.yml").is_file():
146
+ return p
147
+ return Path(".")
148
+
149
+
150
+ def _project_root() -> Path:
151
+ for raw in (os.environ.get("WORKFRAME_PROJECT_ROOT", ""), "/project", os.environ.get("WORKFRAME_COMPOSE_DIR", "")):
152
+ p = Path(str(raw or "").strip())
153
+ if p.is_dir() and (p / "workframe-manifest.json").is_file():
154
+ return p
155
+ for raw in (os.environ.get("WORKFRAME_PROJECT_ROOT", ""), "/project", os.environ.get("WORKFRAME_COMPOSE_DIR", "")):
156
+ p = Path(str(raw or "").strip())
157
+ if p.is_dir() and (p / "docker-compose.yml").is_file():
158
+ return p
159
+ return _compose_dir()
160
+
161
+
162
+ def _script_path(name: str) -> Path | None:
163
+ roots = [
164
+ Path(f"/opt/install/scripts/{name}"),
165
+ Path(f"/opt/install/scripts/workframe/{name}"),
166
+ ]
167
+ mode = str(os.environ.get("WORKFRAME_DEPLOYMENT_MODE") or "trusted_team").strip().lower()
168
+ if mode == "single_user_local":
169
+ roots.extend(
170
+ [
171
+ _project_root() / "scripts" / "workframe" / name,
172
+ _project_root() / "scripts" / name,
173
+ ],
174
+ )
175
+ for path in roots:
176
+ if path.is_file():
177
+ return path
178
+ return None
179
+
180
+
181
+ def _host_compose_ready() -> bool:
182
+ host_raw = str(os.environ.get("WORKFRAME_HOST_COMPOSE_DIR", "")).strip()
183
+ if not host_raw:
184
+ return False
185
+ host = Path(host_raw)
186
+ if host.is_dir() and (host / "docker-compose.yml").is_file():
187
+ return True
188
+ # ponytail: Windows host paths are not visible inside the API container — trust /compose mount
189
+ compose = _compose_dir()
190
+ return compose.joinpath("docker-compose.yml").is_file()
191
+
192
+
193
+ def _supervisor_configured() -> bool:
194
+ return bool(os.environ.get("WORKFRAME_SUPERVISOR_URL", "").strip()) and bool(
195
+ os.environ.get("WORKFRAME_SUPERVISOR_TOKEN", "").strip()
196
+ )
197
+
198
+
199
+ def _supervisor_gateway_image_digest() -> tuple[str, str]:
200
+ """Read gateway image digest via workframe-supervisor (SECURE_MODE API has no docker.sock)."""
201
+ base = str(os.environ.get("WORKFRAME_SUPERVISOR_URL", "")).rstrip("/")
202
+ token = str(os.environ.get("WORKFRAME_SUPERVISOR_TOKEN", "")).strip()
203
+ if not base or not token:
204
+ return "", ""
205
+ req = urllib.request.Request(
206
+ f"{base}/v1/gateway.image",
207
+ headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "User-Agent": "workframe-api"},
208
+ )
209
+ try:
210
+ with urllib.request.urlopen(req, timeout=15.0) as resp:
211
+ data = json.loads(resp.read().decode("utf-8"))
212
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
213
+ return "", ""
214
+ if not isinstance(data, dict) or not data.get("ok"):
215
+ return "", ""
216
+ return str(data.get("digest") or "").strip(), str(data.get("ref") or "").strip()
217
+
218
+
219
+ def _admin_stack_updates_enabled() -> bool:
220
+ if os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") == "1":
221
+ return True
222
+ return _supervisor_configured()
223
+
224
+
225
+ def _docker_apply_ready() -> tuple[bool, str | None]:
226
+ if not Path(DOCKER_SOCK).exists():
227
+ return False, "Docker socket is not available to the API container."
228
+ if not _compose_dir().joinpath("docker-compose.yml").is_file():
229
+ return False, "docker-compose.yml was not found for this stack."
230
+ if not _host_compose_ready():
231
+ return False, (
232
+ "Set WORKFRAME_HOST_COMPOSE_DIR to the host compose folder so updates run on the Docker host."
233
+ )
234
+ return True, None
235
+
236
+
237
+ def _update_apply_channel() -> tuple[str, bool, str | None]:
238
+ """Returns (channel, ready, reason). channel: api_docker | supervisor | none."""
239
+ api_docker = Path(DOCKER_SOCK).exists()
240
+ if api_docker:
241
+ ok, reason = _docker_apply_ready()
242
+ if ok:
243
+ return "api_docker", True, None
244
+ if not _supervisor_configured():
245
+ return "none", False, reason
246
+ if _supervisor_configured():
247
+ if _script_path("apply-update-workframe.sh") is None and _script_path("apply-update-hermes.sh") is None:
248
+ return "supervisor", False, "Stack update scripts are missing from this install."
249
+ return "supervisor", True, None
250
+ if api_docker:
251
+ _, reason = _docker_apply_ready()
252
+ return "none", False, reason
253
+ return (
254
+ "none",
255
+ False,
256
+ "In-place updates need workframe-supervisor or Docker on the stack host.",
257
+ )
258
+
259
+
260
+ def _product_state(*, update_available: bool, can_update: bool) -> str:
261
+ if update_available and can_update:
262
+ return "available"
263
+ if update_available:
264
+ return "blocked"
265
+ return "current"
266
+
267
+
268
+ def parse_hermes_version_output(text: str) -> str:
269
+ """Extract semver from `hermes --version` stdout."""
270
+ match = re.search(r"Hermes Agent v(\d+\.\d+\.\d+)", str(text or ""))
271
+ return match.group(1) if match else ""
272
+
273
+
274
+ def _read_hermes_agent_version() -> str:
275
+ """Native Hermes semver via gateway exec (lazy import avoids server load cycle)."""
276
+ try:
277
+ import server as _server # noqa: WPS433
278
+
279
+ return _server._hermes_agent_version()
280
+ except Exception: # noqa: BLE001
281
+ return ""
282
+
283
+
284
+ def _releases_manifest() -> dict[str, Any]:
285
+ if not RELEASES_URL:
286
+ return {}
287
+ try:
288
+ return _http_json(RELEASES_URL)
289
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
290
+ return {}
291
+
292
+
293
+ def updates_available(*, desktop_version: str = "", hermes_agent_version: str = "") -> dict[str, Any]:
294
+ compose_dir = _compose_dir()
295
+ project_root = _project_root()
296
+ api_docker = Path(DOCKER_SOCK).exists()
297
+ supervisor_ok = _supervisor_configured()
298
+ apply_channel, apply_ready, apply_reason = _update_apply_channel()
299
+ installed = _read_installed_workframe_version(project_root)
300
+
301
+ npm_latest = ""
302
+ try:
303
+ npm_latest = _npm_latest_version()
304
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
305
+ pass
306
+
307
+ releases = _releases_manifest()
308
+ workframe_latest = str(releases.get("workframe") or releases.get("create_workframe") or npm_latest or "")
309
+ desktop_latest = str(releases.get("desktop") or os.environ.get("WORKFRAME_DESKTOP_LATEST", "0.1.0"))
310
+
311
+ installed_pkg = installed.get("package") or installed.get("api") or ""
312
+ workframe_update = bool(workframe_latest and _version_lt(installed_pkg, workframe_latest))
313
+
314
+ hermes_digest, hermes_ref = ("", "")
315
+ if api_docker:
316
+ hermes_digest, hermes_ref = _container_image_digest(GATEWAY_CONTAINER)
317
+ elif supervisor_ok:
318
+ hermes_digest, hermes_ref = _supervisor_gateway_image_digest()
319
+ hermes_tag = hermes_ref.rsplit(":", 1)[-1] if hermes_ref and ":" in hermes_ref else HERMES_TAG
320
+ hermes_latest_digest = ""
321
+ try:
322
+ hermes_latest_digest = _docker_hub_digest(HERMES_IMAGE, HERMES_TAG)
323
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
324
+ pass
325
+ hermes_image_known = bool(api_docker or supervisor_ok)
326
+ hermes_update = bool(
327
+ hermes_image_known
328
+ and hermes_latest_digest
329
+ and hermes_digest
330
+ and hermes_digest != hermes_latest_digest,
331
+ )
332
+
333
+ desktop_installed = str(desktop_version or "").strip()
334
+ desktop_update = bool(desktop_latest and desktop_installed and _version_lt(desktop_installed, desktop_latest))
335
+
336
+ digest_short = hermes_latest_digest
337
+ if len(digest_short) > 28:
338
+ digest_short = digest_short[:28] + "…"
339
+
340
+ hermes_script_ok = _script_path("apply-update-hermes.sh") is not None
341
+ workframe_script_ok = _script_path("apply-update-workframe.sh") is not None
342
+ hermes_can_update = bool(apply_ready and hermes_script_ok)
343
+ workframe_can_update = bool(apply_ready and workframe_script_ok)
344
+ hermes_reason = apply_reason
345
+ if not hermes_reason and hermes_update and not hermes_script_ok:
346
+ hermes_reason = "Hermes update script is missing from this install."
347
+ workframe_reason = apply_reason
348
+ if not workframe_reason and workframe_update and not workframe_script_ok:
349
+ workframe_reason = "Workframe update script is missing from this install."
350
+ if not workframe_reason and workframe_update and not workframe_latest:
351
+ workframe_reason = "No published npm release to update to yet."
352
+
353
+ agent_version = str(hermes_agent_version or "").strip() or _read_hermes_agent_version()
354
+ hermes_current = agent_version or hermes_tag
355
+
356
+ return {
357
+ "ok": True,
358
+ "docker_available": apply_ready,
359
+ "docker_sock_on_api": api_docker,
360
+ "supervisor_configured": supervisor_ok,
361
+ "update_apply_channel": apply_channel if apply_ready else None,
362
+ "update_apply_ready": apply_ready,
363
+ "compose_dir": str(compose_dir),
364
+ "project_root": str(project_root),
365
+ "workframe": {
366
+ "current": installed_pkg,
367
+ "latest": workframe_latest,
368
+ "update_available": workframe_update,
369
+ "can_update": workframe_can_update,
370
+ "state": _product_state(update_available=workframe_update, can_update=workframe_can_update),
371
+ "reason": workframe_reason,
372
+ "update_mode": "docker-compose-rebuild",
373
+ "install_kind": "docker",
374
+ "components": ["ui", "api", "supervisor"],
375
+ },
376
+ "hermes": {
377
+ "current": hermes_current,
378
+ "agent_version": agent_version,
379
+ "image_tag": hermes_tag,
380
+ "latest": "",
381
+ "current_image": hermes_ref,
382
+ "current_digest": hermes_digest[:28] + "…" if len(hermes_digest) > 28 else hermes_digest,
383
+ "latest_digest": digest_short,
384
+ "image": f"{HERMES_IMAGE}:{HERMES_TAG}",
385
+ "update_available": hermes_update,
386
+ "can_update": hermes_can_update,
387
+ "state": _product_state(update_available=hermes_update, can_update=hermes_can_update),
388
+ "reason": hermes_reason,
389
+ "update_mode": "docker-compose-pull",
390
+ "install_kind": "docker",
391
+ "can_restart_gateway": bool(apply_ready and _script_path("restart-gateway-hermes.sh") is not None),
392
+ },
393
+ "desktop": {
394
+ "current": desktop_installed,
395
+ "latest": desktop_latest,
396
+ "update_available": desktop_update,
397
+ "can_update": False,
398
+ "state": "available" if desktop_update else "current",
399
+ "reason": "Desktop updates are distributed separately from the Docker stack.",
400
+ "update_mode": "manual-download",
401
+ "install_kind": "desktop",
402
+ "download_url": str(releases.get("desktop_download_url") or ""),
403
+ },
404
+ }
405
+
406
+
407
+ def _apply_env_for_target(target: str) -> dict[str, str]:
408
+ env = os.environ.copy()
409
+ env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
410
+ env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
411
+ if target in {"workframe", "all"}:
412
+ latest = ""
413
+ try:
414
+ latest = str(updates_available().get("workframe", {}).get("latest") or "").strip()
415
+ except Exception: # noqa: BLE001
416
+ latest = ""
417
+ if latest:
418
+ env["WORKFRAME_UPDATE_ALLOW_NPM"] = "1"
419
+ env["WORKFRAME_UPDATE_VERSION"] = latest
420
+ return env
421
+
422
+
423
+ def apply_update(target: str) -> dict[str, Any]:
424
+ if not _admin_stack_updates_enabled():
425
+ raise ValueError("admin_updates_disabled")
426
+ target = str(target or "all").strip().lower()
427
+ if target not in {"hermes", "workframe", "all"}:
428
+ raise ValueError("invalid_update_target")
429
+ _, apply_ready, apply_reason = _update_apply_channel()
430
+ if not apply_ready:
431
+ if not Path(DOCKER_SOCK).exists():
432
+ raise ValueError("docker_unavailable")
433
+ raise ValueError(str(apply_reason or "docker_apply_unavailable"))
434
+ if not Path(DOCKER_SOCK).exists():
435
+ raise ValueError("docker_unavailable")
436
+
437
+ scripts: list[str] = []
438
+ if target in {"hermes", "all"}:
439
+ script = _script_path("apply-update-hermes.sh")
440
+ if not script:
441
+ raise ValueError("update_script_missing:hermes")
442
+ scripts.append(str(script))
443
+ if target in {"workframe", "all"}:
444
+ script = _script_path("apply-update-workframe.sh")
445
+ if not script:
446
+ raise ValueError("update_script_missing:workframe")
447
+ scripts.append(str(script))
448
+
449
+ env = _apply_env_for_target(target)
450
+
451
+ logs: list[str] = []
452
+ for script in scripts:
453
+ proc = subprocess.run(
454
+ ["bash", script],
455
+ capture_output=True,
456
+ text=True,
457
+ timeout=900,
458
+ env=env,
459
+ cwd=env["WORKFRAME_COMPOSE_DIR"],
460
+ )
461
+ logs.append(f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}")
462
+ if proc.returncode != 0:
463
+ raise ValueError(f"update_failed:{Path(script).name}")
464
+
465
+ return {"ok": True, "target": target, "log": "\n".join(logs)[-12000:]}
466
+
467
+
468
+ def restart_gateway() -> dict[str, Any]:
469
+ if not _admin_stack_updates_enabled():
470
+ raise ValueError("admin_updates_disabled")
471
+ _, apply_ready, apply_reason = _update_apply_channel()
472
+ if not apply_ready:
473
+ if not Path(DOCKER_SOCK).exists():
474
+ raise ValueError("docker_unavailable")
475
+ raise ValueError(str(apply_reason or "docker_apply_unavailable"))
476
+ if not Path(DOCKER_SOCK).exists():
477
+ raise ValueError("docker_unavailable")
478
+ script = _script_path("restart-gateway-hermes.sh")
479
+ if not script:
480
+ raise ValueError("restart_script_missing:gateway")
481
+
482
+ env = os.environ.copy()
483
+ env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
484
+ env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
485
+ proc = subprocess.run(
486
+ ["bash", str(script)],
487
+ capture_output=True,
488
+ text=True,
489
+ timeout=300,
490
+ env=env,
491
+ cwd=env["WORKFRAME_COMPOSE_DIR"],
492
+ )
493
+ log = f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}"
494
+ if proc.returncode != 0:
495
+ raise ValueError("restart_failed:gateway")
496
+ return {"ok": True, "target": "gateway", "log": log[-12000:]}
497
+
498
+
499
+ if __name__ == "__main__":
500
+ assert _version_lt("0.1.0", "0.1.1")
501
+ assert not _version_lt("0.1.0", "0.1.0")
502
+ assert parse_hermes_version_output("Hermes Agent v0.17.0 (2026.6.19)") == "0.17.0"
503
+ ch, ready, _ = _update_apply_channel()
504
+ assert ch in {"api_docker", "supervisor", "none"}
505
+ assert ready is False or ch != "none"
506
+ print("updates module ok")