create-workframe 0.1.8 → 0.1.10

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Published on npm as **create-workframe**.
4
4
 
5
5
  ```bash
6
- npx create-workframe@0.1.8 MyProject
6
+ npx create-workframe@0.1.9 MyProject
7
7
  ```
8
8
 
9
9
  Scaffolds an isolated Workframe + Hermes project on Windows, macOS, and Linux.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-workframe",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Scaffold a Workframe + Hermes workspace with guided onboarding",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,6 +43,60 @@ _wf_sync_tree() {
43
43
  cp -a "${src}/." "${dest}/"
44
44
  }
45
45
 
46
+ _wf_sync_from_pack_dir() {
47
+ local pkg="$1"
48
+ if [[ -d "$pkg/workframe-api" ]]; then
49
+ echo "Syncing workframe-api -> $API_DIR"
50
+ _wf_sync_tree "$pkg/workframe-api" "$API_DIR"
51
+ fi
52
+ if [[ -d "$pkg/workframe-supervisor" ]]; then
53
+ echo "Syncing workframe-supervisor -> $SUP_DIR"
54
+ _wf_sync_tree "$pkg/workframe-supervisor" "$SUP_DIR"
55
+ fi
56
+ if [[ -d "$pkg/workframe-ui/public" ]]; then
57
+ echo "Syncing workframe-ui/public -> $UI_DIR"
58
+ _wf_sync_tree "$pkg/workframe-ui/public" "$UI_DIR"
59
+ fi
60
+ for script in apply-update-hermes.sh apply-update-workframe.sh restart-gateway-hermes.sh compose-docker-host.sh update-hermes.sh; do
61
+ if [[ -f "$pkg/scripts/$script" ]]; then
62
+ cp -a "$pkg/scripts/$script" "$SCRIPTS_DIR/$script"
63
+ chmod +x "$SCRIPTS_DIR/$script" 2>/dev/null || true
64
+ fi
65
+ done
66
+ }
67
+
68
+ _wf_apply_npm_tarball() {
69
+ local tarball="$1"
70
+ local ver="${2:-latest}"
71
+ if ! _wf_npm_integrity_ok "$tarball" "$NPM_PACKAGE" "$ver"; then
72
+ echo "integrity mismatch for ${NPM_PACKAGE}@${ver}" >&2
73
+ exit 1
74
+ fi
75
+ echo "integrity ok: ${NPM_PACKAGE}@${ver}"
76
+ local extract_dir
77
+ extract_dir="$(mktemp -d)"
78
+ tar -xf "$tarball" -C "$extract_dir"
79
+ _wf_sync_from_pack_dir "$extract_dir/package"
80
+ rm -rf "$extract_dir"
81
+ }
82
+
83
+ _wf_record_package_version() {
84
+ local ver="$1"
85
+ [[ -n "$ver" ]] || return 0
86
+ mkdir -p "$API_DIR/data"
87
+ printf '%s\n' "$ver" > "$API_DIR/data/package-version"
88
+ if [[ -f "$PROJECT_ROOT/workframe-manifest.json" ]] && command -v python3 >/dev/null 2>&1; then
89
+ python3 -c 'import json, sys
90
+ from pathlib import Path
91
+ p = Path(sys.argv[1])
92
+ ver = sys.argv[2]
93
+ data = json.loads(p.read_text(encoding="utf-8"))
94
+ data["package_version"] = ver
95
+ data["generator"] = f"create-workframe@{ver}"
96
+ p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")' "$PROJECT_ROOT/workframe-manifest.json" "$ver"
97
+ fi
98
+ }
99
+
46
100
  _wf_install_paths() {
47
101
  local root="$1"
48
102
  if [[ -d "$root/services/workframe-api" ]]; then
@@ -88,8 +142,12 @@ echo "Preserves: Agents/, Files/, .env, workframe-api/data, gateway/Hermes profi
88
142
 
89
143
  TARGET_VERSION="${WORKFRAME_UPDATE_VERSION:-}"
90
144
  NPM_PACKAGE="${WORKFRAME_NPM_PACKAGE:-create-workframe}"
145
+ PREFETCH_TARBALL="${WORKFRAME_UPDATE_TARBALL:-}"
91
146
 
92
- if [[ "${WORKFRAME_UPDATE_SKIP_NPM:-1}" == "1" ]] && [[ "${WORKFRAME_UPDATE_ALLOW_NPM:-}" != "1" ]]; then
147
+ if [[ -n "$PREFETCH_TARBALL" && -f "$PREFETCH_TARBALL" ]]; then
148
+ echo "Applying API-prefetched tarball: $PREFETCH_TARBALL"
149
+ _wf_apply_npm_tarball "$PREFETCH_TARBALL" "${TARGET_VERSION:-latest}"
150
+ elif [[ "${WORKFRAME_UPDATE_SKIP_NPM:-1}" == "1" ]] && [[ "${WORKFRAME_UPDATE_ALLOW_NPM:-}" != "1" ]]; then
93
151
  echo "Skipping npm template sync (WORKFRAME_UPDATE_SKIP_NPM=1; set WORKFRAME_UPDATE_ALLOW_NPM=1 to fetch)"
94
152
  elif command -v npm >/dev/null 2>&1; then
95
153
  if [[ "${WORKFRAME_UPDATE_ALLOW_NPM:-}" == "1" ]] && [[ -z "$TARGET_VERSION" ]]; then
@@ -99,36 +157,16 @@ elif command -v npm >/dev/null 2>&1; then
99
157
  TMP="$(mktemp -d)"
100
158
  trap 'rm -rf "$TMP"' EXIT
101
159
  echo "Fetching ${NPM_PACKAGE}@${TARGET_VERSION:-latest} from npm..."
102
- (cd "$TMP" && npm pack "${NPM_PACKAGE}@${TARGET_VERSION:-latest}" --silent)
160
+ if ! (cd "$TMP" && npm pack "${NPM_PACKAGE}@${TARGET_VERSION:-latest}" --silent); then
161
+ echo "npm pack failed (supervisor control-net has no registry access — use API prefetch)" >&2
162
+ exit 1
163
+ fi
103
164
  TARBALL="$(ls -1 "$TMP"/${NPM_PACKAGE}-*.tgz 2>/dev/null | head -n1 || true)"
104
165
  if [[ -n "$TARBALL" ]]; then
105
- if ! _wf_npm_integrity_ok "$TARBALL" "$NPM_PACKAGE" "${TARGET_VERSION:-latest}"; then
106
- echo "integrity mismatch for ${NPM_PACKAGE}@${TARGET_VERSION:-latest}" >&2
107
- exit 1
108
- fi
109
- echo "integrity ok: ${NPM_PACKAGE}@${TARGET_VERSION:-latest}"
110
- tar -xf "$TARBALL" -C "$TMP"
111
- PKG="$TMP/package"
112
- if [[ -d "$PKG/workframe-api" ]]; then
113
- echo "Syncing workframe-api -> $API_DIR"
114
- _wf_sync_tree "$PKG/workframe-api" "$API_DIR"
115
- fi
116
- if [[ -d "$PKG/workframe-supervisor" ]]; then
117
- echo "Syncing workframe-supervisor -> $SUP_DIR"
118
- _wf_sync_tree "$PKG/workframe-supervisor" "$SUP_DIR"
119
- fi
120
- if [[ -d "$PKG/workframe-ui/public" ]]; then
121
- echo "Syncing workframe-ui/public -> $UI_DIR"
122
- _wf_sync_tree "$PKG/workframe-ui/public" "$UI_DIR"
123
- fi
124
- for script in apply-update-hermes.sh apply-update-workframe.sh restart-gateway-hermes.sh compose-docker-host.sh update-hermes.sh; do
125
- if [[ -f "$PKG/scripts/$script" ]]; then
126
- cp -a "$PKG/scripts/$script" "$SCRIPTS_DIR/$script"
127
- chmod +x "$SCRIPTS_DIR/$script" 2>/dev/null || true
128
- fi
129
- done
166
+ _wf_apply_npm_tarball "$TARBALL" "${TARGET_VERSION:-latest}"
130
167
  else
131
- echo "npm pack produced no tarball — skipping template sync"
168
+ echo "npm pack produced no tarball — skipping template sync" >&2
169
+ exit 1
132
170
  fi
133
171
  else
134
172
  echo "Skipping npm template sync (npm missing or WORKFRAME_UPDATE_SKIP_NPM=1)"
@@ -136,7 +174,16 @@ fi
136
174
 
137
175
  echo "Rebuilding workframe-api and workframe-supervisor..."
138
176
  workframe_compose build workframe-api workframe-supervisor
139
- workframe_compose up -d --build --no-deps workframe-api workframe-supervisor
177
+ if [[ "${WORKFRAME_UPDATE_FROM_SUPERVISOR:-}" == "1" ]]; then
178
+ # ponytail: supervisor cannot recreate itself inside the stack.apply request — defer after handler returns
179
+ workframe_compose up -d --build --no-deps workframe-api
180
+ (
181
+ sleep 3
182
+ workframe_compose up -d --no-deps workframe-supervisor
183
+ ) >/tmp/workframe-supervisor-restart.log 2>&1 &
184
+ else
185
+ workframe_compose up -d --build --no-deps workframe-api workframe-supervisor
186
+ fi
140
187
 
141
188
  if workframe_compose config --services 2>/dev/null | grep -qx workframe-ui; then
142
189
  workframe_compose up -d --no-deps workframe-ui || workframe_compose restart workframe-ui || true
@@ -144,4 +191,6 @@ elif workframe_compose config --services 2>/dev/null | grep -qx workframe; then
144
191
  workframe_compose up -d --no-deps workframe || workframe_compose restart workframe || true
145
192
  fi
146
193
 
194
+ _wf_record_package_version "${TARGET_VERSION:-}"
195
+
147
196
  echo "=== Workframe update complete ==="
@@ -6,12 +6,18 @@ workframe_compose_prepare() {
6
6
  compose_cd=""
7
7
  compose_files=()
8
8
 
9
- # Host-bindings overlay: docker.sock apply from supervisor uses WORKFRAME_HOST_* paths.
9
+ # Host-bindings overlay: absolute WORKFRAME_HOST_* paths for docker.sock on the real host.
10
+ # Skip inside supervisor when only the /compose bind mount is visible — host paths become
11
+ # /compose/D:/... and break build contexts.
10
12
  if [[ -n "${WORKFRAME_HOST_COMPOSE_DIR:-}" && -n "${WORKFRAME_COMPOSE_DIR:-}" \
11
13
  && -f "${WORKFRAME_COMPOSE_DIR}/docker-compose.yml" \
12
14
  && -f "${WORKFRAME_COMPOSE_DIR}/docker-compose.host-bindings.yml" ]]; then
13
15
  compose_cd="${WORKFRAME_COMPOSE_DIR}"
14
- compose_files=(-f docker-compose.yml -f docker-compose.host-bindings.yml)
16
+ if [[ -d "${WORKFRAME_HOST_COMPOSE_DIR}" ]]; then
17
+ compose_files=(-f docker-compose.yml -f docker-compose.host-bindings.yml)
18
+ else
19
+ compose_files=(-f docker-compose.yml)
20
+ fi
15
21
  return 0
16
22
  fi
17
23
 
@@ -0,0 +1,4 @@
1
+ data/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workframe/workframe-api",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -551,44 +551,11 @@ def _admin_write_allowed(handler: BaseHTTPRequestHandler) -> bool:
551
551
 
552
552
 
553
553
  def _apply_stack_update(target: str) -> dict[str, Any]:
554
- workframe_version = ""
555
- if target in {"workframe", "all"}:
556
- try:
557
- workframe_version = str(
558
- stack_updates.updates_available().get("workframe", {}).get("latest") or ""
559
- ).strip()
560
- except Exception: # noqa: BLE001
561
- workframe_version = ""
562
- try:
563
- return stack_updates.apply_update(target)
564
- except ValueError as exc:
565
- err = str(exc)
566
- if not _supervisor_ready():
567
- raise
568
- if err not in ("docker_unavailable", "admin_updates_disabled"):
569
- raise
570
- body: dict[str, Any] = {"target": target}
571
- if workframe_version:
572
- body["workframe_version"] = workframe_version
573
- status, data = _supervisor_request("POST", "/v1/stack.apply", body, timeout=900.0)
574
- if status >= 300 or not isinstance(data, dict) or not data.get("ok"):
575
- raise ValueError(str((data or {}).get("error") or "supervisor_apply_failed"))
576
- return data
554
+ return stack_updates.apply_update(target)
577
555
 
578
556
 
579
557
  def _restart_stack_gateway() -> dict[str, Any]:
580
- try:
581
- return stack_updates.restart_gateway()
582
- except ValueError as exc:
583
- # ponytail: SECURE_MODE API has no docker.sock; supervisor owns gateway restart
584
- if str(exc) not in ("docker_unavailable", "admin_updates_disabled") or not _supervisor_ready():
585
- raise
586
- status, data = _supervisor_request(
587
- "POST", "/v1/stack.apply", {"target": "gateway-restart"}, timeout=300.0,
588
- )
589
- if status >= 300 or not isinstance(data, dict) or not data.get("ok"):
590
- raise ValueError(str((data or {}).get("error") or "supervisor_restart_failed"))
591
- return data
558
+ return stack_updates.restart_gateway()
592
559
 
593
560
 
594
561
  def _cors_origin_for(headers=None) -> str:
@@ -53,6 +53,55 @@ def _npm_latest_version() -> str:
53
53
  return str(data.get("version") or "").strip()
54
54
 
55
55
 
56
+ def _supervisor_tarball_path(package: str, version: str) -> str:
57
+ """Path on the compose bind mount — supervisor reads /compose even when API lacks that mount."""
58
+ for raw in (os.environ.get("WORKFRAME_COMPOSE_DIR", ""), "/compose"):
59
+ root = str(raw or "").strip().rstrip("/")
60
+ if root and root != ".":
61
+ return f"{root}/workframe-api/data/.update-staging/{package}-{version}.tgz"
62
+ data_dir = Path(os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data"))
63
+ return str(data_dir / ".update-staging" / f"{package}-{version}.tgz")
64
+
65
+
66
+ def prefetch_workframe_npm_tarball(version: str) -> str:
67
+ """Download create-workframe pack to API data dir; return supervisor-visible path."""
68
+ import base64
69
+ import hashlib
70
+
71
+ ver = str(version or "").strip()
72
+ if not ver:
73
+ raise ValueError("workframe_version_required")
74
+ pkg = NPM_PACKAGE
75
+ try:
76
+ meta = _http_json(
77
+ f"https://registry.npmjs.org/{urllib.parse.quote(pkg)}/{urllib.parse.quote(ver)}",
78
+ timeout=60.0,
79
+ )
80
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc:
81
+ raise ValueError(f"npm_fetch_failed:{exc}") from exc
82
+ dist = meta.get("dist") if isinstance(meta.get("dist"), dict) else {}
83
+ url = str(dist.get("tarball") or "").strip()
84
+ integrity = str(dist.get("integrity") or "").strip()
85
+ if not url:
86
+ raise ValueError("npm_tarball_url_missing")
87
+ data_dir = Path(os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data"))
88
+ staging = data_dir / ".update-staging"
89
+ staging.mkdir(parents=True, exist_ok=True)
90
+ dest = staging / f"{pkg}-{ver}.tgz"
91
+ req = urllib.request.Request(url, headers={"User-Agent": "workframe-api"})
92
+ try:
93
+ with urllib.request.urlopen(req, timeout=300.0) as resp:
94
+ body = resp.read()
95
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
96
+ raise ValueError(f"npm_download_failed:{exc}") from exc
97
+ if integrity.startswith("sha512-"):
98
+ actual = "sha512-" + base64.b64encode(hashlib.sha512(body).digest()).decode()
99
+ if actual != integrity:
100
+ raise ValueError("npm_integrity_mismatch")
101
+ dest.write_bytes(body)
102
+ return _supervisor_tarball_path(pkg, ver)
103
+
104
+
56
105
  def _docker_hub_digest(repo: str, tag: str) -> str:
57
106
  url = f"https://hub.docker.com/v2/repositories/{repo}/tags/{urllib.parse.quote(tag)}"
58
107
  data = _http_json(url)
@@ -113,8 +162,11 @@ def _container_image_digest(name: str) -> tuple[str, str]:
113
162
 
114
163
  def _read_installed_workframe_version(project_root: Path) -> dict[str, str]:
115
164
  out = {"api": API_VERSION, "package": "", "manifest_generator": ""}
165
+ pin = Path(os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data")) / "package-version"
166
+ if pin.is_file():
167
+ out["package"] = pin.read_text(encoding="utf-8").strip()
116
168
  manifest = project_root / "workframe-manifest.json"
117
- if manifest.is_file():
169
+ if not out["package"] and manifest.is_file():
118
170
  try:
119
171
  data = json.loads(manifest.read_text(encoding="utf-8"))
120
172
  out["package"] = str(data.get("package_version") or "")
@@ -404,36 +456,64 @@ def updates_available(*, desktop_version: str = "", hermes_agent_version: str =
404
456
  }
405
457
 
406
458
 
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 = ""
459
+ def _supervisor_stack_apply(body: dict[str, Any], *, timeout: float = 900.0) -> dict[str, Any]:
460
+ if not _supervisor_configured():
461
+ raise ValueError("supervisor_not_configured")
462
+ base = str(os.environ.get("WORKFRAME_SUPERVISOR_URL", "")).rstrip("/")
463
+ token = str(os.environ.get("WORKFRAME_SUPERVISOR_TOKEN", "")).strip()
464
+ payload = json.dumps(body).encode("utf-8")
465
+ req = urllib.request.Request(
466
+ f"{base}/v1/stack.apply",
467
+ data=payload,
468
+ method="POST",
469
+ headers={
470
+ "Authorization": f"Bearer {token}",
471
+ "Content-Type": "application/json",
472
+ "Accept": "application/json",
473
+ "User-Agent": "workframe-api",
474
+ },
475
+ )
476
+ try:
477
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
478
+ data = json.loads(resp.read().decode("utf-8"))
479
+ except urllib.error.HTTPError as exc:
480
+ detail = exc.read().decode("utf-8", errors="replace")
413
481
  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
482
+ parsed = json.loads(detail)
483
+ if isinstance(parsed, dict) and parsed.get("error"):
484
+ raise ValueError(str(parsed["error"])) from exc
485
+ except json.JSONDecodeError:
486
+ pass
487
+ raise ValueError(f"supervisor_apply_failed:{exc.code}") from exc
488
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc:
489
+ raise ValueError(f"supervisor_apply_failed:{exc}") from exc
490
+ if not isinstance(data, dict) or not data.get("ok"):
491
+ raise ValueError(str(data.get("error") or "supervisor_apply_failed"))
492
+ return data
421
493
 
422
494
 
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")
495
+ def _workframe_update_target_version() -> str:
496
+ try:
497
+ return str(updates_available().get("workframe", {}).get("latest") or "").strip()
498
+ except Exception: # noqa: BLE001
499
+ return ""
500
+
501
+
502
+ def _visible_tarball_path(package: str, version: str, *, channel: str) -> str:
503
+ if channel == "supervisor":
504
+ return _supervisor_tarball_path(package, version)
505
+ return str(Path(os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data")) / ".update-staging" / f"{package}-{version}.tgz")
436
506
 
507
+
508
+ def _prepare_workframe_update(channel: str) -> tuple[str, str]:
509
+ version = _workframe_update_target_version()
510
+ if not version:
511
+ raise ValueError("workframe_update_version_unavailable")
512
+ prefetch_workframe_npm_tarball(version)
513
+ return version, _visible_tarball_path(NPM_PACKAGE, version, channel=channel)
514
+
515
+
516
+ def _run_apply_scripts(target: str, env: dict[str, str]) -> dict[str, Any]:
437
517
  scripts: list[str] = []
438
518
  if target in {"hermes", "all"}:
439
519
  script = _script_path("apply-update-hermes.sh")
@@ -446,8 +526,6 @@ def apply_update(target: str) -> dict[str, Any]:
446
526
  raise ValueError("update_script_missing:workframe")
447
527
  scripts.append(str(script))
448
528
 
449
- env = _apply_env_for_target(target)
450
-
451
529
  logs: list[str] = []
452
530
  for script in scripts:
453
531
  proc = subprocess.run(
@@ -461,18 +539,53 @@ def apply_update(target: str) -> dict[str, Any]:
461
539
  logs.append(f"=== {script} (exit {proc.returncode}) ===\n{proc.stdout}\n{proc.stderr}")
462
540
  if proc.returncode != 0:
463
541
  raise ValueError(f"update_failed:{Path(script).name}")
464
-
465
542
  return {"ok": True, "target": target, "log": "\n".join(logs)[-12000:]}
466
543
 
467
544
 
545
+ def apply_update(target: str) -> dict[str, Any]:
546
+ if not _admin_stack_updates_enabled():
547
+ raise ValueError("admin_updates_disabled")
548
+ target = str(target or "all").strip().lower()
549
+ if target not in {"hermes", "workframe", "all"}:
550
+ raise ValueError("invalid_update_target")
551
+ channel, apply_ready, apply_reason = _update_apply_channel()
552
+ if not apply_ready:
553
+ raise ValueError(str(apply_reason or "docker_apply_unavailable"))
554
+
555
+ workframe_version = ""
556
+ workframe_tarball = ""
557
+ if target in {"workframe", "all"}:
558
+ workframe_version, workframe_tarball = _prepare_workframe_update(channel)
559
+
560
+ if channel == "supervisor":
561
+ body: dict[str, Any] = {"target": target}
562
+ if workframe_version:
563
+ body["workframe_version"] = workframe_version
564
+ if workframe_tarball:
565
+ body["workframe_tarball"] = workframe_tarball
566
+ return _supervisor_stack_apply(body)
567
+
568
+ if not Path(DOCKER_SOCK).exists():
569
+ raise ValueError("docker_unavailable")
570
+
571
+ env = os.environ.copy()
572
+ env.setdefault("WORKFRAME_COMPOSE_DIR", str(_compose_dir()))
573
+ env.setdefault("WORKFRAME_PROJECT_ROOT", str(_project_root()))
574
+ if workframe_version:
575
+ env["WORKFRAME_UPDATE_VERSION"] = workframe_version
576
+ if workframe_tarball:
577
+ env["WORKFRAME_UPDATE_TARBALL"] = workframe_tarball
578
+ return _run_apply_scripts(target, env)
579
+
580
+
468
581
  def restart_gateway() -> dict[str, Any]:
469
582
  if not _admin_stack_updates_enabled():
470
583
  raise ValueError("admin_updates_disabled")
471
- _, apply_ready, apply_reason = _update_apply_channel()
584
+ channel, apply_ready, apply_reason = _update_apply_channel()
472
585
  if not apply_ready:
473
- if not Path(DOCKER_SOCK).exists():
474
- raise ValueError("docker_unavailable")
475
586
  raise ValueError(str(apply_reason or "docker_apply_unavailable"))
587
+ if channel == "supervisor":
588
+ return _supervisor_stack_apply({"target": "gateway-restart"}, timeout=300.0)
476
589
  if not Path(DOCKER_SOCK).exists():
477
590
  raise ValueError("docker_unavailable")
478
591
  script = _script_path("restart-gateway-hermes.sh")
@@ -5,7 +5,7 @@ FROM python:3-alpine
5
5
  RUN apk add --no-cache docker-cli docker-cli-compose bash nodejs npm
6
6
 
7
7
  WORKDIR /app
8
- COPY server.py profile_secret_policy.py .
8
+ COPY server.py profile_secret_policy.py ./
9
9
 
10
10
  EXPOSE 8090
11
11
  CMD ["python3", "server.py"]
@@ -60,7 +60,7 @@ def _exec_targets_runtime_profile_secrets(cmd: list[str], acting_profile: str =
60
60
  return exec_blocked_for_profile(cmd, acting_profile)
61
61
 
62
62
 
63
- def _stack_apply(target: str, *, workframe_version: str = "") -> dict[str, Any]:
63
+ def _stack_apply(target: str, *, workframe_version: str = "", workframe_tarball: str = "") -> dict[str, Any]:
64
64
  target = str(target or "all").strip().lower()
65
65
  if target == "gateway-restart":
66
66
  script = SCRIPTS_DIR / "restart-gateway-hermes.sh"
@@ -92,7 +92,13 @@ def _stack_apply(target: str, *, workframe_version: str = "") -> dict[str, Any]:
92
92
  scripts.append(p)
93
93
  env = os.environ.copy()
94
94
  version = str(workframe_version or "").strip()
95
- if version and target in {"workframe", "all"}:
95
+ tarball = str(workframe_tarball or "").strip()
96
+ env["WORKFRAME_UPDATE_FROM_SUPERVISOR"] = "1"
97
+ if tarball and target in {"workframe", "all"}:
98
+ env["WORKFRAME_UPDATE_TARBALL"] = tarball
99
+ if version:
100
+ env["WORKFRAME_UPDATE_VERSION"] = version
101
+ elif version and target in {"workframe", "all"}:
96
102
  env["WORKFRAME_UPDATE_ALLOW_NPM"] = "1"
97
103
  env["WORKFRAME_UPDATE_VERSION"] = version
98
104
  logs: list[str] = []
@@ -780,8 +786,16 @@ class Handler(BaseHTTPRequestHandler):
780
786
  if path == "/v1/stack.apply":
781
787
  target = str(body.get("target") or "all").strip().lower()
782
788
  workframe_version = str(body.get("workframe_version") or "").strip()
789
+ workframe_tarball = str(body.get("workframe_tarball") or "").strip()
783
790
  try:
784
- return self._json(200, _stack_apply(target, workframe_version=workframe_version))
791
+ return self._json(
792
+ 200,
793
+ _stack_apply(
794
+ target,
795
+ workframe_version=workframe_version,
796
+ workframe_tarball=workframe_tarball,
797
+ ),
798
+ )
785
799
  except ValueError as exc:
786
800
  return self._json(400, {"ok": False, "error": str(exc)})
787
801
  except Exception as exc: # noqa: BLE001