codex-flow 2.1.13__py3-none-any.whl
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.
- codex_flow/__init__.py +28 -0
- codex_flow/__main__.py +9 -0
- codex_flow/cli.py +242 -0
- codex_flow/data/LICENSE +21 -0
- codex_flow/data/README.en.md +303 -0
- codex_flow/data/README.md +305 -0
- codex_flow/data/VERSION +1 -0
- codex_flow/data/apps/chatgpt-mcp/README.md +86 -0
- codex_flow/data/apps/chatgpt-mcp/__init__.py +1 -0
- codex_flow/data/apps/chatgpt-mcp/adapter.py +458 -0
- codex_flow/data/apps/chatgpt-mcp/server.py +358 -0
- codex_flow/data/apps/chatgpt-mcp/widget.html +927 -0
- codex_flow/data/apps/macos-overlay/README.en.md +121 -0
- codex_flow/data/apps/macos-overlay/README.md +123 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayRuntimeState.swift +126 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayScreenGeometry.swift +82 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayWindowController.swift +1052 -0
- codex_flow/data/apps/macos-overlay/Sources/Localization.swift +197 -0
- codex_flow/data/apps/macos-overlay/Sources/Models/TelemetryData.swift +1557 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/AccountSnapshotService.swift +1101 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/FlowPilotInstanceLock.swift +153 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/IPCServer.swift +298 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryQueryEngine.swift +800 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryWatcher.swift +135 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/UpdateService.swift +610 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AccountView.swift +610 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AnalyticsView.swift +566 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AutostartView.swift +293 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/BubbleView.swift +317 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/HistoryView.swift +1124 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/HoverRevealText.swift +165 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/InspectorSkillsToolsView.swift +121 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/LogoView.swift +182 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/SleekSwitch.swift +117 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/StrategyModeView.swift +561 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/SummaryView.swift +1273 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/UpdateView.swift +352 -0
- codex_flow/data/apps/macos-overlay/Sources/main.swift +340 -0
- codex_flow/data/apps/macos-overlay/Tests/OverlayScreenGeometryTests.swift +163 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryPhase1ContractTests.swift +357 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryQueryEngineConcurrencyTests.swift +221 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryQuotaSelectionTests.swift +158 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryWorkerTokenTests.swift +122 -0
- codex_flow/data/apps/macos-overlay/build.sh +75 -0
- codex_flow/data/benchmark/corpus.json +103 -0
- codex_flow/data/benchmark/manifest.example.json +41 -0
- codex_flow/data/benchmark/manifest.schema.json +137 -0
- codex_flow/data/benchmark/prices/gpt-5.6-2026-08-30.json +5 -0
- codex_flow/data/benchmark/profiles.json +90 -0
- codex_flow/data/benchmark/schema.json +77 -0
- codex_flow/data/benchmark/tasks.json +50 -0
- codex_flow/data/completions/codex-flow.bash +34 -0
- codex_flow/data/completions/codex-flow.zsh +52 -0
- codex_flow/data/glama.json +6 -0
- codex_flow/data/install-release.ps1 +126 -0
- codex_flow/data/install-release.sh +155 -0
- codex_flow/data/install.ps1 +349 -0
- codex_flow/data/install.sh +362 -0
- codex_flow/data/policy/benchmark.toml +49 -0
- codex_flow/data/policy/defaults.toml +70 -0
- codex_flow/data/scripts/analyze-benchmark.py +510 -0
- codex_flow/data/scripts/benchmark-local.py +171 -0
- codex_flow/data/scripts/check-recommendation.py +277 -0
- codex_flow/data/scripts/doctor.py +449 -0
- codex_flow/data/scripts/generate-release-manifest.py +74 -0
- codex_flow/data/scripts/localization.py +192 -0
- codex_flow/data/scripts/manage-hooks.py +448 -0
- codex_flow/data/scripts/manage-instructions.py +389 -0
- codex_flow/data/scripts/manage-shell.py +151 -0
- codex_flow/data/scripts/materialize-corpus.py +193 -0
- codex_flow/data/scripts/menu.py +646 -0
- codex_flow/data/scripts/migrations/0001_update_settings.py +80 -0
- codex_flow/data/scripts/package-release.py +132 -0
- codex_flow/data/scripts/render-benchmark-report.py +292 -0
- codex_flow/data/scripts/run-benchmark.py +829 -0
- codex_flow/data/scripts/strategies/__init__.py +28 -0
- codex_flow/data/scripts/strategies/balanced.py +115 -0
- codex_flow/data/scripts/strategies/base.py +363 -0
- codex_flow/data/scripts/strategies/efficient.py +158 -0
- codex_flow/data/scripts/strategies/lifecycle_runtime.py +590 -0
- codex_flow/data/scripts/strategies/quality.py +209 -0
- codex_flow/data/scripts/strategies/speed.py +108 -0
- codex_flow/data/scripts/strategies/task_budget_runtime.py +644 -0
- codex_flow/data/scripts/strategies/task_phase_runtime.py +341 -0
- codex_flow/data/scripts/strategies/work_unit_runtime.py +421 -0
- codex_flow/data/scripts/strategy_runtime.py +1091 -0
- codex_flow/data/scripts/telemetry.py +400 -0
- codex_flow/data/scripts/telemetry_core/__init__.py +192 -0
- codex_flow/data/scripts/telemetry_core/app_server.py +1192 -0
- codex_flow/data/scripts/telemetry_core/collector.py +1247 -0
- codex_flow/data/scripts/telemetry_core/common.py +421 -0
- codex_flow/data/scripts/telemetry_core/latency.py +593 -0
- codex_flow/data/scripts/telemetry_core/query.py +427 -0
- codex_flow/data/scripts/telemetry_core/quota_ledger.py +598 -0
- codex_flow/data/scripts/telemetry_core/render.py +460 -0
- codex_flow/data/scripts/telemetry_core/repair.py +223 -0
- codex_flow/data/scripts/ui.py +266 -0
- codex_flow/data/scripts/update-homebrew-formula.py +146 -0
- codex_flow/data/scripts/update_runtime_config.py +134 -0
- codex_flow/data/scripts/updater.py +1718 -0
- codex_flow/data/smithery.yaml +18 -0
- codex_flow/data/templates/agents/worker-explorer.toml +24 -0
- codex_flow/data/templates/agents/worker-implementer.toml +49 -0
- codex_flow/data/templates/agents/worker-reviewer.toml +25 -0
- codex_flow/data/templates/flow-pilot-instructions.md +35 -0
- codex_flow/data/templates/skills/flow-pilot/SKILL.md +577 -0
- codex_flow/mcp.py +35 -0
- codex_flow-2.1.13.dist-info/METADATA +342 -0
- codex_flow-2.1.13.dist-info/RECORD +113 -0
- codex_flow-2.1.13.dist-info/WHEEL +5 -0
- codex_flow-2.1.13.dist-info/entry_points.txt +3 -0
- codex_flow-2.1.13.dist-info/licenses/LICENSE +21 -0
- codex_flow-2.1.13.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1718 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Release-driven self updater for codex-flow.
|
|
3
|
+
|
|
4
|
+
The updater is deliberately stdlib-only so the installed CLI and the native
|
|
5
|
+
FlowPilot app can share one durable update state without adding runtime deps.
|
|
6
|
+
It supports cached/non-blocking update checks, checksum verified release
|
|
7
|
+
artifacts, transactional managed-file replacement, version history and rollback.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import contextlib
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import ssl
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import tarfile
|
|
24
|
+
import tempfile
|
|
25
|
+
import time
|
|
26
|
+
import urllib.error
|
|
27
|
+
import urllib.request
|
|
28
|
+
import zipfile
|
|
29
|
+
from dataclasses import asdict, dataclass, field
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Iterable
|
|
33
|
+
|
|
34
|
+
REPO = "ParsifalC/codex-flow"
|
|
35
|
+
STATE_SCHEMA = 2
|
|
36
|
+
MANIFEST_SCHEMA = 1
|
|
37
|
+
UPDATER_VERSION = "1.0.0"
|
|
38
|
+
DEFAULT_CHECK_INTERVAL_HOURS = 24
|
|
39
|
+
DEFAULT_RELEASES_API = f"https://api.github.com/repos/{REPO}/releases?per_page=20"
|
|
40
|
+
MANIFEST_ASSET = "codex-flow-update.json"
|
|
41
|
+
USER_AGENT = "codex-flow-updater/1"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _configure_console() -> None:
|
|
45
|
+
if os.name != "nt":
|
|
46
|
+
return
|
|
47
|
+
for stream in (sys.stdout, sys.stderr):
|
|
48
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
49
|
+
if callable(reconfigure):
|
|
50
|
+
with contextlib.suppress(OSError, ValueError):
|
|
51
|
+
reconfigure(errors="replace")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_configure_console()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _codex_home() -> Path:
|
|
58
|
+
return Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")).expanduser()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _state_dir() -> Path:
|
|
62
|
+
return _codex_home() / "codex-flow"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _policy_path() -> Path:
|
|
66
|
+
return _codex_home() / "codex-flow.toml"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _update_dir() -> Path:
|
|
70
|
+
return _state_dir() / "state"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _update_state_path() -> Path:
|
|
74
|
+
return _update_dir() / "update.json"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _history_path() -> Path:
|
|
78
|
+
return _update_dir() / "update-history.json"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _migration_state_path() -> Path:
|
|
82
|
+
return _update_dir() / "migrations.json"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _bin_dir() -> Path:
|
|
86
|
+
override = os.environ.get("CODEX_FLOW_BIN_DIR")
|
|
87
|
+
if override:
|
|
88
|
+
return Path(override).expanduser()
|
|
89
|
+
persisted = _state_dir() / "bin_dir"
|
|
90
|
+
if persisted.exists():
|
|
91
|
+
try:
|
|
92
|
+
value = persisted.read_text(encoding="utf-8-sig").strip()
|
|
93
|
+
if value:
|
|
94
|
+
return Path(value).expanduser()
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
return Path.home() / ".local" / "bin"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def utc_now() -> str:
|
|
101
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse_iso(value: str | None) -> float | None:
|
|
105
|
+
if not value:
|
|
106
|
+
return None
|
|
107
|
+
try:
|
|
108
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
|
109
|
+
except (TypeError, ValueError):
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def atomic_write_text(path: Path, text: str) -> None:
|
|
114
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
|
|
116
|
+
try:
|
|
117
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
118
|
+
handle.write(text)
|
|
119
|
+
handle.flush()
|
|
120
|
+
os.fsync(handle.fileno())
|
|
121
|
+
os.replace(tmp_name, path)
|
|
122
|
+
finally:
|
|
123
|
+
with contextlib.suppress(FileNotFoundError):
|
|
124
|
+
os.unlink(tmp_name)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def atomic_write_json(path: Path, payload: Any) -> None:
|
|
128
|
+
atomic_write_text(path, json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_json(path: Path, default: Any) -> Any:
|
|
132
|
+
try:
|
|
133
|
+
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
134
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
135
|
+
return default
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _strip_v(version: str) -> str:
|
|
139
|
+
version = (version or "").strip()
|
|
140
|
+
return version[1:] if version.lower().startswith("v") else version
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def version_key(version: str) -> tuple[tuple[int, ...], int, tuple[Any, ...]]:
|
|
144
|
+
"""Return a deterministic SemVer-ish comparison key.
|
|
145
|
+
|
|
146
|
+
Stable releases sort after prereleases with the same numeric core. The
|
|
147
|
+
project currently uses simple x.y.z tags; the tolerant parser also keeps
|
|
148
|
+
beta/rc channels predictable without depending on packaging.version.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
raw = _strip_v(version).split("+", 1)[0]
|
|
152
|
+
core, sep, prerelease = raw.partition("-")
|
|
153
|
+
nums: list[int] = []
|
|
154
|
+
for piece in core.split("."):
|
|
155
|
+
match = re.match(r"^(\d+)", piece)
|
|
156
|
+
nums.append(int(match.group(1)) if match else 0)
|
|
157
|
+
while len(nums) < 3:
|
|
158
|
+
nums.append(0)
|
|
159
|
+
pre_key: list[Any] = []
|
|
160
|
+
if sep:
|
|
161
|
+
for token in re.split(r"[.-]", prerelease):
|
|
162
|
+
pre_key.append((0, int(token)) if token.isdigit() else (1, token.lower()))
|
|
163
|
+
return tuple(nums), 0 if sep else 1, tuple(pre_key)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def is_newer(candidate: str, current: str) -> bool:
|
|
167
|
+
return version_key(candidate) > version_key(current)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def current_version() -> str:
|
|
171
|
+
for path in (_state_dir() / "version", Path(__file__).resolve().parents[1] / "VERSION"):
|
|
172
|
+
try:
|
|
173
|
+
value = path.read_text(encoding="utf-8-sig").strip()
|
|
174
|
+
if value:
|
|
175
|
+
return _strip_v(value)
|
|
176
|
+
except OSError:
|
|
177
|
+
pass
|
|
178
|
+
return "0.0.0"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def platform_key() -> str:
|
|
182
|
+
system = platform.system().lower()
|
|
183
|
+
machine = platform.machine().lower()
|
|
184
|
+
if machine in {"amd64", "x86_64"}:
|
|
185
|
+
machine = "x86_64"
|
|
186
|
+
elif machine in {"aarch64", "arm64"}:
|
|
187
|
+
machine = "arm64"
|
|
188
|
+
if system == "darwin":
|
|
189
|
+
return f"darwin-{machine}"
|
|
190
|
+
if system == "windows":
|
|
191
|
+
return f"windows-{machine}"
|
|
192
|
+
return f"linux-{machine}"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _simple_toml_value(text: str, section: str, key: str) -> str | None:
|
|
196
|
+
section_match = re.search(
|
|
197
|
+
rf"(?ms)^\[{re.escape(section)}\]\s*\n(.*?)(?=^\[[^\n]+\]\s*$|\Z)", text
|
|
198
|
+
)
|
|
199
|
+
if not section_match:
|
|
200
|
+
return None
|
|
201
|
+
key_match = re.search(rf"(?m)^\s*{re.escape(key)}\s*=\s*(.*?)\s*$", section_match.group(1))
|
|
202
|
+
if not key_match:
|
|
203
|
+
return None
|
|
204
|
+
value = re.sub(r"\s+#.*$", "", key_match.group(1)).strip()
|
|
205
|
+
if len(value) >= 2 and value[0] == value[-1] == '"':
|
|
206
|
+
value = value[1:-1]
|
|
207
|
+
return value
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _as_bool(value: str | None, fallback: bool) -> bool:
|
|
211
|
+
if value is None:
|
|
212
|
+
return fallback
|
|
213
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def set_policy_value(path: Path, section: str, key: str, value: str, *, quote: bool = True) -> None:
|
|
217
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
if not path.exists():
|
|
219
|
+
formatted_val = f'"{value}"' if quote else value
|
|
220
|
+
atomic_write_text(path, f"[{section}]\n{key} = {formatted_val}\n")
|
|
221
|
+
return
|
|
222
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
223
|
+
section_match = re.search(
|
|
224
|
+
rf"(?ms)^\[{re.escape(section)}\]\s*\n(.*?)(?=^\[[^\n]+\]\s*$|\Z)", text
|
|
225
|
+
)
|
|
226
|
+
formatted_val = f'"{value}"' if quote else value
|
|
227
|
+
line = f"{key} = {formatted_val}"
|
|
228
|
+
if section_match:
|
|
229
|
+
body = section_match.group(1)
|
|
230
|
+
key_re = re.compile(rf"(?m)^\s*{re.escape(key)}\s*=.*$")
|
|
231
|
+
if key_re.search(body):
|
|
232
|
+
body = key_re.sub(line, body)
|
|
233
|
+
else:
|
|
234
|
+
if body and not body.endswith("\n"):
|
|
235
|
+
body += "\n"
|
|
236
|
+
body += line + "\n"
|
|
237
|
+
text = text[: section_match.start(1)] + body + text[section_match.end(1):]
|
|
238
|
+
else:
|
|
239
|
+
if text and not text.endswith("\n"):
|
|
240
|
+
text += "\n"
|
|
241
|
+
if text and not text.endswith("\n\n"):
|
|
242
|
+
text += "\n"
|
|
243
|
+
text += f"[{section}]\n{line}\n"
|
|
244
|
+
atomic_write_text(path, text)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass
|
|
248
|
+
class UpdateConfig:
|
|
249
|
+
channel: str = "stable"
|
|
250
|
+
check: bool = True
|
|
251
|
+
check_interval_hours: int = DEFAULT_CHECK_INTERVAL_HOURS
|
|
252
|
+
notify_cli: bool = True
|
|
253
|
+
notify_app: bool = True
|
|
254
|
+
auto_install: bool = True
|
|
255
|
+
releases_api: str = DEFAULT_RELEASES_API
|
|
256
|
+
manifest_url: str | None = None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def load_config(policy: Path | None = None) -> UpdateConfig:
|
|
260
|
+
path = policy or _policy_path()
|
|
261
|
+
config = UpdateConfig()
|
|
262
|
+
try:
|
|
263
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
264
|
+
except OSError:
|
|
265
|
+
text = ""
|
|
266
|
+
config.channel = (_simple_toml_value(text, "update", "channel") or config.channel).lower()
|
|
267
|
+
config.check = _as_bool(_simple_toml_value(text, "update", "check"), config.check)
|
|
268
|
+
config.notify_cli = _as_bool(_simple_toml_value(text, "update", "notify_cli"), config.notify_cli)
|
|
269
|
+
config.notify_app = _as_bool(_simple_toml_value(text, "update", "notify_app"), config.notify_app)
|
|
270
|
+
config.auto_install = _as_bool(_simple_toml_value(text, "update", "auto_install"), config.auto_install)
|
|
271
|
+
interval = _simple_toml_value(text, "update", "check_interval_hours")
|
|
272
|
+
if interval and interval.isdigit() and int(interval) > 0:
|
|
273
|
+
config.check_interval_hours = int(interval)
|
|
274
|
+
config.releases_api = os.environ.get("CODEX_FLOW_UPDATE_RELEASES_API", config.releases_api)
|
|
275
|
+
config.manifest_url = os.environ.get("CODEX_FLOW_UPDATE_MANIFEST_URL") or None
|
|
276
|
+
return config
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass
|
|
280
|
+
class UpdateState:
|
|
281
|
+
schema: int = STATE_SCHEMA
|
|
282
|
+
status: str = "unknown"
|
|
283
|
+
current_version: str = "0.0.0"
|
|
284
|
+
latest_version: str | None = None
|
|
285
|
+
channel: str = "stable"
|
|
286
|
+
notify_cli: bool = True
|
|
287
|
+
notify_app: bool = True
|
|
288
|
+
auto_install: bool = True
|
|
289
|
+
update_available: bool = False
|
|
290
|
+
checked_at: str | None = None
|
|
291
|
+
last_attempt_at: str | None = None
|
|
292
|
+
restart_required: bool = False
|
|
293
|
+
flowpilot_restart_required: bool = False
|
|
294
|
+
mandatory: bool = False
|
|
295
|
+
release_url: str | None = None
|
|
296
|
+
release_notes: str | None = None
|
|
297
|
+
artifact_available: bool = False
|
|
298
|
+
artifact_platform: str | None = None
|
|
299
|
+
last_error: str | None = None
|
|
300
|
+
previous_version: str | None = None
|
|
301
|
+
installed_at: str | None = None
|
|
302
|
+
progress: float | None = None
|
|
303
|
+
pending_codex_pids: list[int] = field(default_factory=list)
|
|
304
|
+
restart_reason: str | None = None
|
|
305
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def from_mapping(cls, payload: dict[str, Any] | None) -> "UpdateState":
|
|
309
|
+
payload = dict(payload or {})
|
|
310
|
+
known = {field_name for field_name in cls.__dataclass_fields__}
|
|
311
|
+
kwargs = {key: value for key, value in payload.items() if key in known and key != "extra"}
|
|
312
|
+
extra = {key: value for key, value in payload.items() if key not in known}
|
|
313
|
+
if "pending_codex_pids" in kwargs and isinstance(kwargs["pending_codex_pids"], list):
|
|
314
|
+
kwargs["pending_codex_pids"] = [
|
|
315
|
+
int(p) for p in kwargs["pending_codex_pids"]
|
|
316
|
+
if isinstance(p, (int, str)) and str(p).isdigit()
|
|
317
|
+
]
|
|
318
|
+
state = cls(**kwargs)
|
|
319
|
+
if isinstance(payload.get("extra"), dict):
|
|
320
|
+
extra.update(payload["extra"])
|
|
321
|
+
state.extra = extra
|
|
322
|
+
return state
|
|
323
|
+
|
|
324
|
+
def to_mapping(self) -> dict[str, Any]:
|
|
325
|
+
payload = asdict(self)
|
|
326
|
+
extra = payload.pop("extra", {})
|
|
327
|
+
payload.update(extra)
|
|
328
|
+
return payload
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _evaluate_auto_ack_restart(state: UpdateState) -> UpdateState:
|
|
332
|
+
if not state.restart_required:
|
|
333
|
+
return state
|
|
334
|
+
if state.pending_codex_pids:
|
|
335
|
+
alive_pids = [pid for pid in state.pending_codex_pids if _pid_is_alive(pid)]
|
|
336
|
+
if not alive_pids:
|
|
337
|
+
state.restart_required = False
|
|
338
|
+
state.pending_codex_pids = []
|
|
339
|
+
state.restart_reason = None
|
|
340
|
+
with contextlib.suppress(OSError):
|
|
341
|
+
save_state(state)
|
|
342
|
+
elif len(alive_pids) != len(state.pending_codex_pids):
|
|
343
|
+
state.pending_codex_pids = alive_pids
|
|
344
|
+
with contextlib.suppress(OSError):
|
|
345
|
+
save_state(state)
|
|
346
|
+
return state
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def load_state() -> UpdateState:
|
|
350
|
+
config = load_config()
|
|
351
|
+
state = UpdateState.from_mapping(_read_json(_update_state_path(), {}))
|
|
352
|
+
state.current_version = current_version()
|
|
353
|
+
state.auto_install = config.auto_install
|
|
354
|
+
# Cached availability is advisory. Recompute it from the version currently
|
|
355
|
+
# installed on disk so a second process cannot reinstall a version another
|
|
356
|
+
# updater completed after this cache was written.
|
|
357
|
+
if state.latest_version:
|
|
358
|
+
state.update_available = is_newer(state.latest_version, state.current_version)
|
|
359
|
+
if not state.update_available and state.status in {"available", "downloading", "installing"}:
|
|
360
|
+
state.status = "latest"
|
|
361
|
+
state = _evaluate_auto_ack_restart(state)
|
|
362
|
+
return state
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def save_state(state: UpdateState) -> None:
|
|
366
|
+
state.schema = STATE_SCHEMA
|
|
367
|
+
atomic_write_json(_update_state_path(), state.to_mapping())
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def cache_is_fresh(state: UpdateState, config: UpdateConfig) -> bool:
|
|
371
|
+
checked = _parse_iso(state.checked_at)
|
|
372
|
+
if checked is None:
|
|
373
|
+
return False
|
|
374
|
+
return time.time() - checked < config.check_interval_hours * 3600
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _ssl_context() -> ssl.SSLContext:
|
|
378
|
+
try:
|
|
379
|
+
context = ssl.create_default_context()
|
|
380
|
+
except Exception:
|
|
381
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
382
|
+
context.check_hostname = True
|
|
383
|
+
context.verify_mode = ssl.CERT_REQUIRED
|
|
384
|
+
|
|
385
|
+
paths = ssl.get_default_verify_paths()
|
|
386
|
+
ca_missing = bool(paths.openssl_cafile and not os.path.exists(paths.openssl_cafile))
|
|
387
|
+
no_ca_configured = not bool(paths.cafile or paths.capath)
|
|
388
|
+
|
|
389
|
+
if ca_missing or no_ca_configured:
|
|
390
|
+
loaded = False
|
|
391
|
+
try:
|
|
392
|
+
import certifi
|
|
393
|
+
|
|
394
|
+
ca = certifi.where()
|
|
395
|
+
if os.path.exists(ca):
|
|
396
|
+
context.load_verify_locations(cafile=ca)
|
|
397
|
+
loaded = True
|
|
398
|
+
except Exception:
|
|
399
|
+
pass
|
|
400
|
+
|
|
401
|
+
if not loaded:
|
|
402
|
+
common_bundle_paths = (
|
|
403
|
+
"/etc/ssl/cert.pem",
|
|
404
|
+
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
405
|
+
"/etc/ssl/certs/ca-certificates.crt",
|
|
406
|
+
"/etc/ssl/ca-bundle.pem",
|
|
407
|
+
"/usr/local/share/certs/ca-root-nss.crt",
|
|
408
|
+
)
|
|
409
|
+
for path in common_bundle_paths:
|
|
410
|
+
if os.path.exists(path):
|
|
411
|
+
try:
|
|
412
|
+
context.load_verify_locations(cafile=path)
|
|
413
|
+
break
|
|
414
|
+
except Exception:
|
|
415
|
+
pass
|
|
416
|
+
return context
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _request_bytes(url: str, timeout: float = 8.0) -> bytes:
|
|
420
|
+
if url.startswith("file://"):
|
|
421
|
+
return Path(url[7:]).read_bytes()
|
|
422
|
+
local = Path(url)
|
|
423
|
+
if "://" not in url and local.exists():
|
|
424
|
+
return local.read_bytes()
|
|
425
|
+
request = urllib.request.Request(
|
|
426
|
+
url,
|
|
427
|
+
headers={"User-Agent": USER_AGENT, "Accept": "application/vnd.github+json"},
|
|
428
|
+
)
|
|
429
|
+
with urllib.request.urlopen(request, timeout=timeout, context=_ssl_context()) as response:
|
|
430
|
+
return response.read()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _request_json(url: str, timeout: float = 8.0) -> Any:
|
|
434
|
+
return json.loads(_request_bytes(url, timeout=timeout).decode("utf-8-sig"))
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _release_matches_channel(release: dict[str, Any], channel: str) -> bool:
|
|
438
|
+
if release.get("draft"):
|
|
439
|
+
return False
|
|
440
|
+
tag = str(release.get("tag_name") or "").lower()
|
|
441
|
+
prerelease = bool(release.get("prerelease"))
|
|
442
|
+
normalized = _strip_v(tag)
|
|
443
|
+
if channel == "stable":
|
|
444
|
+
return not prerelease and "nightly" not in tag and "-" not in normalized
|
|
445
|
+
if channel == "beta":
|
|
446
|
+
return "nightly" not in tag
|
|
447
|
+
if channel == "nightly":
|
|
448
|
+
return "nightly" in tag
|
|
449
|
+
return not prerelease
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _manifest_from_release_api(config: UpdateConfig) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
453
|
+
releases = _request_json(config.releases_api)
|
|
454
|
+
if not isinstance(releases, list):
|
|
455
|
+
raise RuntimeError("release API returned an unexpected payload")
|
|
456
|
+
candidates = [r for r in releases if isinstance(r, dict) and _release_matches_channel(r, config.channel)]
|
|
457
|
+
candidates.sort(key=lambda r: version_key(str(r.get("tag_name") or "0.0.0")), reverse=True)
|
|
458
|
+
if not candidates:
|
|
459
|
+
raise RuntimeError(f"no {config.channel} release found")
|
|
460
|
+
# Prefer the newest release that actually participates in the OTA protocol.
|
|
461
|
+
# During rollout there may be older, pre-OTA GitHub releases without a
|
|
462
|
+
# manifest. If the newest public release is not newer than the installed
|
|
463
|
+
# version, treat the installed version as current instead of surfacing a
|
|
464
|
+
# spurious update-check error in the CLI/App.
|
|
465
|
+
for release in candidates:
|
|
466
|
+
assets = release.get("assets") or []
|
|
467
|
+
manifest_asset = next(
|
|
468
|
+
(item for item in assets if isinstance(item, dict) and item.get("name") == MANIFEST_ASSET), None
|
|
469
|
+
)
|
|
470
|
+
if not manifest_asset or not manifest_asset.get("browser_download_url"):
|
|
471
|
+
continue
|
|
472
|
+
manifest = _request_json(str(manifest_asset["browser_download_url"]))
|
|
473
|
+
if not isinstance(manifest, dict):
|
|
474
|
+
raise RuntimeError("update manifest is not an object")
|
|
475
|
+
return manifest, release
|
|
476
|
+
|
|
477
|
+
newest_release = candidates[0]
|
|
478
|
+
newest_tag = _strip_v(str(newest_release.get("tag_name") or "0.0.0"))
|
|
479
|
+
installed = current_version()
|
|
480
|
+
if not is_newer(newest_tag, installed):
|
|
481
|
+
return (
|
|
482
|
+
{
|
|
483
|
+
"schema": MANIFEST_SCHEMA,
|
|
484
|
+
"version": installed,
|
|
485
|
+
"channel": config.channel,
|
|
486
|
+
"restart_required": False,
|
|
487
|
+
"mandatory": False,
|
|
488
|
+
"release_url": newest_release.get("html_url"),
|
|
489
|
+
"release_notes": "",
|
|
490
|
+
"artifacts": {},
|
|
491
|
+
},
|
|
492
|
+
newest_release,
|
|
493
|
+
)
|
|
494
|
+
raise RuntimeError(f"release {newest_release.get('tag_name', '')} does not provide {MANIFEST_ASSET}")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def fetch_manifest(config: UpdateConfig) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
498
|
+
if config.manifest_url:
|
|
499
|
+
manifest = _request_json(config.manifest_url)
|
|
500
|
+
if not isinstance(manifest, dict):
|
|
501
|
+
raise RuntimeError("update manifest is not an object")
|
|
502
|
+
return manifest, {}
|
|
503
|
+
return _manifest_from_release_api(config)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _validate_manifest(manifest: dict[str, Any]) -> None:
|
|
507
|
+
if int(manifest.get("schema", 0)) != MANIFEST_SCHEMA:
|
|
508
|
+
raise RuntimeError(f"unsupported update manifest schema: {manifest.get('schema')}")
|
|
509
|
+
if not str(manifest.get("version") or "").strip():
|
|
510
|
+
raise RuntimeError("update manifest is missing version")
|
|
511
|
+
minimum_updater = str(manifest.get("minimum_updater_version") or "").strip()
|
|
512
|
+
if minimum_updater and is_newer(minimum_updater, UPDATER_VERSION):
|
|
513
|
+
raise RuntimeError(
|
|
514
|
+
f"update requires updater v{_strip_v(minimum_updater)} or newer; "
|
|
515
|
+
"reinstall codex-flow once to refresh the updater"
|
|
516
|
+
)
|
|
517
|
+
artifacts = manifest.get("artifacts")
|
|
518
|
+
if not isinstance(artifacts, dict):
|
|
519
|
+
raise RuntimeError("update manifest is missing artifacts")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _artifact_for_platform(manifest: dict[str, Any], key: str | None = None) -> dict[str, Any] | None:
|
|
523
|
+
artifacts = manifest.get("artifacts") or {}
|
|
524
|
+
value = artifacts.get(key or platform_key()) if isinstance(artifacts, dict) else None
|
|
525
|
+
return value if isinstance(value, dict) else None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def check_for_updates(*, force: bool = False, quiet: bool = False) -> UpdateState:
|
|
529
|
+
config = load_config()
|
|
530
|
+
state = load_state()
|
|
531
|
+
state.channel = config.channel
|
|
532
|
+
state.notify_cli = config.notify_cli
|
|
533
|
+
state.notify_app = config.notify_app
|
|
534
|
+
state.current_version = current_version()
|
|
535
|
+
if not config.check and not force:
|
|
536
|
+
return state
|
|
537
|
+
if not force and cache_is_fresh(state, config):
|
|
538
|
+
return state
|
|
539
|
+
|
|
540
|
+
state.last_attempt_at = utc_now()
|
|
541
|
+
try:
|
|
542
|
+
manifest, release = fetch_manifest(config)
|
|
543
|
+
_validate_manifest(manifest)
|
|
544
|
+
latest = _strip_v(str(manifest["version"]))
|
|
545
|
+
artifact = _artifact_for_platform(manifest)
|
|
546
|
+
state.latest_version = latest
|
|
547
|
+
state.update_available = is_newer(latest, state.current_version)
|
|
548
|
+
state.status = "available" if state.update_available else "latest"
|
|
549
|
+
state.checked_at = utc_now()
|
|
550
|
+
state.last_error = None
|
|
551
|
+
state.mandatory = bool(manifest.get("mandatory", False))
|
|
552
|
+
state.release_url = str(
|
|
553
|
+
manifest.get("release_url") or release.get("html_url") or state.release_url or ""
|
|
554
|
+
) or None
|
|
555
|
+
notes = manifest.get("release_notes") or release.get("body")
|
|
556
|
+
state.release_notes = str(notes) if notes else None
|
|
557
|
+
state.artifact_platform = platform_key()
|
|
558
|
+
state.artifact_available = bool(
|
|
559
|
+
artifact and artifact.get("url") and artifact.get("sha256")
|
|
560
|
+
)
|
|
561
|
+
state.extra["manifest"] = manifest
|
|
562
|
+
save_state(state)
|
|
563
|
+
except Exception as exc: # keep a known cached result usable on transient failures
|
|
564
|
+
state.last_error = str(exc)
|
|
565
|
+
if state.status == "checking":
|
|
566
|
+
state.status = "unknown"
|
|
567
|
+
save_state(state)
|
|
568
|
+
if not quiet:
|
|
569
|
+
pass
|
|
570
|
+
return state
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def cached_status(*, trigger_background: bool = False) -> UpdateState:
|
|
574
|
+
config = load_config()
|
|
575
|
+
state = load_state()
|
|
576
|
+
state.channel = config.channel
|
|
577
|
+
state.notify_cli = config.notify_cli
|
|
578
|
+
state.notify_app = config.notify_app
|
|
579
|
+
if trigger_background and config.check and not cache_is_fresh(state, config):
|
|
580
|
+
start_background_check()
|
|
581
|
+
return state
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _lock_path() -> Path:
|
|
585
|
+
return _update_dir() / "check.lock"
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _pid_is_alive(pid: int) -> bool:
|
|
589
|
+
"""Best-effort process liveness check; uncertainty is treated as alive."""
|
|
590
|
+
if pid <= 0:
|
|
591
|
+
return False
|
|
592
|
+
if pid == os.getpid():
|
|
593
|
+
return True
|
|
594
|
+
if os.name == "nt":
|
|
595
|
+
try:
|
|
596
|
+
import ctypes
|
|
597
|
+
|
|
598
|
+
process_query_limited_information = 0x1000
|
|
599
|
+
kernel32 = ctypes.windll.kernel32
|
|
600
|
+
handle = kernel32.OpenProcess(process_query_limited_information, False, pid)
|
|
601
|
+
if handle:
|
|
602
|
+
kernel32.CloseHandle(handle)
|
|
603
|
+
return True
|
|
604
|
+
return False
|
|
605
|
+
except Exception:
|
|
606
|
+
return True
|
|
607
|
+
try:
|
|
608
|
+
os.kill(pid, 0)
|
|
609
|
+
return True
|
|
610
|
+
except ProcessLookupError:
|
|
611
|
+
return False
|
|
612
|
+
except PermissionError:
|
|
613
|
+
return True
|
|
614
|
+
except OSError:
|
|
615
|
+
return True
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _find_running_codex_pids() -> list[int]:
|
|
619
|
+
"""Find running Codex or ChatGPT host process PIDs across platforms."""
|
|
620
|
+
pids: set[int] = set()
|
|
621
|
+
my_pid = os.getpid()
|
|
622
|
+
target_names = ("ChatGPT", "Codex", "codex", "chatgpt")
|
|
623
|
+
|
|
624
|
+
if os.name == "nt":
|
|
625
|
+
for name in target_names:
|
|
626
|
+
try:
|
|
627
|
+
proc = subprocess.run(
|
|
628
|
+
["tasklist", "/FI", f"IMAGENAME eq {name}.exe", "/FO", "CSV", "/NH"],
|
|
629
|
+
capture_output=True,
|
|
630
|
+
text=True,
|
|
631
|
+
timeout=3,
|
|
632
|
+
check=False,
|
|
633
|
+
)
|
|
634
|
+
for line in proc.stdout.splitlines():
|
|
635
|
+
parts = line.strip().split('","')
|
|
636
|
+
if len(parts) >= 2:
|
|
637
|
+
pid_str = parts[1].replace('"', "").strip()
|
|
638
|
+
if pid_str.isdigit():
|
|
639
|
+
pid = int(pid_str)
|
|
640
|
+
if pid != my_pid:
|
|
641
|
+
pids.add(pid)
|
|
642
|
+
except Exception:
|
|
643
|
+
pass
|
|
644
|
+
return sorted(pids)
|
|
645
|
+
|
|
646
|
+
for name in target_names:
|
|
647
|
+
try:
|
|
648
|
+
proc = subprocess.run(
|
|
649
|
+
["pgrep", "-x", name],
|
|
650
|
+
capture_output=True,
|
|
651
|
+
text=True,
|
|
652
|
+
timeout=3,
|
|
653
|
+
check=False,
|
|
654
|
+
)
|
|
655
|
+
for line in proc.stdout.splitlines():
|
|
656
|
+
line = line.strip()
|
|
657
|
+
if line.isdigit():
|
|
658
|
+
pid = int(line)
|
|
659
|
+
if pid != my_pid:
|
|
660
|
+
pids.add(pid)
|
|
661
|
+
except Exception:
|
|
662
|
+
pass
|
|
663
|
+
|
|
664
|
+
return sorted(pids)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _file_content_hash(path: Path | None) -> str | None:
|
|
668
|
+
if path is None or not path.is_file():
|
|
669
|
+
return None
|
|
670
|
+
try:
|
|
671
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
672
|
+
except OSError:
|
|
673
|
+
return None
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _codex_managed_files_diff(backup: Path) -> tuple[bool, list[str]]:
|
|
677
|
+
"""Compare managed assets that affect Codex before and after the update."""
|
|
678
|
+
pairs: list[tuple[str, Path, Path]] = [
|
|
679
|
+
("config.toml", backup / "config", _codex_home() / "config.toml"),
|
|
680
|
+
("hooks.json", backup / "user-managed" / "hooks.json", _codex_home() / "hooks.json"),
|
|
681
|
+
("worker-explorer.toml", backup / "user-managed" / "worker-explorer.toml", _codex_home() / "agents" / "worker-explorer.toml"),
|
|
682
|
+
("worker-implementer.toml", backup / "user-managed" / "worker-implementer.toml", _codex_home() / "agents" / "worker-implementer.toml"),
|
|
683
|
+
("worker-reviewer.toml", backup / "user-managed" / "worker-reviewer.toml", _codex_home() / "agents" / "worker-reviewer.toml"),
|
|
684
|
+
("flow-pilot/SKILL.md", backup / "user-managed" / "SKILL.md", _codex_home() / "skills" / "flow-pilot" / "SKILL.md"),
|
|
685
|
+
("AGENTS.md", backup / "user-managed" / "AGENTS.md", _codex_home() / "AGENTS.md"),
|
|
686
|
+
("AGENTS.override.md", backup / "user-managed" / "AGENTS.override.md", _codex_home() / "AGENTS.override.md"),
|
|
687
|
+
("flow-pilot-instructions.md", backup / "managed" / "flow-pilot-instructions.md", _state_dir() / "flow-pilot-instructions.md"),
|
|
688
|
+
("codex-flow.toml", backup / "policy", _policy_path()),
|
|
689
|
+
]
|
|
690
|
+
changed: list[str] = []
|
|
691
|
+
for label, pre_path, post_path in pairs:
|
|
692
|
+
pre_hash = _file_content_hash(pre_path)
|
|
693
|
+
post_hash = _file_content_hash(post_path)
|
|
694
|
+
if pre_hash != post_hash:
|
|
695
|
+
changed.append(label)
|
|
696
|
+
return bool(changed), changed
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _resolve_codex_restart_requirement(
|
|
700
|
+
backup: Path,
|
|
701
|
+
manifest: dict[str, Any] | None = None,
|
|
702
|
+
) -> tuple[bool, list[int], str | None]:
|
|
703
|
+
"""Intelligently determine whether Codex needs a restart after update/rollback."""
|
|
704
|
+
if manifest is not None and manifest.get("restart_required") is False:
|
|
705
|
+
return False, [], None
|
|
706
|
+
|
|
707
|
+
files_changed, changed_names = _codex_managed_files_diff(backup)
|
|
708
|
+
if not files_changed:
|
|
709
|
+
return False, [], None
|
|
710
|
+
|
|
711
|
+
running_pids = _find_running_codex_pids()
|
|
712
|
+
if not running_pids:
|
|
713
|
+
return False, [], None
|
|
714
|
+
|
|
715
|
+
reason = f"Changed: {', '.join(changed_names)}"
|
|
716
|
+
return True, running_pids, reason
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
@contextlib.contextmanager
|
|
720
|
+
def update_lock(stale_seconds: int = 600):
|
|
721
|
+
path = _lock_path()
|
|
722
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
723
|
+
if path.exists():
|
|
724
|
+
owner_pid: int | None = None
|
|
725
|
+
with contextlib.suppress(OSError, ValueError):
|
|
726
|
+
owner_pid = int(path.read_text(errors="ignore").strip())
|
|
727
|
+
age = 0.0
|
|
728
|
+
with contextlib.suppress(OSError):
|
|
729
|
+
age = max(0.0, time.time() - path.stat().st_mtime)
|
|
730
|
+
should_reclaim = (
|
|
731
|
+
owner_pid is not None and not _pid_is_alive(owner_pid)
|
|
732
|
+
) or (
|
|
733
|
+
owner_pid is None and age > stale_seconds
|
|
734
|
+
)
|
|
735
|
+
if should_reclaim:
|
|
736
|
+
with contextlib.suppress(OSError):
|
|
737
|
+
path.unlink()
|
|
738
|
+
fd: int | None = None
|
|
739
|
+
owned = False
|
|
740
|
+
try:
|
|
741
|
+
fd = os.open(str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
742
|
+
os.write(fd, str(os.getpid()).encode("ascii"))
|
|
743
|
+
os.close(fd)
|
|
744
|
+
fd = None
|
|
745
|
+
owned = True
|
|
746
|
+
yield True
|
|
747
|
+
except FileExistsError:
|
|
748
|
+
yield False
|
|
749
|
+
finally:
|
|
750
|
+
if fd is not None:
|
|
751
|
+
os.close(fd)
|
|
752
|
+
if owned:
|
|
753
|
+
with contextlib.suppress(FileNotFoundError):
|
|
754
|
+
if path.exists() and path.read_text(errors="ignore").strip() == str(os.getpid()):
|
|
755
|
+
path.unlink()
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
@contextlib.contextmanager
|
|
759
|
+
def install_lock(timeout_seconds: float = 20.0, poll_interval: float = 0.25):
|
|
760
|
+
"""Wait briefly for a background check, then require exclusive writer ownership.
|
|
761
|
+
|
|
762
|
+
Checks and writers intentionally share the PID lock so an update can never
|
|
763
|
+
mutate managed files while a checker is updating shared state. A user action
|
|
764
|
+
should not fail merely because a short detached check was already in flight,
|
|
765
|
+
so writers retry for a bounded interval before reporting real contention.
|
|
766
|
+
"""
|
|
767
|
+
|
|
768
|
+
deadline = time.monotonic() + max(0.0, timeout_seconds)
|
|
769
|
+
while True:
|
|
770
|
+
with update_lock() as acquired:
|
|
771
|
+
if acquired:
|
|
772
|
+
yield
|
|
773
|
+
return
|
|
774
|
+
if time.monotonic() >= deadline:
|
|
775
|
+
raise RuntimeError("another codex-flow update, rollback, or update check is still running")
|
|
776
|
+
time.sleep(max(0.01, poll_interval))
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def start_background_check() -> bool:
|
|
780
|
+
"""Spawn a silent checker and return immediately.
|
|
781
|
+
|
|
782
|
+
UI surfaces call this only after rendering cached state, so a slow network can
|
|
783
|
+
never delay the CLI menu or the native app launch.
|
|
784
|
+
"""
|
|
785
|
+
|
|
786
|
+
config = load_config()
|
|
787
|
+
state = load_state()
|
|
788
|
+
if not config.check or cache_is_fresh(state, config):
|
|
789
|
+
return False
|
|
790
|
+
action_flag = "--auto" if config.auto_install else "--check"
|
|
791
|
+
cmd = [sys.executable, str(Path(__file__).resolve()), action_flag, "--quiet"]
|
|
792
|
+
kwargs: dict[str, Any] = {
|
|
793
|
+
"stdin": subprocess.DEVNULL,
|
|
794
|
+
"stdout": subprocess.DEVNULL,
|
|
795
|
+
"stderr": subprocess.DEVNULL,
|
|
796
|
+
"close_fds": os.name != "nt",
|
|
797
|
+
}
|
|
798
|
+
if os.name == "nt":
|
|
799
|
+
kwargs["creationflags"] = (
|
|
800
|
+
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
801
|
+
| getattr(subprocess, "DETACHED_PROCESS", 0)
|
|
802
|
+
)
|
|
803
|
+
else:
|
|
804
|
+
kwargs["start_new_session"] = True
|
|
805
|
+
try:
|
|
806
|
+
subprocess.Popen(cmd, **kwargs)
|
|
807
|
+
return True
|
|
808
|
+
except OSError:
|
|
809
|
+
return False
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def update_menu_label(lang: str = "en", state: UpdateState | None = None) -> str:
|
|
813
|
+
state = state or load_state()
|
|
814
|
+
zh = lang.lower().startswith("zh")
|
|
815
|
+
if state.notify_cli is False:
|
|
816
|
+
return "🔄 检查更新" if zh else "🔄 Check for updates"
|
|
817
|
+
if state.flowpilot_restart_required or state.restart_required:
|
|
818
|
+
version = state.current_version or state.latest_version or ""
|
|
819
|
+
if state.flowpilot_restart_required and state.restart_required:
|
|
820
|
+
return f"⚠️ v{version} 已安装 · 请重启 FlowPilot / Codex" if zh else f"⚠️ v{version} installed · restart FlowPilot / Codex"
|
|
821
|
+
if state.flowpilot_restart_required:
|
|
822
|
+
return f"⚠️ v{version} 已安装 · 请重启 FlowPilot" if zh else f"⚠️ v{version} installed · restart FlowPilot"
|
|
823
|
+
return f"⚠️ v{version} 已安装 · 请重启 Codex" if zh else f"⚠️ v{version} installed · restart Codex"
|
|
824
|
+
if state.update_available and state.latest_version:
|
|
825
|
+
return (
|
|
826
|
+
f"🆕 更新 codex-flow · v{state.latest_version} 可用"
|
|
827
|
+
if zh
|
|
828
|
+
else f"🆕 Update codex-flow · v{state.latest_version} available"
|
|
829
|
+
)
|
|
830
|
+
if state.status == "latest" and state.current_version:
|
|
831
|
+
return (
|
|
832
|
+
f"✅ 已是最新版本 · v{state.current_version}"
|
|
833
|
+
if zh
|
|
834
|
+
else f"✅ Up to date · v{state.current_version}"
|
|
835
|
+
)
|
|
836
|
+
return "🔄 检查更新" if zh else "🔄 Check for updates"
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _safe_target(root: Path, member_name: str) -> Path:
|
|
840
|
+
target = (root / member_name).resolve()
|
|
841
|
+
root_resolved = root.resolve()
|
|
842
|
+
if target != root_resolved and root_resolved not in target.parents:
|
|
843
|
+
raise RuntimeError(f"unsafe archive member: {member_name}")
|
|
844
|
+
return target
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def safe_extract(archive: Path, destination: Path) -> None:
|
|
848
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
849
|
+
if archive.suffix.lower() == ".zip":
|
|
850
|
+
with zipfile.ZipFile(archive) as zf:
|
|
851
|
+
for info in zf.infolist():
|
|
852
|
+
_safe_target(destination, info.filename)
|
|
853
|
+
zf.extractall(destination)
|
|
854
|
+
return
|
|
855
|
+
with tarfile.open(archive, "r:*") as tf:
|
|
856
|
+
for member in tf.getmembers():
|
|
857
|
+
_safe_target(destination, member.name)
|
|
858
|
+
if member.issym() or member.islnk():
|
|
859
|
+
raise RuntimeError(f"release archive may not contain links: {member.name}")
|
|
860
|
+
if not (member.isfile() or member.isdir()):
|
|
861
|
+
raise RuntimeError(f"release archive contains unsupported special member: {member.name}")
|
|
862
|
+
tf.extractall(destination)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _sha256(path: Path) -> str:
|
|
866
|
+
digest = hashlib.sha256()
|
|
867
|
+
with path.open("rb") as handle:
|
|
868
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
869
|
+
digest.update(chunk)
|
|
870
|
+
return digest.hexdigest()
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _download(url: str, destination: Path) -> None:
|
|
874
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
875
|
+
if url.startswith("file://"):
|
|
876
|
+
shutil.copy2(Path(url[7:]), destination)
|
|
877
|
+
return
|
|
878
|
+
local = Path(url)
|
|
879
|
+
if "://" not in url and local.exists():
|
|
880
|
+
shutil.copy2(local, destination)
|
|
881
|
+
return
|
|
882
|
+
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
883
|
+
with urllib.request.urlopen(request, timeout=30, context=_ssl_context()) as response, destination.open("wb") as out:
|
|
884
|
+
shutil.copyfileobj(response, out, length=1024 * 1024)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _package_root(extract_dir: Path) -> Path:
|
|
888
|
+
if (extract_dir / "VERSION").exists():
|
|
889
|
+
return extract_dir
|
|
890
|
+
children = [p for p in extract_dir.iterdir() if p.is_dir()]
|
|
891
|
+
candidates = [p for p in children if (p / "VERSION").exists()]
|
|
892
|
+
if len(candidates) == 1:
|
|
893
|
+
return candidates[0]
|
|
894
|
+
raise RuntimeError("release artifact does not contain a codex-flow package root")
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _atomic_copy(src: Path, dst: Path, executable: bool | None = None) -> None:
|
|
898
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
899
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{dst.name}.", dir=str(dst.parent))
|
|
900
|
+
os.close(fd)
|
|
901
|
+
tmp = Path(tmp_name)
|
|
902
|
+
try:
|
|
903
|
+
shutil.copy2(src, tmp)
|
|
904
|
+
if executable is True and os.name != "nt":
|
|
905
|
+
tmp.chmod(tmp.stat().st_mode | 0o111)
|
|
906
|
+
os.replace(tmp, dst)
|
|
907
|
+
finally:
|
|
908
|
+
with contextlib.suppress(FileNotFoundError):
|
|
909
|
+
tmp.unlink()
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _replace_dir(src: Path, dst: Path) -> None:
|
|
913
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
914
|
+
staging = dst.parent / f".{dst.name}.new-{os.getpid()}"
|
|
915
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
916
|
+
shutil.copytree(src, staging)
|
|
917
|
+
old = dst.parent / f".{dst.name}.old-{os.getpid()}"
|
|
918
|
+
shutil.rmtree(old, ignore_errors=True)
|
|
919
|
+
if dst.exists():
|
|
920
|
+
os.replace(dst, old)
|
|
921
|
+
os.replace(staging, dst)
|
|
922
|
+
shutil.rmtree(old, ignore_errors=True)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _snapshot(current: str) -> Path:
|
|
926
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
|
|
927
|
+
backup = _state_dir() / "backups" / f"{stamp}-{current}"
|
|
928
|
+
backup.mkdir(parents=True, exist_ok=True)
|
|
929
|
+
targets = {
|
|
930
|
+
"policy": _policy_path(),
|
|
931
|
+
"config": _codex_home() / "config.toml",
|
|
932
|
+
"version": _state_dir() / "version",
|
|
933
|
+
"source": _state_dir() / "source",
|
|
934
|
+
"defaults": _state_dir() / "defaults.toml",
|
|
935
|
+
}
|
|
936
|
+
for name, path in targets.items():
|
|
937
|
+
if path.exists():
|
|
938
|
+
shutil.copy2(path, backup / name)
|
|
939
|
+
managed = backup / "managed"
|
|
940
|
+
managed.mkdir()
|
|
941
|
+
instruction_managed_names = {
|
|
942
|
+
"manage-instructions.py",
|
|
943
|
+
"flow-pilot-instructions.md",
|
|
944
|
+
"instructions-state.json",
|
|
945
|
+
}
|
|
946
|
+
instruction_presence: dict[str, bool] = {}
|
|
947
|
+
for name in (
|
|
948
|
+
"updater.py",
|
|
949
|
+
"update_runtime_config.py",
|
|
950
|
+
"telemetry.py",
|
|
951
|
+
"manage-hooks.py",
|
|
952
|
+
"manage-instructions.py",
|
|
953
|
+
"menu.py",
|
|
954
|
+
"localization.py",
|
|
955
|
+
"ui.py",
|
|
956
|
+
"doctor.py",
|
|
957
|
+
"strategy_runtime.py",
|
|
958
|
+
"flow-pilot-instructions.md",
|
|
959
|
+
"instructions-state.json",
|
|
960
|
+
):
|
|
961
|
+
path = _state_dir() / name
|
|
962
|
+
if name in instruction_managed_names:
|
|
963
|
+
instruction_presence[name] = path.exists()
|
|
964
|
+
if path.exists():
|
|
965
|
+
shutil.copy2(path, managed / name)
|
|
966
|
+
atomic_write_json(managed / "instructions-presence.json", instruction_presence)
|
|
967
|
+
for name in ("strategies", "telemetry_core"):
|
|
968
|
+
path = _state_dir() / name
|
|
969
|
+
if path.exists():
|
|
970
|
+
shutil.copytree(path, managed / name)
|
|
971
|
+
bin_backup = backup / "bin"
|
|
972
|
+
bin_backup.mkdir()
|
|
973
|
+
for name in ("codex-flow", "codex-flow.ps1", "codex-flow.cmd"):
|
|
974
|
+
path = _bin_dir() / name
|
|
975
|
+
if path.exists():
|
|
976
|
+
shutil.copy2(path, bin_backup / name)
|
|
977
|
+
overlay_backup = backup / "overlay"
|
|
978
|
+
for name in ("FlowPilot", "codex-flow-overlay"):
|
|
979
|
+
path = _state_dir() / "bin" / name
|
|
980
|
+
if path.exists():
|
|
981
|
+
overlay_backup.mkdir(exist_ok=True)
|
|
982
|
+
shutil.copy2(path, overlay_backup / name)
|
|
983
|
+
|
|
984
|
+
user_backup = backup / "user-managed"
|
|
985
|
+
user_backup.mkdir()
|
|
986
|
+
live_targets = {
|
|
987
|
+
"hooks.json": _codex_home() / "hooks.json",
|
|
988
|
+
"worker-explorer.toml": _codex_home() / "agents" / "worker-explorer.toml",
|
|
989
|
+
"worker-implementer.toml": _codex_home() / "agents" / "worker-implementer.toml",
|
|
990
|
+
"worker-reviewer.toml": _codex_home() / "agents" / "worker-reviewer.toml",
|
|
991
|
+
"SKILL.md": _codex_home() / "skills" / "flow-pilot" / "SKILL.md",
|
|
992
|
+
"AGENTS.md": _codex_home() / "AGENTS.md",
|
|
993
|
+
"AGENTS.override.md": _codex_home() / "AGENTS.override.md",
|
|
994
|
+
"migrations.json": _migration_state_path(),
|
|
995
|
+
}
|
|
996
|
+
presence: dict[str, bool] = {}
|
|
997
|
+
symlinks: dict[str, str] = {}
|
|
998
|
+
for name, path in live_targets.items():
|
|
999
|
+
presence[name] = os.path.lexists(str(path))
|
|
1000
|
+
if path.is_symlink():
|
|
1001
|
+
symlinks[name] = os.readlink(str(path))
|
|
1002
|
+
elif path.exists():
|
|
1003
|
+
shutil.copy2(path, user_backup / name)
|
|
1004
|
+
atomic_write_json(user_backup / "presence.json", presence)
|
|
1005
|
+
atomic_write_json(user_backup / "symlinks.json", symlinks)
|
|
1006
|
+
return backup
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _restore_snapshot(backup: Path) -> None:
|
|
1010
|
+
policy = backup / "policy"
|
|
1011
|
+
if policy.exists():
|
|
1012
|
+
_atomic_copy(policy, _policy_path())
|
|
1013
|
+
config = backup / "config"
|
|
1014
|
+
if config.exists():
|
|
1015
|
+
_atomic_copy(config, _codex_home() / "config.toml")
|
|
1016
|
+
for name in ("version", "source", "defaults"):
|
|
1017
|
+
src = backup / name
|
|
1018
|
+
dst_name = "defaults.toml" if name == "defaults" else name
|
|
1019
|
+
if src.exists():
|
|
1020
|
+
_atomic_copy(src, _state_dir() / dst_name)
|
|
1021
|
+
managed = backup / "managed"
|
|
1022
|
+
if managed.exists():
|
|
1023
|
+
instruction_presence = _read_json(managed / "instructions-presence.json", {})
|
|
1024
|
+
for path in managed.iterdir():
|
|
1025
|
+
if path.name == "instructions-presence.json":
|
|
1026
|
+
continue
|
|
1027
|
+
dst = _state_dir() / path.name
|
|
1028
|
+
if path.is_dir():
|
|
1029
|
+
_replace_dir(path, dst)
|
|
1030
|
+
else:
|
|
1031
|
+
_atomic_copy(path, dst, executable=path.suffix == ".py")
|
|
1032
|
+
if isinstance(instruction_presence, dict):
|
|
1033
|
+
for name, present in instruction_presence.items():
|
|
1034
|
+
if not present:
|
|
1035
|
+
with contextlib.suppress(FileNotFoundError):
|
|
1036
|
+
(_state_dir() / name).unlink()
|
|
1037
|
+
bin_backup = backup / "bin"
|
|
1038
|
+
if bin_backup.exists():
|
|
1039
|
+
for path in bin_backup.iterdir():
|
|
1040
|
+
_atomic_copy(path, _bin_dir() / path.name, executable=path.name == "codex-flow")
|
|
1041
|
+
overlay = backup / "overlay"
|
|
1042
|
+
if overlay.exists():
|
|
1043
|
+
for path in overlay.iterdir():
|
|
1044
|
+
_atomic_copy(path, _state_dir() / "bin" / path.name, executable=True)
|
|
1045
|
+
|
|
1046
|
+
user_backup = backup / "user-managed"
|
|
1047
|
+
if user_backup.exists():
|
|
1048
|
+
presence = _read_json(user_backup / "presence.json", {})
|
|
1049
|
+
live_targets = {
|
|
1050
|
+
"hooks.json": _codex_home() / "hooks.json",
|
|
1051
|
+
"worker-explorer.toml": _codex_home() / "agents" / "worker-explorer.toml",
|
|
1052
|
+
"worker-implementer.toml": _codex_home() / "agents" / "worker-implementer.toml",
|
|
1053
|
+
"worker-reviewer.toml": _codex_home() / "agents" / "worker-reviewer.toml",
|
|
1054
|
+
"SKILL.md": _codex_home() / "skills" / "flow-pilot" / "SKILL.md",
|
|
1055
|
+
"AGENTS.md": _codex_home() / "AGENTS.md",
|
|
1056
|
+
"AGENTS.override.md": _codex_home() / "AGENTS.override.md",
|
|
1057
|
+
"migrations.json": _migration_state_path(),
|
|
1058
|
+
}
|
|
1059
|
+
symlinks = _read_json(user_backup / "symlinks.json", {})
|
|
1060
|
+
for name, dst in live_targets.items():
|
|
1061
|
+
src = user_backup / name
|
|
1062
|
+
if isinstance(symlinks, dict) and name in symlinks:
|
|
1063
|
+
with contextlib.suppress(FileNotFoundError):
|
|
1064
|
+
dst.unlink()
|
|
1065
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
1066
|
+
os.symlink(str(symlinks[name]), str(dst))
|
|
1067
|
+
elif src.exists():
|
|
1068
|
+
_atomic_copy(src, dst)
|
|
1069
|
+
elif isinstance(presence, dict) and presence.get(name) is False:
|
|
1070
|
+
with contextlib.suppress(FileNotFoundError):
|
|
1071
|
+
dst.unlink()
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _read_policy_bool(section: str, key: str, fallback: bool) -> bool:
|
|
1075
|
+
try:
|
|
1076
|
+
text = _policy_path().read_text(encoding="utf-8-sig")
|
|
1077
|
+
except OSError:
|
|
1078
|
+
return fallback
|
|
1079
|
+
return _as_bool(_simple_toml_value(text, section, key), fallback)
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _run_migrations(package_root: Path) -> list[str]:
|
|
1083
|
+
migrations_dir = package_root / "scripts" / "migrations"
|
|
1084
|
+
state = _read_json(_migration_state_path(), {"applied": []})
|
|
1085
|
+
applied = set(state.get("applied") or []) if isinstance(state, dict) else set()
|
|
1086
|
+
if not migrations_dir.exists():
|
|
1087
|
+
return sorted(applied)
|
|
1088
|
+
for script in sorted(migrations_dir.glob("*.py")):
|
|
1089
|
+
migration_id = script.stem
|
|
1090
|
+
if migration_id.startswith("_") or migration_id in applied:
|
|
1091
|
+
continue
|
|
1092
|
+
subprocess.run(
|
|
1093
|
+
[sys.executable, str(script), "--policy", str(_policy_path())],
|
|
1094
|
+
check=True,
|
|
1095
|
+
env=os.environ.copy(),
|
|
1096
|
+
)
|
|
1097
|
+
applied.add(migration_id)
|
|
1098
|
+
return sorted(applied)
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def _reconcile_runtime_config(package_root: Path) -> None:
|
|
1102
|
+
reconciler = package_root / "scripts" / "update_runtime_config.py"
|
|
1103
|
+
defaults = package_root / "policy" / "defaults.toml"
|
|
1104
|
+
if not reconciler.exists():
|
|
1105
|
+
raise RuntimeError("release package is missing runtime config reconciler")
|
|
1106
|
+
if not defaults.exists():
|
|
1107
|
+
raise RuntimeError("release package is missing policy defaults")
|
|
1108
|
+
subprocess.run(
|
|
1109
|
+
[
|
|
1110
|
+
sys.executable,
|
|
1111
|
+
str(reconciler),
|
|
1112
|
+
"--config",
|
|
1113
|
+
str(_codex_home() / "config.toml"),
|
|
1114
|
+
"--policy",
|
|
1115
|
+
str(_policy_path()),
|
|
1116
|
+
"--defaults",
|
|
1117
|
+
str(defaults),
|
|
1118
|
+
],
|
|
1119
|
+
check=True,
|
|
1120
|
+
env=os.environ.copy(),
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _sync_managed_runtime(package_root: Path) -> None:
|
|
1125
|
+
state = _state_dir()
|
|
1126
|
+
state.mkdir(parents=True, exist_ok=True)
|
|
1127
|
+
scripts = package_root / "scripts"
|
|
1128
|
+
for name in (
|
|
1129
|
+
"updater.py",
|
|
1130
|
+
"update_runtime_config.py",
|
|
1131
|
+
"telemetry.py",
|
|
1132
|
+
"manage-hooks.py",
|
|
1133
|
+
"manage-instructions.py",
|
|
1134
|
+
"menu.py",
|
|
1135
|
+
"localization.py",
|
|
1136
|
+
"ui.py",
|
|
1137
|
+
"doctor.py",
|
|
1138
|
+
"strategy_runtime.py",
|
|
1139
|
+
):
|
|
1140
|
+
src = scripts / name
|
|
1141
|
+
if src.exists():
|
|
1142
|
+
_atomic_copy(src, state / name, executable=True)
|
|
1143
|
+
for name in ("strategies", "telemetry_core"):
|
|
1144
|
+
src = scripts / name
|
|
1145
|
+
if src.exists():
|
|
1146
|
+
_replace_dir(src, state / name)
|
|
1147
|
+
defaults = package_root / "policy" / "defaults.toml"
|
|
1148
|
+
if defaults.exists():
|
|
1149
|
+
_atomic_copy(defaults, state / "defaults.toml")
|
|
1150
|
+
|
|
1151
|
+
agents_dir = _codex_home() / "agents"
|
|
1152
|
+
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
1153
|
+
for name in ("worker-explorer.toml", "worker-implementer.toml", "worker-reviewer.toml"):
|
|
1154
|
+
src = package_root / "templates" / "agents" / name
|
|
1155
|
+
if src.exists():
|
|
1156
|
+
_atomic_copy(src, agents_dir / name)
|
|
1157
|
+
skill_src = package_root / "templates" / "skills" / "flow-pilot" / "SKILL.md"
|
|
1158
|
+
if skill_src.exists():
|
|
1159
|
+
_atomic_copy(skill_src, _codex_home() / "skills" / "flow-pilot" / "SKILL.md")
|
|
1160
|
+
instruction_helper = state / "manage-instructions.py"
|
|
1161
|
+
instruction_template = state / "flow-pilot-instructions.md"
|
|
1162
|
+
template_src = package_root / "templates" / "flow-pilot-instructions.md"
|
|
1163
|
+
if template_src.exists():
|
|
1164
|
+
_atomic_copy(template_src, instruction_template)
|
|
1165
|
+
if instruction_helper.exists() and instruction_template.exists():
|
|
1166
|
+
subprocess.run(
|
|
1167
|
+
[
|
|
1168
|
+
sys.executable,
|
|
1169
|
+
str(instruction_helper),
|
|
1170
|
+
"--codex-home",
|
|
1171
|
+
str(_codex_home()),
|
|
1172
|
+
"--template",
|
|
1173
|
+
str(instruction_template),
|
|
1174
|
+
"install",
|
|
1175
|
+
],
|
|
1176
|
+
check=True,
|
|
1177
|
+
env=os.environ.copy(),
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
bin_dir = _bin_dir()
|
|
1181
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
1182
|
+
if os.name == "nt":
|
|
1183
|
+
for name in ("codex-flow.ps1", "codex-flow.cmd"):
|
|
1184
|
+
src = package_root / "bin" / name
|
|
1185
|
+
if src.exists():
|
|
1186
|
+
_atomic_copy(src, bin_dir / name)
|
|
1187
|
+
else:
|
|
1188
|
+
src = package_root / "bin" / "codex-flow"
|
|
1189
|
+
if src.exists():
|
|
1190
|
+
_atomic_copy(src, bin_dir / "codex-flow", executable=True)
|
|
1191
|
+
|
|
1192
|
+
if platform.system().lower() == "darwin":
|
|
1193
|
+
for name in ("FlowPilot", "codex-flow-overlay"):
|
|
1194
|
+
src = package_root / "apps" / "macos-overlay" / "bin" / name
|
|
1195
|
+
if src.exists():
|
|
1196
|
+
_atomic_copy(src, state / "bin" / name, executable=True)
|
|
1197
|
+
|
|
1198
|
+
# Hooks keep their stable state-dir target, so replacing telemetry.py does not
|
|
1199
|
+
# require re-authorization. Reconcile both enabled and disabled states so a
|
|
1200
|
+
# previously-installed managed hook cannot survive after telemetry is disabled.
|
|
1201
|
+
manage_hooks = state / "manage-hooks.py"
|
|
1202
|
+
if manage_hooks.exists():
|
|
1203
|
+
hooks = _codex_home() / "hooks.json"
|
|
1204
|
+
if _read_policy_bool("telemetry", "enabled", True):
|
|
1205
|
+
command = [
|
|
1206
|
+
sys.executable,
|
|
1207
|
+
str(manage_hooks),
|
|
1208
|
+
"install",
|
|
1209
|
+
"--hooks",
|
|
1210
|
+
str(hooks),
|
|
1211
|
+
"--script",
|
|
1212
|
+
str(state / "telemetry.py"),
|
|
1213
|
+
]
|
|
1214
|
+
else:
|
|
1215
|
+
command = [sys.executable, str(manage_hooks), "uninstall", "--hooks", str(hooks)]
|
|
1216
|
+
subprocess.run(command, check=True)
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def _run_health_check() -> None:
|
|
1220
|
+
doctor = _state_dir() / "doctor.py"
|
|
1221
|
+
if doctor.exists() and os.environ.get("CODEX_FLOW_UPDATE_SKIP_DOCTOR") != "1":
|
|
1222
|
+
subprocess.run([sys.executable, str(doctor)], check=True)
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _history() -> list[dict[str, Any]]:
|
|
1226
|
+
payload = _read_json(_history_path(), [])
|
|
1227
|
+
return payload if isinstance(payload, list) else []
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _append_history(entry: dict[str, Any]) -> None:
|
|
1231
|
+
history = _history()
|
|
1232
|
+
history.append(entry)
|
|
1233
|
+
retained = history[-20:]
|
|
1234
|
+
atomic_write_json(_history_path(), retained)
|
|
1235
|
+
referenced = {str(item.get("backup")) for item in retained if item.get("backup")}
|
|
1236
|
+
backup_root = _state_dir() / "backups"
|
|
1237
|
+
if backup_root.exists():
|
|
1238
|
+
for candidate in backup_root.iterdir():
|
|
1239
|
+
if candidate.is_dir() and str(candidate) not in referenced:
|
|
1240
|
+
shutil.rmtree(candidate, ignore_errors=True)
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def _ensure_current_version_package(version: str) -> Path | None:
|
|
1244
|
+
"""Capture the pre-OTA installation so the first OTA update can roll back."""
|
|
1245
|
+
|
|
1246
|
+
versions = _state_dir() / "versions"
|
|
1247
|
+
versions.mkdir(parents=True, exist_ok=True)
|
|
1248
|
+
target = versions / version
|
|
1249
|
+
if target.exists():
|
|
1250
|
+
return target
|
|
1251
|
+
staging = versions / f".{version}.bootstrap-{os.getpid()}"
|
|
1252
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
1253
|
+
staging.mkdir(parents=True)
|
|
1254
|
+
source = None
|
|
1255
|
+
source_file = _state_dir() / "source"
|
|
1256
|
+
with contextlib.suppress(OSError):
|
|
1257
|
+
candidate = Path(source_file.read_text(encoding="utf-8-sig").strip())
|
|
1258
|
+
if candidate.exists() and (candidate / "VERSION").exists():
|
|
1259
|
+
source = candidate
|
|
1260
|
+
try:
|
|
1261
|
+
if source is not None:
|
|
1262
|
+
package_items = [
|
|
1263
|
+
"VERSION",
|
|
1264
|
+
"LICENSE",
|
|
1265
|
+
"README.md",
|
|
1266
|
+
"README.en.md",
|
|
1267
|
+
"install.sh",
|
|
1268
|
+
"install.ps1",
|
|
1269
|
+
"bin",
|
|
1270
|
+
"scripts",
|
|
1271
|
+
"templates",
|
|
1272
|
+
"completions",
|
|
1273
|
+
"policy",
|
|
1274
|
+
"benchmark",
|
|
1275
|
+
"apps/chatgpt-mcp",
|
|
1276
|
+
]
|
|
1277
|
+
if platform.system().lower() == "darwin":
|
|
1278
|
+
package_items.append("apps/macos-overlay")
|
|
1279
|
+
for name in package_items:
|
|
1280
|
+
src = source / name
|
|
1281
|
+
dst = staging / name
|
|
1282
|
+
if src.is_dir():
|
|
1283
|
+
shutil.copytree(src, dst, ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"))
|
|
1284
|
+
elif src.is_file():
|
|
1285
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
1286
|
+
shutil.copy2(src, dst)
|
|
1287
|
+
else:
|
|
1288
|
+
(staging / "VERSION").write_text(version + "\n", encoding="utf-8")
|
|
1289
|
+
scripts = staging / "scripts"
|
|
1290
|
+
scripts.mkdir(parents=True)
|
|
1291
|
+
for name in ("updater.py", "update_runtime_config.py", "telemetry.py", "manage-hooks.py", "manage-instructions.py", "menu.py", "localization.py", "ui.py", "doctor.py", "strategy_runtime.py"):
|
|
1292
|
+
src = _state_dir() / name
|
|
1293
|
+
if src.exists():
|
|
1294
|
+
shutil.copy2(src, scripts / name)
|
|
1295
|
+
for name in ("strategies", "telemetry_core"):
|
|
1296
|
+
src = _state_dir() / name
|
|
1297
|
+
if src.exists():
|
|
1298
|
+
shutil.copytree(src, scripts / name)
|
|
1299
|
+
(staging / "bin").mkdir()
|
|
1300
|
+
for name in ("codex-flow", "codex-flow.ps1", "codex-flow.cmd"):
|
|
1301
|
+
src = _bin_dir() / name
|
|
1302
|
+
if src.exists():
|
|
1303
|
+
shutil.copy2(src, staging / "bin" / name)
|
|
1304
|
+
defaults = _state_dir() / "defaults.toml"
|
|
1305
|
+
if defaults.exists():
|
|
1306
|
+
(staging / "policy").mkdir()
|
|
1307
|
+
shutil.copy2(defaults, staging / "policy" / "defaults.toml")
|
|
1308
|
+
instruction_template = _state_dir() / "flow-pilot-instructions.md"
|
|
1309
|
+
if instruction_template.exists():
|
|
1310
|
+
(staging / "templates").mkdir()
|
|
1311
|
+
shutil.copy2(instruction_template, staging / "templates" / "flow-pilot-instructions.md")
|
|
1312
|
+
for name in ("FlowPilot", "codex-flow-overlay"):
|
|
1313
|
+
src = _state_dir() / "bin" / name
|
|
1314
|
+
if src.exists():
|
|
1315
|
+
dst = staging / "apps" / "macos-overlay" / "bin" / name
|
|
1316
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
1317
|
+
shutil.copy2(src, dst)
|
|
1318
|
+
os.replace(staging, target)
|
|
1319
|
+
return target
|
|
1320
|
+
except Exception:
|
|
1321
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
1322
|
+
return None
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
def _install_package(package_root: Path, version: str, manifest: dict[str, Any]) -> UpdateState:
|
|
1326
|
+
state_dir = _state_dir()
|
|
1327
|
+
versions = state_dir / "versions"
|
|
1328
|
+
versions.mkdir(parents=True, exist_ok=True)
|
|
1329
|
+
target_version = versions / version
|
|
1330
|
+
current = current_version()
|
|
1331
|
+
_ensure_current_version_package(current)
|
|
1332
|
+
backup = _snapshot(current)
|
|
1333
|
+
previous_source = None
|
|
1334
|
+
source_path = state_dir / "source"
|
|
1335
|
+
with contextlib.suppress(OSError):
|
|
1336
|
+
previous_source = source_path.read_text(encoding="utf-8-sig").strip()
|
|
1337
|
+
|
|
1338
|
+
staged_version = versions / f".{version}.staging-{os.getpid()}"
|
|
1339
|
+
shutil.rmtree(staged_version, ignore_errors=True)
|
|
1340
|
+
shutil.copytree(package_root, staged_version)
|
|
1341
|
+
try:
|
|
1342
|
+
applied_migrations = _run_migrations(staged_version)
|
|
1343
|
+
_reconcile_runtime_config(staged_version)
|
|
1344
|
+
_sync_managed_runtime(staged_version)
|
|
1345
|
+
_run_health_check()
|
|
1346
|
+
atomic_write_json(_migration_state_path(), {"applied": applied_migrations})
|
|
1347
|
+
if target_version.exists():
|
|
1348
|
+
shutil.rmtree(target_version)
|
|
1349
|
+
os.replace(staged_version, target_version)
|
|
1350
|
+
atomic_write_text(state_dir / "previous-version", current + "\n")
|
|
1351
|
+
atomic_write_text(state_dir / "version", version + "\n")
|
|
1352
|
+
atomic_write_text(state_dir / "current-version", version + "\n")
|
|
1353
|
+
atomic_write_text(state_dir / "source", str(target_version) + "\n")
|
|
1354
|
+
state = load_state()
|
|
1355
|
+
state.current_version = version
|
|
1356
|
+
state.latest_version = _strip_v(str(manifest.get("version") or version))
|
|
1357
|
+
state.previous_version = current
|
|
1358
|
+
state.update_available = is_newer(state.latest_version, version)
|
|
1359
|
+
state.status = "available" if state.update_available else "latest"
|
|
1360
|
+
restart_req, pending_pids, restart_reason = _resolve_codex_restart_requirement(backup, manifest)
|
|
1361
|
+
state.restart_required = restart_req
|
|
1362
|
+
state.pending_codex_pids = pending_pids
|
|
1363
|
+
state.restart_reason = restart_reason
|
|
1364
|
+
state.flowpilot_restart_required = (
|
|
1365
|
+
platform.system().lower() == "darwin"
|
|
1366
|
+
and any((target_version / "apps" / "macos-overlay" / "bin" / name).exists() for name in ("FlowPilot", "codex-flow-overlay"))
|
|
1367
|
+
)
|
|
1368
|
+
state.installed_at = utc_now()
|
|
1369
|
+
state.last_error = None
|
|
1370
|
+
state.progress = 1.0
|
|
1371
|
+
save_state(state)
|
|
1372
|
+
_append_history(
|
|
1373
|
+
{
|
|
1374
|
+
"from": current,
|
|
1375
|
+
"to": version,
|
|
1376
|
+
"installed_at": state.installed_at,
|
|
1377
|
+
"backup": str(backup),
|
|
1378
|
+
"previous_source": previous_source,
|
|
1379
|
+
}
|
|
1380
|
+
)
|
|
1381
|
+
return state
|
|
1382
|
+
except Exception:
|
|
1383
|
+
_restore_snapshot(backup)
|
|
1384
|
+
shutil.rmtree(backup, ignore_errors=True)
|
|
1385
|
+
raise
|
|
1386
|
+
finally:
|
|
1387
|
+
shutil.rmtree(staged_version, ignore_errors=True)
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def _perform_update_unlocked(*, force_check: bool = True) -> UpdateState:
|
|
1391
|
+
state = check_for_updates(force=force_check)
|
|
1392
|
+
manifest = state.extra.get("manifest") if isinstance(state.extra, dict) else None
|
|
1393
|
+
if not state.update_available:
|
|
1394
|
+
return state
|
|
1395
|
+
if not isinstance(manifest, dict):
|
|
1396
|
+
raise RuntimeError("no installable update manifest is cached")
|
|
1397
|
+
artifact = _artifact_for_platform(manifest)
|
|
1398
|
+
if not artifact:
|
|
1399
|
+
raise RuntimeError(f"release does not provide an artifact for {platform_key()}")
|
|
1400
|
+
url = str(artifact.get("url") or "")
|
|
1401
|
+
expected = str(artifact.get("sha256") or "").lower()
|
|
1402
|
+
if not url or not re.fullmatch(r"[0-9a-f]{64}", expected):
|
|
1403
|
+
raise RuntimeError("release artifact metadata is incomplete")
|
|
1404
|
+
|
|
1405
|
+
version = _strip_v(str(manifest["version"]))
|
|
1406
|
+
state.status = "downloading"
|
|
1407
|
+
state.progress = 0.0
|
|
1408
|
+
save_state(state)
|
|
1409
|
+
with tempfile.TemporaryDirectory(prefix="codex-flow-update-") as tmp_value:
|
|
1410
|
+
tmp = Path(tmp_value)
|
|
1411
|
+
suffix = ".zip" if url.lower().endswith(".zip") else ".tar.gz"
|
|
1412
|
+
archive = tmp / f"release{suffix}"
|
|
1413
|
+
_download(url, archive)
|
|
1414
|
+
state.progress = 0.55
|
|
1415
|
+
save_state(state)
|
|
1416
|
+
actual = _sha256(archive)
|
|
1417
|
+
if actual.lower() != expected:
|
|
1418
|
+
raise RuntimeError(f"checksum mismatch for update artifact: expected {expected}, got {actual}")
|
|
1419
|
+
extract = tmp / "extract"
|
|
1420
|
+
safe_extract(archive, extract)
|
|
1421
|
+
root = _package_root(extract)
|
|
1422
|
+
package_version = _strip_v((root / "VERSION").read_text(encoding="utf-8-sig").strip())
|
|
1423
|
+
if package_version != version:
|
|
1424
|
+
raise RuntimeError(f"artifact version {package_version} does not match manifest {version}")
|
|
1425
|
+
state.status = "installing"
|
|
1426
|
+
state.progress = 0.8
|
|
1427
|
+
save_state(state)
|
|
1428
|
+
return _install_package(root, version, manifest)
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def perform_update(*, force_check: bool = True) -> UpdateState:
|
|
1432
|
+
with install_lock():
|
|
1433
|
+
return _perform_update_unlocked(force_check=force_check)
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def _rollback_unlocked() -> UpdateState:
|
|
1437
|
+
state_dir = _state_dir()
|
|
1438
|
+
current = current_version()
|
|
1439
|
+
previous_path = state_dir / "previous-version"
|
|
1440
|
+
if not previous_path.exists():
|
|
1441
|
+
raise RuntimeError("no previous codex-flow version is available for rollback")
|
|
1442
|
+
previous = _strip_v(previous_path.read_text(encoding="utf-8-sig").strip())
|
|
1443
|
+
history_entry = next(
|
|
1444
|
+
(entry for entry in reversed(_history()) if entry.get("from") == previous and entry.get("to") == current and entry.get("backup")),
|
|
1445
|
+
None,
|
|
1446
|
+
)
|
|
1447
|
+
historical_backup = Path(str(history_entry["backup"])) if history_entry else None
|
|
1448
|
+
has_exact_snapshot = bool(historical_backup and historical_backup.exists())
|
|
1449
|
+
package_root = state_dir / "versions" / previous
|
|
1450
|
+
if not has_exact_snapshot and not package_root.exists():
|
|
1451
|
+
raise RuntimeError(f"rollback package is missing: {package_root}")
|
|
1452
|
+
backup = _snapshot(current)
|
|
1453
|
+
try:
|
|
1454
|
+
if has_exact_snapshot:
|
|
1455
|
+
assert historical_backup is not None
|
|
1456
|
+
_restore_snapshot(historical_backup)
|
|
1457
|
+
else:
|
|
1458
|
+
_reconcile_runtime_config(package_root)
|
|
1459
|
+
_sync_managed_runtime(package_root)
|
|
1460
|
+
_run_health_check()
|
|
1461
|
+
atomic_write_text(state_dir / "version", previous + "\n")
|
|
1462
|
+
atomic_write_text(state_dir / "current-version", previous + "\n")
|
|
1463
|
+
if not has_exact_snapshot:
|
|
1464
|
+
atomic_write_text(state_dir / "source", str(package_root) + "\n")
|
|
1465
|
+
atomic_write_text(state_dir / "previous-version", current + "\n")
|
|
1466
|
+
state = load_state()
|
|
1467
|
+
state.current_version = previous
|
|
1468
|
+
state.previous_version = current
|
|
1469
|
+
state.update_available = bool(state.latest_version and is_newer(state.latest_version, previous))
|
|
1470
|
+
state.status = "available" if state.update_available else "latest"
|
|
1471
|
+
restart_req, pending_pids, restart_reason = _resolve_codex_restart_requirement(backup)
|
|
1472
|
+
state.restart_required = restart_req
|
|
1473
|
+
state.pending_codex_pids = pending_pids
|
|
1474
|
+
state.restart_reason = restart_reason
|
|
1475
|
+
state.flowpilot_restart_required = (
|
|
1476
|
+
platform.system().lower() == "darwin"
|
|
1477
|
+
and any((_state_dir() / "bin" / name).exists() for name in ("FlowPilot", "codex-flow-overlay"))
|
|
1478
|
+
)
|
|
1479
|
+
state.last_error = None
|
|
1480
|
+
save_state(state)
|
|
1481
|
+
_append_history({"from": current, "to": previous, "rollback": True, "installed_at": utc_now(), "backup": str(backup)})
|
|
1482
|
+
return state
|
|
1483
|
+
except Exception:
|
|
1484
|
+
_restore_snapshot(backup)
|
|
1485
|
+
shutil.rmtree(backup, ignore_errors=True)
|
|
1486
|
+
raise
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def rollback() -> UpdateState:
|
|
1490
|
+
with install_lock():
|
|
1491
|
+
return _rollback_unlocked()
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
def _legacy_git_update() -> int:
|
|
1495
|
+
"""Compatibility bridge for pre-OTA releases and source installs.
|
|
1496
|
+
|
|
1497
|
+
Once a release ships a manifest/artifact this path is not used. Keeping it
|
|
1498
|
+
temporarily means existing users and old smoke tests still have a recovery
|
|
1499
|
+
path while the release channel is being bootstrapped.
|
|
1500
|
+
"""
|
|
1501
|
+
|
|
1502
|
+
source_file = _state_dir() / "source"
|
|
1503
|
+
try:
|
|
1504
|
+
src = Path(source_file.read_text(encoding="utf-8-sig").strip())
|
|
1505
|
+
except OSError:
|
|
1506
|
+
return 2
|
|
1507
|
+
if not (src / ".git").exists():
|
|
1508
|
+
return 2
|
|
1509
|
+
try:
|
|
1510
|
+
branch = subprocess.check_output(
|
|
1511
|
+
["git", "-C", str(src), "rev-parse", "--abbrev-ref", "HEAD"],
|
|
1512
|
+
text=True,
|
|
1513
|
+
stderr=subprocess.DEVNULL,
|
|
1514
|
+
).strip()
|
|
1515
|
+
if branch and branch != "HEAD":
|
|
1516
|
+
remote = subprocess.run(
|
|
1517
|
+
["git", "-C", str(src), "config", "--get", f"branch.{branch}.remote"],
|
|
1518
|
+
check=False,
|
|
1519
|
+
stdout=subprocess.PIPE,
|
|
1520
|
+
stderr=subprocess.DEVNULL,
|
|
1521
|
+
text=True,
|
|
1522
|
+
).stdout.strip() or "origin"
|
|
1523
|
+
subprocess.run(["git", "-C", str(src), "pull", "--ff-only", remote, branch], check=True)
|
|
1524
|
+
if os.name == "nt":
|
|
1525
|
+
subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(src / "install.ps1")], check=True)
|
|
1526
|
+
else:
|
|
1527
|
+
subprocess.run(["bash", str(src / "install.sh")], check=True)
|
|
1528
|
+
return 0
|
|
1529
|
+
except (OSError, subprocess.CalledProcessError):
|
|
1530
|
+
return 2
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def _legacy_fallback_allowed(exc: Exception) -> bool:
|
|
1534
|
+
# Legacy git update exists only to bootstrap users while the first OTA-aware
|
|
1535
|
+
# release is rolling out. Never bypass checksum, transaction, concurrency,
|
|
1536
|
+
# migration, or health-check failures with an unverified git pull.
|
|
1537
|
+
message = str(exc)
|
|
1538
|
+
return MANIFEST_ASSET in message and "does not provide" in message
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
def _print_status(state: UpdateState, as_json: bool = False) -> None:
|
|
1542
|
+
if as_json:
|
|
1543
|
+
print(json.dumps(state.to_mapping(), ensure_ascii=False, indent=2, sort_keys=True))
|
|
1544
|
+
return
|
|
1545
|
+
if state.flowpilot_restart_required and state.restart_required:
|
|
1546
|
+
print(f"✓ codex-flow v{state.current_version} installed; restart FlowPilot and Codex to activate all updated components.")
|
|
1547
|
+
elif state.flowpilot_restart_required:
|
|
1548
|
+
print(f"✓ codex-flow v{state.current_version} installed; restart FlowPilot to load the updated app binary.")
|
|
1549
|
+
elif state.restart_required:
|
|
1550
|
+
suffix = f" ({state.restart_reason})" if state.restart_reason else ""
|
|
1551
|
+
print(f"✓ codex-flow v{state.current_version} installed; restart Codex to activate updated policy/hooks snapshots.{suffix}")
|
|
1552
|
+
elif state.update_available and state.latest_version:
|
|
1553
|
+
suffix = "" if state.artifact_available else " (release package not available yet)"
|
|
1554
|
+
print(f"↑ codex-flow v{state.latest_version} is available; current v{state.current_version}{suffix}")
|
|
1555
|
+
elif state.status == "latest":
|
|
1556
|
+
print(f"✓ codex-flow v{state.current_version} is up to date.")
|
|
1557
|
+
elif state.last_error:
|
|
1558
|
+
print(f"! Update check unavailable: {state.last_error}")
|
|
1559
|
+
else:
|
|
1560
|
+
print(f"codex-flow v{state.current_version}: update status unknown")
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1564
|
+
parser = argparse.ArgumentParser(description="codex-flow OTA updater")
|
|
1565
|
+
parser.add_argument("command", nargs="?", choices=("rollback", "auto-install", "auto-update"))
|
|
1566
|
+
parser.add_argument(
|
|
1567
|
+
"action",
|
|
1568
|
+
nargs="?",
|
|
1569
|
+
choices=("status", "enable", "disable", "on", "off"),
|
|
1570
|
+
default=None,
|
|
1571
|
+
help="action for auto-install command: status|enable|disable|on|off",
|
|
1572
|
+
)
|
|
1573
|
+
parser.add_argument("--check", action="store_true", help="check only; never install")
|
|
1574
|
+
parser.add_argument("--auto", action="store_true", help="perform auto-update when enabled in policy")
|
|
1575
|
+
parser.add_argument("--force", action="store_true", help="ignore cached check interval")
|
|
1576
|
+
parser.add_argument("--quiet", action="store_true")
|
|
1577
|
+
parser.add_argument("--json", action="store_true")
|
|
1578
|
+
parser.add_argument(
|
|
1579
|
+
"--ack-restart",
|
|
1580
|
+
action="store_true",
|
|
1581
|
+
help="clear Codex restart reminder after Codex has been fully restarted",
|
|
1582
|
+
)
|
|
1583
|
+
parser.add_argument(
|
|
1584
|
+
"--ack-flowpilot-restart",
|
|
1585
|
+
action="store_true",
|
|
1586
|
+
help="clear FlowPilot restart reminder after the updated FlowPilot binary has launched",
|
|
1587
|
+
)
|
|
1588
|
+
parser.add_argument("--no-legacy-fallback", action="store_true")
|
|
1589
|
+
return parser
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def main(argv: Iterable[str] | None = None) -> int:
|
|
1593
|
+
args = build_parser().parse_args(list(argv) if argv is not None else None)
|
|
1594
|
+
_update_dir().mkdir(parents=True, exist_ok=True)
|
|
1595
|
+
if args.ack_restart:
|
|
1596
|
+
state = load_state()
|
|
1597
|
+
state.restart_required = False
|
|
1598
|
+
state.pending_codex_pids = []
|
|
1599
|
+
state.restart_reason = None
|
|
1600
|
+
save_state(state)
|
|
1601
|
+
if not args.quiet:
|
|
1602
|
+
print("✓ Codex restart reminder cleared.")
|
|
1603
|
+
return 0
|
|
1604
|
+
if args.ack_flowpilot_restart:
|
|
1605
|
+
state = load_state()
|
|
1606
|
+
state.flowpilot_restart_required = False
|
|
1607
|
+
save_state(state)
|
|
1608
|
+
if not args.quiet:
|
|
1609
|
+
print("✓ FlowPilot restart reminder cleared.")
|
|
1610
|
+
return 0
|
|
1611
|
+
if args.command in {"auto-install", "auto-update"}:
|
|
1612
|
+
action = (args.action or "status").lower()
|
|
1613
|
+
policy = _policy_path()
|
|
1614
|
+
if action in {"enable", "on"}:
|
|
1615
|
+
set_policy_value(policy, "update", "auto_install", "true", quote=False)
|
|
1616
|
+
state = load_state()
|
|
1617
|
+
state.auto_install = True
|
|
1618
|
+
save_state(state)
|
|
1619
|
+
if not args.quiet:
|
|
1620
|
+
if args.json:
|
|
1621
|
+
print(json.dumps({"auto_install": True}, ensure_ascii=False))
|
|
1622
|
+
else:
|
|
1623
|
+
print("✓ Auto-update enabled.")
|
|
1624
|
+
return 0
|
|
1625
|
+
if action in {"disable", "off"}:
|
|
1626
|
+
set_policy_value(policy, "update", "auto_install", "false", quote=False)
|
|
1627
|
+
state = load_state()
|
|
1628
|
+
state.auto_install = False
|
|
1629
|
+
save_state(state)
|
|
1630
|
+
if not args.quiet:
|
|
1631
|
+
if args.json:
|
|
1632
|
+
print(json.dumps({"auto_install": False}, ensure_ascii=False))
|
|
1633
|
+
else:
|
|
1634
|
+
print("○ Auto-update disabled.")
|
|
1635
|
+
return 0
|
|
1636
|
+
config = load_config()
|
|
1637
|
+
if args.json:
|
|
1638
|
+
print(json.dumps({"auto_install": config.auto_install}, ensure_ascii=False))
|
|
1639
|
+
else:
|
|
1640
|
+
print(f"auto_install = {'true' if config.auto_install else 'false'}")
|
|
1641
|
+
return 0
|
|
1642
|
+
|
|
1643
|
+
if args.command == "rollback":
|
|
1644
|
+
try:
|
|
1645
|
+
state = rollback()
|
|
1646
|
+
if not args.quiet:
|
|
1647
|
+
print(f"↩ Rolled back codex-flow to v{state.current_version}.")
|
|
1648
|
+
if state.flowpilot_restart_required:
|
|
1649
|
+
print("⚠ Restart FlowPilot to load the rolled-back app binary.")
|
|
1650
|
+
if state.restart_required:
|
|
1651
|
+
print("⚠ Restart Codex to activate rolled-back policy/hooks snapshots.")
|
|
1652
|
+
return 0
|
|
1653
|
+
except Exception as exc:
|
|
1654
|
+
print(f"codex-flow rollback failed: {exc}", file=sys.stderr)
|
|
1655
|
+
return 1
|
|
1656
|
+
|
|
1657
|
+
if args.auto:
|
|
1658
|
+
config = load_config()
|
|
1659
|
+
if not config.auto_install:
|
|
1660
|
+
return 0
|
|
1661
|
+
with update_lock() as acquired:
|
|
1662
|
+
if not acquired:
|
|
1663
|
+
return 0
|
|
1664
|
+
state = check_for_updates(force=args.force, quiet=args.quiet)
|
|
1665
|
+
if state.update_available and state.artifact_available:
|
|
1666
|
+
state = perform_update(force_check=False)
|
|
1667
|
+
if not args.quiet:
|
|
1668
|
+
print(f"✨ Updated codex-flow to v{state.current_version}.")
|
|
1669
|
+
if state.flowpilot_restart_required:
|
|
1670
|
+
print("⚠ Restart FlowPilot to load the updated app binary.")
|
|
1671
|
+
if state.restart_required:
|
|
1672
|
+
print("⚠ Restart Codex to activate updated FlowPilot policy/hooks snapshots.")
|
|
1673
|
+
return 0
|
|
1674
|
+
|
|
1675
|
+
if args.check:
|
|
1676
|
+
with update_lock() as acquired:
|
|
1677
|
+
state = check_for_updates(force=args.force, quiet=args.quiet) if acquired else load_state()
|
|
1678
|
+
if not args.quiet:
|
|
1679
|
+
_print_status(state, as_json=args.json)
|
|
1680
|
+
return 0 if not state.last_error or state.latest_version else 1
|
|
1681
|
+
|
|
1682
|
+
try:
|
|
1683
|
+
state = check_for_updates(force=True)
|
|
1684
|
+
if state.update_available and state.artifact_available:
|
|
1685
|
+
state = perform_update(force_check=False)
|
|
1686
|
+
if not args.quiet:
|
|
1687
|
+
print(f"✨ Updated codex-flow to v{state.current_version}.")
|
|
1688
|
+
if state.flowpilot_restart_required:
|
|
1689
|
+
print("⚠ Restart FlowPilot to load the updated app binary.")
|
|
1690
|
+
if state.restart_required:
|
|
1691
|
+
print("⚠ Restart Codex to activate updated FlowPilot policy/hooks snapshots.")
|
|
1692
|
+
return 0
|
|
1693
|
+
if state.update_available and not state.artifact_available:
|
|
1694
|
+
raise RuntimeError("new release detected but no installable artifact is available")
|
|
1695
|
+
if state.status == "latest":
|
|
1696
|
+
if not args.quiet:
|
|
1697
|
+
_print_status(state, as_json=args.json)
|
|
1698
|
+
return 0
|
|
1699
|
+
if state.last_error:
|
|
1700
|
+
raise RuntimeError(state.last_error)
|
|
1701
|
+
if not args.quiet:
|
|
1702
|
+
_print_status(state, as_json=args.json)
|
|
1703
|
+
return 0
|
|
1704
|
+
except Exception as exc:
|
|
1705
|
+
if (
|
|
1706
|
+
not args.no_legacy_fallback
|
|
1707
|
+
and _legacy_fallback_allowed(exc)
|
|
1708
|
+
and _legacy_git_update() == 0
|
|
1709
|
+
):
|
|
1710
|
+
if not args.quiet:
|
|
1711
|
+
print("✓ Updated using the legacy source checkout fallback. Future release packages will use OTA automatically.")
|
|
1712
|
+
return 0
|
|
1713
|
+
print(f"codex-flow update failed: {exc}", file=sys.stderr)
|
|
1714
|
+
return 1
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
if __name__ == "__main__":
|
|
1718
|
+
raise SystemExit(main())
|