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.
Files changed (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
@@ -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())