netizen-cli 0.10.0__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.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
netizen_cli/cli_setup.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Explicit, per-instance setup; package installation has no setup side effects."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import getpass
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pwd
|
|
9
|
+
import secrets
|
|
10
|
+
import stat
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Callable, IO
|
|
16
|
+
|
|
17
|
+
from .lark_app import encode_lark_app, load_lark_app
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SetupError(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _write_atomic(path: Path, content: bytes, *, mode: int) -> None:
|
|
25
|
+
"""Save private setup files without importing the retired installer."""
|
|
26
|
+
parent = path.parent
|
|
27
|
+
if parent.is_symlink():
|
|
28
|
+
raise SetupError(f"configuration parent must not be a symlink: {parent}")
|
|
29
|
+
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
30
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=parent)
|
|
31
|
+
temporary = Path(temporary_name)
|
|
32
|
+
try:
|
|
33
|
+
os.fchmod(descriptor, mode)
|
|
34
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
35
|
+
output.write(content)
|
|
36
|
+
output.flush()
|
|
37
|
+
os.fsync(output.fileno())
|
|
38
|
+
os.replace(temporary, path)
|
|
39
|
+
directory = os.open(parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
40
|
+
try:
|
|
41
|
+
os.fsync(directory)
|
|
42
|
+
finally:
|
|
43
|
+
os.close(directory)
|
|
44
|
+
except BaseException:
|
|
45
|
+
with contextlib.suppress(OSError):
|
|
46
|
+
os.close(descriptor)
|
|
47
|
+
with contextlib.suppress(OSError):
|
|
48
|
+
temporary.unlink()
|
|
49
|
+
raise
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _private_file(path: Path) -> None:
|
|
53
|
+
metadata = path.lstat()
|
|
54
|
+
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.geteuid()
|
|
55
|
+
or stat.S_IMODE(metadata.st_mode) & 0o077):
|
|
56
|
+
raise SetupError(f"expected a private current-user regular file: {path}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _helper(module: str, arguments: list[str], *, timeout: float,
|
|
60
|
+
runner: Callable = subprocess.run, progress: bool = False) -> dict:
|
|
61
|
+
"""Capture credentials privately; never include stdout in an exception."""
|
|
62
|
+
try:
|
|
63
|
+
result = runner(
|
|
64
|
+
[os.path.abspath(sys.executable), "-E", "-P", "-B", "-u", "-m", module, *arguments],
|
|
65
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
|
66
|
+
stderr=None if progress else subprocess.PIPE,
|
|
67
|
+
text=True, check=False, timeout=timeout,
|
|
68
|
+
)
|
|
69
|
+
except (OSError, subprocess.SubprocessError):
|
|
70
|
+
raise SetupError("setup helper failed or timed out; rerun netizen setup") from None
|
|
71
|
+
if result.returncode == 130:
|
|
72
|
+
raise KeyboardInterrupt
|
|
73
|
+
if result.returncode != 0:
|
|
74
|
+
raise SetupError("setup helper did not complete; rerun netizen setup")
|
|
75
|
+
try:
|
|
76
|
+
value = json.loads(result.stdout)
|
|
77
|
+
except (TypeError, ValueError):
|
|
78
|
+
raise SetupError("setup helper returned an invalid result") from None
|
|
79
|
+
if not isinstance(value, dict) or value.get("version") != 1:
|
|
80
|
+
raise SetupError("setup helper returned an invalid result")
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def require_codex_login(*, runner: Callable = subprocess.run) -> None:
|
|
85
|
+
"""Check the selected account context without executing shell startup code.
|
|
86
|
+
|
|
87
|
+
Login profiles run only inside the managed service boundary. An explicitly
|
|
88
|
+
selected CODEX_HOME is the same lexical absolute path used by registration.
|
|
89
|
+
"""
|
|
90
|
+
account = pwd.getpwuid(os.geteuid())
|
|
91
|
+
environment = dict(os.environ)
|
|
92
|
+
selected_codex_home = environment.get("CODEX_HOME")
|
|
93
|
+
codex_home = (Path(selected_codex_home).expanduser().absolute()
|
|
94
|
+
if selected_codex_home else Path(account.pw_dir) / ".codex")
|
|
95
|
+
environment.update(HOME=account.pw_dir, USER=account.pw_name, LOGNAME=account.pw_name,
|
|
96
|
+
SHELL=account.pw_shell, CODEX_HOME=str(codex_home))
|
|
97
|
+
for name in ("PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV", "__PYVENV_LAUNCHER__"):
|
|
98
|
+
environment.pop(name, None)
|
|
99
|
+
try:
|
|
100
|
+
result = runner(
|
|
101
|
+
[os.path.abspath(sys.executable), "-E", "-P", "-c",
|
|
102
|
+
"import os; from codex_cli_bin import bundled_codex_path; "
|
|
103
|
+
"p = os.fspath(bundled_codex_path()); os.execv(p, [p, 'login', 'status'])"],
|
|
104
|
+
stdin=subprocess.DEVNULL, capture_output=True, text=True, check=False,
|
|
105
|
+
timeout=30, env=environment, cwd=account.pw_dir,
|
|
106
|
+
)
|
|
107
|
+
except (OSError, subprocess.SubprocessError):
|
|
108
|
+
raise SetupError("could not check the bundled Codex login") from None
|
|
109
|
+
if result.returncode != 0:
|
|
110
|
+
raise SetupError("sign in to Codex for this account, then rerun netizen setup")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _register(root: Path, app_id: str | None, *, runner: Callable) -> None:
|
|
114
|
+
arguments = ["--app-id", app_id] if app_id else []
|
|
115
|
+
value = _helper("netizen_cli.feishu_app_onboarding", arguments,
|
|
116
|
+
timeout=660, runner=runner, progress=True)
|
|
117
|
+
if (set(value) != {"version", "appId", "appSecret"}
|
|
118
|
+
or not isinstance(value["appId"], str)
|
|
119
|
+
or not isinstance(value["appSecret"], str)
|
|
120
|
+
or not value["appId"] or not value["appSecret"]
|
|
121
|
+
or (app_id is not None and value["appId"] != app_id)):
|
|
122
|
+
raise SetupError("browser setup returned invalid or mismatched app credentials")
|
|
123
|
+
encoded = encode_lark_app(value["appId"], value["appSecret"])
|
|
124
|
+
_write_atomic(root / "lark-app/config.json", encoded, mode=0o600)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _manual_credentials(root: Path, app_id: str | None, *, source: IO[str],
|
|
128
|
+
secret_prompt: Callable[[str], str]) -> None:
|
|
129
|
+
if not app_id:
|
|
130
|
+
print("Feishu App ID: ", end="", file=sys.stderr, flush=True)
|
|
131
|
+
app_id = source.readline().strip()
|
|
132
|
+
secret = secret_prompt("Feishu App Secret (not echoed): ").strip()
|
|
133
|
+
if not app_id or not secret:
|
|
134
|
+
raise SetupError("App ID and App Secret must not be empty")
|
|
135
|
+
_write_atomic(root / "lark-app/config.json", encode_lark_app(app_id, secret), mode=0o600)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _browser_or_manual(root: Path, app_id: str | None, *, runner: Callable,
|
|
139
|
+
source: IO[str], secret_prompt: Callable[[str], str]) -> None:
|
|
140
|
+
try:
|
|
141
|
+
_register(root, app_id, runner=runner)
|
|
142
|
+
except SetupError:
|
|
143
|
+
if not source.isatty():
|
|
144
|
+
raise
|
|
145
|
+
# Only one browser attempt. Never echo helper output or retry in a loop.
|
|
146
|
+
print("Browser setup did not complete. Enter credentials manually, or press Ctrl-C to cancel.",
|
|
147
|
+
file=sys.stderr)
|
|
148
|
+
_manual_credentials(root, app_id, source=source, secret_prompt=secret_prompt)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _missing_permissions(root: Path, *, runner: Callable) -> list[str]:
|
|
152
|
+
from .feishu_app_onboarding import REQUIRED_TENANT_SCOPES
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
value = _helper("netizen_cli.feishu_app_permissions",
|
|
156
|
+
["--lark-app-config", str(root / "lark-app/config.json")],
|
|
157
|
+
timeout=90, runner=runner)
|
|
158
|
+
except SetupError:
|
|
159
|
+
raise SetupError(
|
|
160
|
+
"could not verify saved app credentials/permissions; check network and app status. "
|
|
161
|
+
"If the App Secret is invalid, clear only appSecret in this instance's "
|
|
162
|
+
"lark-app/config.json (keep appId), then rerun netizen setup for exact-App repair"
|
|
163
|
+
) from None
|
|
164
|
+
missing = value.get("missingScopes")
|
|
165
|
+
if (set(value) != {"version", "missingScopes"} or not isinstance(missing, list)
|
|
166
|
+
or any(not isinstance(scope, str) for scope in missing)
|
|
167
|
+
or missing != [scope for scope in REQUIRED_TENANT_SCOPES if scope in missing]):
|
|
168
|
+
raise SetupError("permission helper returned an invalid result")
|
|
169
|
+
return missing
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def prepare_configuration(root: Path, *, admin_port: int | None = None,
|
|
173
|
+
runner: Callable = subprocess.run,
|
|
174
|
+
input_stream: IO[str] | None = None,
|
|
175
|
+
secret_prompt: Callable[[str], str] = getpass.getpass) -> None:
|
|
176
|
+
"""Root/maintenance/lifetime locks belong to the caller; no service is started."""
|
|
177
|
+
from .admin.auth import load_credential_snapshot
|
|
178
|
+
from .settings import Settings
|
|
179
|
+
import yaml
|
|
180
|
+
|
|
181
|
+
source = sys.stdin if input_stream is None else input_stream
|
|
182
|
+
config_file = root / "config.yaml"
|
|
183
|
+
if not config_file.exists() and not config_file.is_symlink():
|
|
184
|
+
config = {
|
|
185
|
+
"instance": {"dataDir": str(root / "state"),
|
|
186
|
+
"projectRoot": str(Path(pwd.getpwuid(os.geteuid()).pw_dir) / "projects")},
|
|
187
|
+
"projects": {}, "channel": {"securityMode": "audit"},
|
|
188
|
+
"adminWeb": {"enabled": True, "host": "0.0.0.0"},
|
|
189
|
+
}
|
|
190
|
+
if admin_port is not None:
|
|
191
|
+
config["adminWeb"]["port"] = admin_port
|
|
192
|
+
_write_atomic(config_file, yaml.safe_dump(config, sort_keys=False).encode(), mode=0o600)
|
|
193
|
+
else:
|
|
194
|
+
_private_file(config_file)
|
|
195
|
+
if admin_port is not None:
|
|
196
|
+
try:
|
|
197
|
+
existing = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
|
198
|
+
except (UnicodeError, yaml.YAMLError):
|
|
199
|
+
raise SetupError("existing configuration is invalid; repair it before retrying setup") from None
|
|
200
|
+
admin = existing.get("adminWeb") if isinstance(existing, dict) else None
|
|
201
|
+
port = admin.get("port") if isinstance(admin, dict) else None
|
|
202
|
+
if type(port) is not int or port != admin_port:
|
|
203
|
+
raise SetupError("--admin-port differs from existing configuration; edit the config explicitly")
|
|
204
|
+
credential = root / "credentials/admin-web-secret"
|
|
205
|
+
if not credential.exists() and not credential.is_symlink():
|
|
206
|
+
_write_atomic(credential, secrets.token_urlsafe(32).encode("ascii"), mode=0o600)
|
|
207
|
+
_private_file(credential)
|
|
208
|
+
load_credential_snapshot(credential)
|
|
209
|
+
profile = root / "lark-app/config.json"
|
|
210
|
+
if not profile.exists() and not profile.is_symlink():
|
|
211
|
+
_write_atomic(profile, encode_lark_app("", ""), mode=0o600)
|
|
212
|
+
credentials = load_lark_app(profile, allow_incomplete=True)
|
|
213
|
+
started_flow = False
|
|
214
|
+
if not (credentials.app_id and credentials.app_secret):
|
|
215
|
+
manual = False
|
|
216
|
+
if source.isatty():
|
|
217
|
+
print("Feishu setup: [1] browser (default), [2] manual credentials", file=sys.stderr)
|
|
218
|
+
choice = source.readline().strip()
|
|
219
|
+
if choice not in {"", "1", "2"}:
|
|
220
|
+
raise SetupError("choose 1 or 2 and rerun setup")
|
|
221
|
+
manual = choice == "2"
|
|
222
|
+
if manual:
|
|
223
|
+
_manual_credentials(root, credentials.app_id or None,
|
|
224
|
+
source=source, secret_prompt=secret_prompt)
|
|
225
|
+
else:
|
|
226
|
+
started_flow = True
|
|
227
|
+
_browser_or_manual(root, credentials.app_id or None, runner=runner,
|
|
228
|
+
source=source, secret_prompt=secret_prompt)
|
|
229
|
+
missing = _missing_permissions(root, runner=runner)
|
|
230
|
+
if missing and not started_flow:
|
|
231
|
+
_browser_or_manual(root, load_lark_app(profile).app_id, runner=runner,
|
|
232
|
+
source=source, secret_prompt=secret_prompt)
|
|
233
|
+
missing = _missing_permissions(root, runner=runner)
|
|
234
|
+
if missing:
|
|
235
|
+
raise SetupError("finish tenant approval, publish/install the app, then rerun setup; missing scopes: "
|
|
236
|
+
+ ", ".join(missing))
|
|
237
|
+
settings = Settings.from_file(config_file, environment={
|
|
238
|
+
"NETIZEN_ADMIN_SECRET_FILE": str(credential),
|
|
239
|
+
"NETIZEN_LARK_APP_CONFIG": str(profile),
|
|
240
|
+
})
|
|
241
|
+
if settings.data_dir.resolve() != root / "state":
|
|
242
|
+
raise SetupError("instance.dataDir must be this instance's state directory")
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Environment-scoped, stop-first package update orchestration.
|
|
2
|
+
|
|
3
|
+
System service definitions remain the inventory. The small copied worker is the
|
|
4
|
+
only process allowed to execute after package replacement; it uses fresh CLI
|
|
5
|
+
processes for restoration and never imports the replaced package.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fcntl
|
|
11
|
+
from contextlib import ExitStack
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import pwd
|
|
16
|
+
import stat
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from . import cli_update_worker
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UpdateError(RuntimeError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def new_report() -> dict[str, Any]:
|
|
30
|
+
return {
|
|
31
|
+
"protocol": 1, "status": "running", "phase": "preflight",
|
|
32
|
+
"environment": None, "backend": None, "inventory_complete": False,
|
|
33
|
+
"package": {"state": "unchanged", "replacement_started": False,
|
|
34
|
+
"before_version": None, "after_version": None},
|
|
35
|
+
"progress": {"stop_total": 0, "stopped": 0, "start_total": 0, "ready": 0},
|
|
36
|
+
"instances": [], "reason": None, "recommendation": None,
|
|
37
|
+
"unexecuted": ["stop", "package-update", "validation", "restore"],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _private_directory(path: Path) -> None:
|
|
42
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
43
|
+
info = path.lstat()
|
|
44
|
+
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid()
|
|
45
|
+
or stat.S_IMODE(info.st_mode) != 0o700):
|
|
46
|
+
raise UpdateError(f"Update maintenance directory must be owned by this user and private: {path}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def maintenance_directory(prefix: Path, *, home: Path | None = None) -> Path:
|
|
50
|
+
account_home = Path(pwd.getpwuid(os.geteuid()).pw_dir) if home is None else home
|
|
51
|
+
digest = hashlib.sha256(os.fsencode(prefix.resolve())).hexdigest()[:24]
|
|
52
|
+
parent = account_home.resolve()
|
|
53
|
+
for component in (".cache", "netizen-cli", "updates"):
|
|
54
|
+
parent = parent / component
|
|
55
|
+
parent.mkdir(mode=0o700, exist_ok=True)
|
|
56
|
+
info = parent.lstat()
|
|
57
|
+
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid()
|
|
58
|
+
or stat.S_IMODE(info.st_mode) & 0o022):
|
|
59
|
+
raise UpdateError(f"Unsafe update maintenance parent: {parent}")
|
|
60
|
+
path = parent / digest
|
|
61
|
+
_private_directory(path)
|
|
62
|
+
if path.resolve().is_relative_to(prefix.resolve()):
|
|
63
|
+
raise UpdateError("Update maintenance files would be inside the installation being replaced")
|
|
64
|
+
return path
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _lock(directory: Path) -> int:
|
|
68
|
+
descriptor = os.open(directory / "update.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
69
|
+
try:
|
|
70
|
+
info = os.fstat(descriptor)
|
|
71
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid()
|
|
72
|
+
or stat.S_IMODE(info.st_mode) != 0o600):
|
|
73
|
+
raise UpdateError("Unrecognized update lock ownership or permissions")
|
|
74
|
+
try:
|
|
75
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
76
|
+
except BlockingIOError as error:
|
|
77
|
+
raise UpdateError("Another update is already maintaining this Python environment") from error
|
|
78
|
+
os.set_inheritable(descriptor, True)
|
|
79
|
+
return descriptor
|
|
80
|
+
except BaseException:
|
|
81
|
+
os.close(descriptor)
|
|
82
|
+
raise
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _reject_service_context(service_names: list[str]) -> None:
|
|
86
|
+
if os.environ.get("NETIZEN_CLI_SERVICE") == "1":
|
|
87
|
+
raise UpdateError("Run netizen update from an external terminal, not inside a Netizen service")
|
|
88
|
+
if os.environ.get("XPC_SERVICE_NAME") in service_names:
|
|
89
|
+
raise UpdateError("The update process belongs to a service it would stop; use an external terminal")
|
|
90
|
+
if sys.platform == "linux":
|
|
91
|
+
try:
|
|
92
|
+
entries = Path("/proc/self/cgroup").read_text(encoding="utf-8").splitlines()
|
|
93
|
+
except OSError as error:
|
|
94
|
+
raise UpdateError("Cannot verify that the updater is outside the affected service cgroups") from error
|
|
95
|
+
for entry in entries:
|
|
96
|
+
components = entry.split(":", 2)[-1].split("/")
|
|
97
|
+
if any(name in components for name in service_names):
|
|
98
|
+
raise UpdateError("The update process belongs to a service it would stop; use an external terminal")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _snapshot(manager: Any, prefix: Path, python: Path) -> list[dict[str, Any]]:
|
|
102
|
+
instances = []
|
|
103
|
+
for status in manager.list_instances(prefix=prefix):
|
|
104
|
+
binding = status.binding
|
|
105
|
+
if binding.prefix.resolve() != prefix.resolve():
|
|
106
|
+
continue
|
|
107
|
+
# Prefix proves the environment, while the lexical launcher is retained.
|
|
108
|
+
# Different venvs can have the same base-interpreter realpath.
|
|
109
|
+
if binding.python.parent.resolve() != python.parent.resolve():
|
|
110
|
+
raise UpdateError(f"Ambiguous interpreter binding for instance {binding.root}")
|
|
111
|
+
if not isinstance(status.running, bool):
|
|
112
|
+
raise UpdateError(f"Cannot determine whether instance is running: {binding.root}")
|
|
113
|
+
needs_stop = _needs_stop(manager, status)
|
|
114
|
+
instances.append({
|
|
115
|
+
"root": str(binding.root), "service": manager.service_name(binding.root),
|
|
116
|
+
"python": str(binding.python), "prefix": str(binding.prefix),
|
|
117
|
+
"was_running": status.running,
|
|
118
|
+
"needs_stop": needs_stop,
|
|
119
|
+
"state": _observed_state(manager, status), "action": None,
|
|
120
|
+
"reason": None,
|
|
121
|
+
})
|
|
122
|
+
return instances
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _needs_stop(manager: Any, status: Any) -> bool:
|
|
126
|
+
# launchd may restart a loaded KeepAlive job even while its PID is absent.
|
|
127
|
+
# A loaded inactive systemd unit, by contrast, needs no stop operation.
|
|
128
|
+
if manager.platform == "darwin":
|
|
129
|
+
if not isinstance(status.loaded, bool):
|
|
130
|
+
raise UpdateError(f"Cannot determine whether LaunchAgent is loaded: {status.binding.root}")
|
|
131
|
+
return status.running or status.loaded
|
|
132
|
+
return status.running
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _observed_state(manager: Any, status: Any) -> str:
|
|
136
|
+
if status.running:
|
|
137
|
+
return "running"
|
|
138
|
+
return "loaded" if _needs_stop(manager, status) else "stopped"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _refresh_observed(manager: Any, instances: list[dict[str, Any]]) -> None:
|
|
142
|
+
for item in instances:
|
|
143
|
+
try:
|
|
144
|
+
current = manager.inspect(Path(item["root"]))
|
|
145
|
+
if current is None:
|
|
146
|
+
item["state"] = "unregistered"
|
|
147
|
+
else:
|
|
148
|
+
item["state"] = _observed_state(manager, current)
|
|
149
|
+
except Exception:
|
|
150
|
+
item["state"] = "unknown"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def stop_recorded(manager: Any, report: dict[str, Any]) -> None:
|
|
154
|
+
"""A partial stop has no compensation: preserve and report actual states."""
|
|
155
|
+
report["phase"] = "stopping"
|
|
156
|
+
for item in report["instances"]:
|
|
157
|
+
if not item["needs_stop"]:
|
|
158
|
+
continue
|
|
159
|
+
item["action"] = "stop"
|
|
160
|
+
try:
|
|
161
|
+
stopped = manager.stop(Path(item["root"]))
|
|
162
|
+
if _needs_stop(manager, stopped):
|
|
163
|
+
raise UpdateError("Stop returned without confirming process exit and LaunchAgent unload")
|
|
164
|
+
item["state"] = "stopped"
|
|
165
|
+
report["progress"]["stopped"] += 1
|
|
166
|
+
except (Exception, KeyboardInterrupt) as error:
|
|
167
|
+
item["reason"] = str(error)
|
|
168
|
+
_refresh_observed(manager, report["instances"])
|
|
169
|
+
raise UpdateError(f"Stopping {item['root']} failed: {error}") from error
|
|
170
|
+
if report.get("report_path"):
|
|
171
|
+
cli_update_worker.persist_report(Path(report["report_path"]), report)
|
|
172
|
+
# Detect already-observable external starts before the package replacement.
|
|
173
|
+
for item in report["instances"]:
|
|
174
|
+
try:
|
|
175
|
+
current = manager.inspect(Path(item["root"]))
|
|
176
|
+
except Exception:
|
|
177
|
+
item["state"] = "unknown"
|
|
178
|
+
raise
|
|
179
|
+
if (current is None
|
|
180
|
+
or current.binding.prefix.resolve() != Path(item["prefix"]).resolve()
|
|
181
|
+
or str(current.binding.python) != item["python"]):
|
|
182
|
+
item["state"] = "unknown"
|
|
183
|
+
raise UpdateError(f"Service binding changed during update: {item['root']}")
|
|
184
|
+
if _needs_stop(manager, current):
|
|
185
|
+
item["state"] = _observed_state(manager, current)
|
|
186
|
+
raise UpdateError(f"Instance started during update or its LaunchAgent is still loaded; "
|
|
187
|
+
f"package replacement aborted: {item['root']}")
|
|
188
|
+
report["unexecuted"] = ["package-update", "validation", "restore"]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _stage_worker(directory: Path, report: dict[str, Any], package: dict[str, Any],
|
|
192
|
+
*, json_output: bool, root_locks: dict[str, int]) -> tuple[Path, Path]:
|
|
193
|
+
worker = directory / "worker.py"
|
|
194
|
+
worker.write_bytes(Path(cli_update_worker.__file__).read_bytes())
|
|
195
|
+
worker.chmod(0o600)
|
|
196
|
+
plan_file = directory / "plan.json"
|
|
197
|
+
with plan_file.open("x", encoding="utf-8") as stream:
|
|
198
|
+
json.dump({"protocol": 1, "report": report, "package_plan": package,
|
|
199
|
+
"json_output": json_output, "root_locks": root_locks}, stream)
|
|
200
|
+
plan_file.chmod(0o600)
|
|
201
|
+
return worker, plan_file
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def run_update(*, json_output: bool = False, backend: str | None = None,
|
|
205
|
+
manager: Any = None, prepare: Any = None, home: Path | None = None,
|
|
206
|
+
executor: Any = os.execv) -> int:
|
|
207
|
+
"""Report early failures, or replace this process with an external worker."""
|
|
208
|
+
report = new_report()
|
|
209
|
+
report["environment"] = str(Path(sys.prefix).resolve())
|
|
210
|
+
report["backend"] = backend
|
|
211
|
+
descriptor = None
|
|
212
|
+
root_locks: dict[str, int] = {}
|
|
213
|
+
maintenance = ExitStack()
|
|
214
|
+
try:
|
|
215
|
+
if os.environ.get("NETIZEN_CLI_SERVICE") == "1":
|
|
216
|
+
raise UpdateError("Run netizen update from an external terminal, not inside a Netizen service")
|
|
217
|
+
directory = maintenance_directory(Path(sys.prefix), home=home)
|
|
218
|
+
descriptor = _lock(directory)
|
|
219
|
+
operation = Path(tempfile.mkdtemp(prefix="operation-", dir=directory))
|
|
220
|
+
report["report_path"] = str(operation / "report.json")
|
|
221
|
+
if prepare is None:
|
|
222
|
+
from .cli_packages import prepare_update
|
|
223
|
+
prepare = prepare_update
|
|
224
|
+
package = prepare(work_dir=operation, backend=backend)
|
|
225
|
+
if Path(package["prefix"]).resolve() != Path(sys.prefix).resolve():
|
|
226
|
+
raise UpdateError("Package update target does not match the locked current Python environment")
|
|
227
|
+
report.update(environment=package["prefix"], backend=package["backend"])
|
|
228
|
+
report["package"]["before_version"] = package["before_version"]
|
|
229
|
+
if manager is None:
|
|
230
|
+
from .cli_services import ServiceManager
|
|
231
|
+
manager = ServiceManager()
|
|
232
|
+
report["instances"] = _snapshot(
|
|
233
|
+
manager, Path(package["prefix"]), Path(package["environment_python"]),
|
|
234
|
+
)
|
|
235
|
+
report["inventory_complete"] = True
|
|
236
|
+
_reject_service_context([item["service"] for item in report["instances"]])
|
|
237
|
+
if package["changes_required"]:
|
|
238
|
+
from .deployment.update_protocol import install_lock, read_operation, terminal_phase
|
|
239
|
+
from .instance import require_instance_root_marker
|
|
240
|
+
|
|
241
|
+
# Use the very same locks as CLI mutations and Admin restarts. Take
|
|
242
|
+
# every known root (including stopped instances) before stopping any.
|
|
243
|
+
for item in sorted(report["instances"], key=lambda row: row["root"]):
|
|
244
|
+
try:
|
|
245
|
+
root = Path(item["root"])
|
|
246
|
+
require_instance_root_marker(root)
|
|
247
|
+
lock_fd = maintenance.enter_context(install_lock(root))
|
|
248
|
+
except (OSError, RuntimeError) as error:
|
|
249
|
+
raise UpdateError(f"Instance maintenance is busy or unsafe: {item['root']}: {error}") from error
|
|
250
|
+
os.set_inheritable(lock_fd, True)
|
|
251
|
+
root_locks[item["root"]] = lock_fd
|
|
252
|
+
pending = read_operation(root)
|
|
253
|
+
if pending is not None and not terminal_phase(pending["phase"]):
|
|
254
|
+
raise UpdateError(
|
|
255
|
+
f"Instance has pending maintenance ({pending['phase']}): {root}. "
|
|
256
|
+
"Let its Admin restart finish; inspect status/logs and recover an interrupted "
|
|
257
|
+
"restart with netizen start before retrying update."
|
|
258
|
+
)
|
|
259
|
+
current = _snapshot(manager, Path(package["prefix"]), Path(package["environment_python"]))
|
|
260
|
+
identity = lambda rows: {(row["root"], row["python"], row["prefix"]) for row in rows}
|
|
261
|
+
if identity(current) != identity(report["instances"]):
|
|
262
|
+
raise UpdateError("Instance inventory changed while acquiring maintenance locks; retry")
|
|
263
|
+
report["instances"] = current
|
|
264
|
+
count = sum(item["was_running"] for item in report["instances"])
|
|
265
|
+
report["progress"].update(stop_total=sum(item["needs_stop"] for item in report["instances"]),
|
|
266
|
+
start_total=count)
|
|
267
|
+
print(f"Updating {package['prefix']} using {package['backend']}; "
|
|
268
|
+
f"{count} running instance(s) affected.", file=sys.stderr)
|
|
269
|
+
for item in report["instances"]:
|
|
270
|
+
print(f" {item['root']}: {item['state']}", file=sys.stderr)
|
|
271
|
+
if package.get("notice"):
|
|
272
|
+
print(package["notice"], file=sys.stderr)
|
|
273
|
+
if not package["changes_required"]:
|
|
274
|
+
report.update(status="succeeded", phase="complete", reason="No package changes are required.")
|
|
275
|
+
report["package"]["after_version"] = package["before_version"]
|
|
276
|
+
report["progress"].update(stop_total=0, start_total=0)
|
|
277
|
+
report["unexecuted"] = ["stop (not needed)", "package-update (not needed)", "restore (not needed)"]
|
|
278
|
+
else:
|
|
279
|
+
print("Running tasks may be interrupted. Only previously running instances will be restored.",
|
|
280
|
+
file=sys.stderr)
|
|
281
|
+
cli_update_worker.persist_report(Path(report["report_path"]), report)
|
|
282
|
+
stop_recorded(manager, report)
|
|
283
|
+
worker, plan_file = _stage_worker(operation, report, package,
|
|
284
|
+
json_output=json_output, root_locks=root_locks)
|
|
285
|
+
# A venv/tool may be recreated by its manager. Use the user's base
|
|
286
|
+
# interpreter to keep the standard-library-only coordinator outside it.
|
|
287
|
+
base_python = os.path.abspath(getattr(sys, "_base_executable", sys.executable))
|
|
288
|
+
executor(base_python, [base_python, "-I", "-B", str(worker), str(plan_file)])
|
|
289
|
+
raise UpdateError("Update helper handoff unexpectedly returned")
|
|
290
|
+
except (Exception, KeyboardInterrupt) as error:
|
|
291
|
+
report.update(status="failed", reason=f"{type(error).__name__}: {error}", recommendation=(
|
|
292
|
+
"Resolve the reported problem and retry. Package replacement has not started. "
|
|
293
|
+
"Any instances already stopped remain stopped; start them explicitly if needed. "
|
|
294
|
+
"Unsupported or ambiguous installations can be maintained manually with their package manager."
|
|
295
|
+
))
|
|
296
|
+
finally:
|
|
297
|
+
maintenance.close()
|
|
298
|
+
if descriptor is not None:
|
|
299
|
+
os.close(descriptor)
|
|
300
|
+
if report.get("report_path"):
|
|
301
|
+
cli_update_worker.persist_report(Path(report["report_path"]), report)
|
|
302
|
+
cli_update_worker.print_report(report, json_output=json_output)
|
|
303
|
+
return 0 if report["status"] == "succeeded" else 1
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Fresh-install, one-shot update restore under an inherited exact root lock.
|
|
2
|
+
|
|
3
|
+
Not a public lifecycle command: unlike ``start``, this cannot register or change
|
|
4
|
+
a binding. Keeping the updater's lock avoids a release/reacquire window between
|
|
5
|
+
package validation and readiness, and serializes existing Admin/CLI maintenance.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any, Sequence
|
|
13
|
+
|
|
14
|
+
from .deployment.update_protocol import validate_inherited_lock
|
|
15
|
+
from .instance import require_instance_root_marker
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def restore(root: Path, descriptor: int, *, expected_prefix: Path,
|
|
19
|
+
expected_python: Path, manager: Any = None) -> None:
|
|
20
|
+
if root != root.resolve() or not root.is_absolute():
|
|
21
|
+
raise ValueError("Update restoration requires the recorded canonical root")
|
|
22
|
+
require_instance_root_marker(root)
|
|
23
|
+
validate_inherited_lock(root, descriptor)
|
|
24
|
+
if manager is None:
|
|
25
|
+
from .cli_services import ServiceManager
|
|
26
|
+
manager = ServiceManager()
|
|
27
|
+
status = manager.inspect(root)
|
|
28
|
+
if (status is None or status.binding.prefix != expected_prefix
|
|
29
|
+
or status.binding.python != expected_python):
|
|
30
|
+
raise RuntimeError("Service binding changed; update restoration will not register or rebind it")
|
|
31
|
+
ready = manager.start(root)
|
|
32
|
+
if not ready.ready:
|
|
33
|
+
raise RuntimeError("Instance did not confirm readiness")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
37
|
+
parser = argparse.ArgumentParser(description="Private Netizen update restoration entry")
|
|
38
|
+
parser.add_argument("--root", type=Path, required=True)
|
|
39
|
+
parser.add_argument("--lock-fd", type=int, required=True)
|
|
40
|
+
parser.add_argument("--expected-prefix", type=Path, required=True)
|
|
41
|
+
parser.add_argument("--expected-python", type=Path, required=True)
|
|
42
|
+
args = parser.parse_args(argv)
|
|
43
|
+
try:
|
|
44
|
+
restore(args.root, args.lock_fd, expected_prefix=args.expected_prefix,
|
|
45
|
+
expected_python=args.expected_python)
|
|
46
|
+
return 0
|
|
47
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
48
|
+
print(f"Instance restoration failed: {error}", file=sys.stderr)
|
|
49
|
+
return 1
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
raise SystemExit(main())
|