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,651 @@
1
+ """Per-user installed-CLI services; definitions are the instance inventory.
2
+
3
+ Only Netizen's CLI v1 service definition is writable, rendered for systemd or
4
+ launchd. Recognition uses the same platform-specific contract independently
5
+ of the registration renderer.
6
+ systemd state comes from machine properties; launchd's documented list columns
7
+ provide PID state.
8
+ The diagnostic output of ``launchctl print`` is deliberately never parsed.
9
+ Callers serialize lifecycle mutations with the instance maintenance lock.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import fcntl
15
+ import json
16
+ import os
17
+ import plistlib
18
+ import pwd
19
+ import stat
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ from collections.abc import Callable
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ from .instance import launch_agent_label, require_instance_root_marker, systemd_service_name
28
+
29
+
30
+ class ServiceError(RuntimeError):
31
+ """Service identity, manager state, or lifecycle completion is unproven."""
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ServiceBinding:
36
+ root: Path
37
+ python: Path
38
+ prefix: Path
39
+ codex_home: Path | None = None
40
+
41
+ def __post_init__(self) -> None:
42
+ paths = (self.root, self.python, self.prefix)
43
+ if self.codex_home is not None:
44
+ paths += (self.codex_home,)
45
+ for path in paths:
46
+ if not path.is_absolute() or any(ord(c) < 32 for c in str(path)):
47
+ raise ServiceError("service binding paths must be absolute without control characters")
48
+ if self.root != self.root.resolve() or self.prefix != self.prefix.resolve():
49
+ raise ServiceError("instance root and environment prefix must be canonical")
50
+ # Do NOT resolve python: two venvs often point at the same base binary.
51
+ if self.python != Path(os.path.abspath(self.python)):
52
+ raise ServiceError("Python launch path must be normalized without resolving its symlink")
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ServiceStatus:
57
+ binding: ServiceBinding
58
+ running: bool
59
+ enabled: bool | None
60
+ ready: bool
61
+ loaded: bool = False
62
+
63
+
64
+ _SERVICE_MARKER = "# Netizen CLI service v1 "
65
+ _LAUNCH_AGENT_SENTINEL = "io.github.lijingda.netizen/cli-v1"
66
+ _READY = b"netizen service ready\n"
67
+ _LOG_READ_LIMIT = 1024 * 1024
68
+ _SYSTEMD_PROPERTIES = (
69
+ "LoadState", "ActiveState", "SubState", "MainPID", "ControlPID", "FragmentPath",
70
+ "DropInPaths", "NeedDaemonReload", "Transient", "UnitFileState",
71
+ )
72
+
73
+
74
+ def _systemd_quote(value: str) -> str:
75
+ if any(ord(c) < 32 for c in value):
76
+ raise ServiceError("systemd values cannot contain control characters")
77
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%") + '"'
78
+
79
+
80
+ def _service_environment(home: Path, binding: ServiceBinding) -> dict[str, str]:
81
+ """Service environment: no extra overrides or caller PATH are accepted."""
82
+ root = binding.root
83
+ environment = {
84
+ "HOME": str(home), "NETIZEN_ROOT": str(root),
85
+ "NETIZEN_CLI_PREFIX": str(binding.prefix), "NETIZEN_CLI_SERVICE": "1",
86
+ "NETIZEN_CLI_PYTHON": str(binding.python),
87
+ "NETIZEN_CONFIG_PATH": str(root / "config.yaml"),
88
+ "NETIZEN_LARK_APP_CONFIG": str(root / "lark-app/config.json"),
89
+ "NETIZEN_ADMIN_SECRET_FILE": str(root / "credentials/admin-web-secret"),
90
+ "NETIZEN_READY_FILE": str(root / "state/service.ready"),
91
+ "NETIZEN_LIFETIME_LOCK_FILE": str(root / "state/service.lifetime.lock"),
92
+ "NETIZEN_LOG_FILE": str(root / "state/netizen.log"),
93
+ "PATH": ":".join([str(home / ".local/bin"), "/opt/homebrew/bin",
94
+ "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]),
95
+ }
96
+ if binding.codex_home is not None:
97
+ environment["CODEX_HOME"] = str(binding.codex_home)
98
+ return environment
99
+
100
+
101
+ def _service_arguments(binding: ServiceBinding) -> list[str]:
102
+ return [str(binding.python), "-E", "-P", "-m", "netizen_cli", "_serve",
103
+ "--root", str(binding.root)]
104
+
105
+
106
+ def _service_definition(home: Path, platform: str, binding: ServiceBinding,
107
+ *, enabled: bool = True) -> bytes:
108
+ """CLI v1 contract with native platform serialization.
109
+
110
+ Linux accepts this exact serialization; launchd accepts this exact plist
111
+ structure and types. Binding paths and RunAtLoad are the only variable
112
+ fields. This is not a parser for arbitrary manager overrides.
113
+ """
114
+ env = _service_environment(home, binding)
115
+ if platform == "darwin":
116
+ env["NETIZEN_MANAGED_LAUNCH_AGENT"] = _LAUNCH_AGENT_SENTINEL
117
+ return plistlib.dumps({
118
+ "Label": launch_agent_label(binding.root),
119
+ "ProgramArguments": _service_arguments(binding),
120
+ "WorkingDirectory": str(home), "RunAtLoad": enabled,
121
+ "KeepAlive": {"SuccessfulExit": False}, "ExitTimeOut": 75,
122
+ "ThrottleInterval": 3, "Umask": 0o077,
123
+ "AbandonProcessGroup": False, "EnvironmentVariables": env,
124
+ "StandardOutPath": "/dev/null",
125
+ "StandardErrorPath": str(binding.root / "state/launchd.stderr.log"),
126
+ }, sort_keys=True)
127
+ metadata = json.dumps({"root": str(binding.root), "python": str(binding.python),
128
+ "prefix": str(binding.prefix),
129
+ "codex_home": str(binding.codex_home) if binding.codex_home else None},
130
+ sort_keys=True)
131
+ lines = [
132
+ _SERVICE_MARKER + metadata, "[Unit]", "Description=Netizen CLI instance",
133
+ "After=network-online.target", "[Service]", "Type=simple",
134
+ # This single-path field does not unquote or C-unescape. Escape only
135
+ # specifiers; '/' protects a trailing space or backslash in the path.
136
+ "WorkingDirectory=" + str(home).replace("%", "%%") + "/",
137
+ "ExecStart=:" + " ".join(_systemd_quote(arg) for arg in _service_arguments(binding)),
138
+ "Restart=on-failure", "RestartSec=3", "TimeoutStopSec=75",
139
+ "KillMode=control-group", "UMask=0077",
140
+ *["Environment=" + _systemd_quote(f"{key}={value}") for key, value in env.items()],
141
+ "[Install]", "WantedBy=default.target", "",
142
+ ]
143
+ return "\n".join(lines).encode()
144
+
145
+
146
+ def _same_service_fragment(fragment: str, expected: Path) -> bool:
147
+ """Allow parent-directory aliases, never a different or symlink unit file."""
148
+ actual = Path(fragment)
149
+ try:
150
+ return (actual.is_absolute() and not actual.is_symlink()
151
+ and actual.resolve(strict=True) == expected.resolve(strict=True))
152
+ except (OSError, RuntimeError, ValueError):
153
+ return False
154
+
155
+
156
+ def _read_owned(path: Path, uid: int) -> bytes:
157
+ try:
158
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK)
159
+ with os.fdopen(fd, "rb") as source:
160
+ metadata = os.fstat(source.fileno())
161
+ if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != uid
162
+ or stat.S_IMODE(metadata.st_mode) & 0o022):
163
+ raise ServiceError(f"not a protected current-user regular file: {path}")
164
+ return source.read()
165
+ except OSError as exc:
166
+ raise ServiceError(f"cannot read owned service file {path}: {exc}") from exc
167
+
168
+
169
+ def _lock_available(root: Path, uid: int) -> bool:
170
+ path = root / "state" / "service.lifetime.lock"
171
+ try:
172
+ fd = os.open(path, os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK)
173
+ except FileNotFoundError:
174
+ return True
175
+ except OSError as exc:
176
+ raise ServiceError(f"cannot inspect instance lifetime lock {path}: {exc}") from exc
177
+ try:
178
+ metadata = os.fstat(fd)
179
+ if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != uid
180
+ or stat.S_IMODE(metadata.st_mode) != 0o600):
181
+ raise ServiceError(f"unsafe instance lifetime lock: {path}")
182
+ try:
183
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
184
+ except BlockingIOError:
185
+ return False
186
+ fcntl.flock(fd, fcntl.LOCK_UN)
187
+ return True
188
+ finally:
189
+ os.close(fd)
190
+
191
+
192
+ def _tail_log(path: Path, *, uid: int, lines: int) -> str | None:
193
+ """Read a bounded tail without following a log file replaced by a symlink."""
194
+ try:
195
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK)
196
+ except FileNotFoundError:
197
+ return None
198
+ except OSError as exc:
199
+ raise ServiceError(f"cannot read log {path}: {exc}") from exc
200
+ with os.fdopen(fd, "rb") as source:
201
+ metadata = os.fstat(source.fileno())
202
+ if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != uid
203
+ or stat.S_IMODE(metadata.st_mode) & 0o022):
204
+ raise ServiceError(f"not a protected current-user log file: {path}")
205
+ offset = max(0, metadata.st_size - _LOG_READ_LIMIT)
206
+ source.seek(offset)
207
+ payload = source.read(_LOG_READ_LIMIT)
208
+ if offset:
209
+ # Drop a possibly partial first line, but retain a giant last line's
210
+ # bounded bytes if it contains no newline at all.
211
+ separator = payload.find(b"\n")
212
+ if separator >= 0:
213
+ payload = payload[separator + 1:]
214
+ text = "\n".join(payload.decode("utf-8", errors="replace").splitlines()[-lines:])
215
+ return ("[earlier log bytes omitted]\n" if offset else "") + text
216
+
217
+
218
+ class ServiceManager:
219
+ def __init__(
220
+ self, *, home: Path | None = None, platform: str | None = None,
221
+ runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
222
+ ) -> None:
223
+ self.uid = os.geteuid()
224
+ self.home = (home or Path(pwd.getpwuid(self.uid).pw_dir)).resolve()
225
+ self.platform = platform or sys.platform
226
+ if self.platform not in {"linux", "darwin"}:
227
+ raise ServiceError("Netizen supports systemd user services and macOS GUI LaunchAgents only")
228
+ self.service_dir = self.home / (
229
+ ".config/systemd/user" if self.platform == "linux" else "Library/LaunchAgents"
230
+ )
231
+ self._runner = runner
232
+
233
+ def service_name(self, root: Path) -> str:
234
+ return (systemd_service_name if self.platform == "linux" else launch_agent_label)(root)
235
+
236
+ def service_file(self, root: Path) -> Path:
237
+ name = self.service_name(root)
238
+ return self.service_dir / (name if self.platform == "linux" else name + ".plist")
239
+
240
+ def _instance_name(self, name: str) -> bool:
241
+ prefix = "netizen-" if self.platform == "linux" else "io.github.lijingda.netizen."
242
+ suffix = ".service" if self.platform == "linux" else ""
243
+ if not name.startswith(prefix) or (suffix and not name.endswith(suffix)):
244
+ return False
245
+ digest = name[len(prefix):len(name) - len(suffix) if suffix else None]
246
+ return len(digest) == 24 and all(c in "0123456789abcdef" for c in digest)
247
+
248
+ def _run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
249
+ env = {key: value for key, value in os.environ.items()
250
+ if not key.startswith(("PYTHON", "LD_", "DYLD_"))}
251
+ env["HOME"] = str(self.home)
252
+ env["LC_ALL"] = "C"
253
+ if self.platform == "linux":
254
+ env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{self.uid}")
255
+ env.setdefault("DBUS_SESSION_BUS_ADDRESS", f"unix:path=/run/user/{self.uid}/bus")
256
+ try:
257
+ result = self._runner(list(args), check=False, capture_output=True, text=True,
258
+ env=env, timeout=100)
259
+ except (OSError, subprocess.SubprocessError) as exc:
260
+ raise ServiceError(f"service manager command failed: {args[0]}: {exc}") from exc
261
+ if check and result.returncode:
262
+ raise ServiceError(f"service manager command failed ({result.returncode}): "
263
+ f"{' '.join(args)}; {result.stderr.strip()}")
264
+ return result
265
+
266
+ def _systemctl(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
267
+ return self._run("systemctl", "--user", *args, check=check)
268
+
269
+ def _launchctl(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
270
+ return self._run("launchctl", *args, check=check)
271
+
272
+ def _root(self, root: Path) -> Path:
273
+ root = Path(root).resolve()
274
+ if root in {self.home, Path(root.anchor)}:
275
+ raise ServiceError("instance root cannot be the account home or filesystem root")
276
+ return root
277
+
278
+ def _render(self, binding: ServiceBinding, *, enabled: bool = True) -> bytes:
279
+ return _service_definition(self.home, self.platform, binding, enabled=enabled)
280
+
281
+ def _binding_from_file(self, path: Path) -> ServiceBinding:
282
+ content = _read_owned(path, self.uid)
283
+ try:
284
+ if self.platform == "linux":
285
+ first, *_ = content.decode().splitlines()
286
+ if not first.startswith(_SERVICE_MARKER):
287
+ raise ValueError("unsupported service definition format; only CLI v1 is supported")
288
+ raw = json.loads(first[len(_SERVICE_MARKER):])
289
+ if set(raw) != {"root", "python", "prefix", "codex_home"}:
290
+ raise ValueError("unrecognized binding metadata")
291
+ binding = ServiceBinding(*(Path(raw[key]) for key in ("root", "python", "prefix")),
292
+ Path(raw["codex_home"]) if raw["codex_home"] is not None else None)
293
+ expected = _service_definition(self.home, self.platform, binding)
294
+ valid = content == expected
295
+ else:
296
+ payload = plistlib.loads(content)
297
+ env = payload["EnvironmentVariables"]
298
+ if not isinstance(env, dict) or env.get("NETIZEN_MANAGED_LAUNCH_AGENT") != _LAUNCH_AGENT_SENTINEL:
299
+ raise ValueError("unsupported service definition format; only CLI v1 is supported")
300
+ binding = ServiceBinding(Path(env["NETIZEN_ROOT"]),
301
+ Path(env["NETIZEN_CLI_PYTHON"]),
302
+ Path(env["NETIZEN_CLI_PREFIX"]),
303
+ Path(env["CODEX_HOME"]) if "CODEX_HOME" in env else None)
304
+ enabled = payload["RunAtLoad"]
305
+ expected = plistlib.loads(_service_definition(self.home, self.platform, binding, enabled=enabled))
306
+ # plist serialization retains scalar types (False is not the
307
+ # integer 0), unlike Python's ordinary dictionary equality.
308
+ valid = (isinstance(enabled, bool)
309
+ and plistlib.dumps(payload, sort_keys=True)
310
+ == plistlib.dumps(expected, sort_keys=True))
311
+ except (ValueError, TypeError, KeyError, IndexError, OverflowError, UnicodeError) as exc:
312
+ raise ServiceError(f"unrecognized service definition {path}: {exc}") from exc
313
+ if not valid or path != self.service_file(binding.root):
314
+ raise ServiceError(f"modified or mismatched service definition: {path}")
315
+ if binding.root == self.home or binding.root == Path(binding.root.anchor):
316
+ raise ServiceError(f"unsafe root in service definition: {path}")
317
+ return binding
318
+
319
+ def _systemd_properties(self, root: Path) -> dict[str, str]:
320
+ # A failed query is never interpreted as an absent service.
321
+ result = self._systemctl("show", self.service_name(root),
322
+ "--property=" + ",".join(_SYSTEMD_PROPERTIES), check=False)
323
+ props: dict[str, str] = {}
324
+ for line in result.stdout.splitlines():
325
+ key, sep, value = line.partition("=")
326
+ if not sep or key not in _SYSTEMD_PROPERTIES or key in props:
327
+ raise ServiceError("unrecognized systemctl show property response")
328
+ props[key] = value
329
+ if set(props) != set(_SYSTEMD_PROPERTIES):
330
+ raise ServiceError("systemd state unavailable or incomplete; no service action performed")
331
+ if result.returncode and props.get("LoadState") != "not-found":
332
+ raise ServiceError(f"systemd service query failed ({result.returncode})")
333
+ return props
334
+
335
+ def _launchd_jobs(self) -> dict[str, int | None]:
336
+ self._launchctl("print", f"gui/{self.uid}") # exit code only
337
+ output = self._launchctl("list").stdout
338
+ lines = output.splitlines()
339
+ if not lines or lines[0].split() != ["PID", "Status", "Label"]:
340
+ raise ServiceError("unsupported launchctl list format; refusing ambiguous service state")
341
+ jobs: dict[str, int | None] = {}
342
+ for line in lines[1:]:
343
+ parts = line.split()
344
+ if len(parts) != 3:
345
+ raise ServiceError("malformed launchctl list row")
346
+ pid, status_code, label = parts
347
+ if (not status_code.lstrip("-").isdigit() or label in jobs
348
+ or (pid != "-" and (not pid.isdigit() or int(pid) <= 0))):
349
+ raise ServiceError("invalid launchctl list PID/status/label")
350
+ jobs[label] = None if pid == "-" else int(pid)
351
+ return jobs
352
+
353
+ def _ready(self, root: Path) -> bool:
354
+ path = root / "state/service.ready"
355
+ if not path.exists() and not path.is_symlink():
356
+ return False
357
+ try:
358
+ return stat.S_IMODE(path.lstat().st_mode) == 0o600 and _read_owned(path, self.uid) == _READY
359
+ except (OSError, ServiceError):
360
+ return False
361
+
362
+ def _clear_ready(self, root: Path) -> None:
363
+ path = root / "state/service.ready"
364
+ if not path.exists() and not path.is_symlink():
365
+ return
366
+ _read_owned(path, self.uid)
367
+ path.unlink()
368
+
369
+ def inspect(self, root: Path, *, lifetime_descriptor: int | None = None) -> ServiceStatus | None:
370
+ return self._inspect(root, lifetime_descriptor=lifetime_descriptor)
371
+
372
+ def _identity_matches(self, binding: ServiceBinding, pid: int, *, starting: bool = False) -> bool:
373
+ path = binding.root / "state/service.identity.json"
374
+ if not path.exists() and not path.is_symlink():
375
+ return False
376
+ try:
377
+ if stat.S_IMODE(path.lstat().st_mode) != 0o600:
378
+ raise ValueError("runtime identity is not private")
379
+ identity = json.loads(_read_owned(path, self.uid))
380
+ except (OSError, ValueError) as exc:
381
+ raise ServiceError(f"invalid runtime service identity: {path}: {exc}") from exc
382
+ expected = {"format": 1, "pid": pid, "python": str(binding.python),
383
+ "prefix": str(binding.prefix), "root": str(binding.root)}
384
+ if (starting and isinstance(identity, dict) and type(identity.get("pid")) is int
385
+ and identity["pid"] != pid):
386
+ # The new launcher can briefly own the lock before replacing the
387
+ # previous process's evidence. This is not a ready or update proof.
388
+ return False
389
+ if (identity != expected or type(identity.get("format")) is not int
390
+ or type(identity.get("pid")) is not int):
391
+ raise ServiceError("running service identity differs from its registered binding; "
392
+ "inspect the service definition before controlling it")
393
+ return True
394
+
395
+ def _inspect(self, root: Path, *, stopping: bool = False,
396
+ starting: bool = False,
397
+ lifetime_descriptor: int | None = None) -> ServiceStatus | None:
398
+ root = self._root(root)
399
+ path = self.service_file(root)
400
+ exists = path.exists() or path.is_symlink()
401
+ binding = self._binding_from_file(path) if exists else None
402
+ if binding is not None and binding.root != root:
403
+ raise ServiceError("service root does not match selected instance")
404
+ if self.platform == "linux":
405
+ props = self._systemd_properties(root)
406
+ if binding is None:
407
+ if props["LoadState"] != "not-found" or props["FragmentPath"]:
408
+ raise ServiceError("service definition is missing but systemd still knows the target")
409
+ running = False
410
+ else:
411
+ if (props["LoadState"] != "loaded"
412
+ or not _same_service_fragment(props["FragmentPath"], path)
413
+ or props["DropInPaths"] or props["NeedDaemonReload"] != "no"
414
+ or props["Transient"] != "no"):
415
+ raise ServiceError("effective systemd definition differs or is unknown; inspect overrides")
416
+ active = props["ActiveState"]
417
+ if active not in {"active", "activating", "reloading", "deactivating", "inactive", "failed"}:
418
+ raise ServiceError(f"unrecognized systemd active state: {active}")
419
+ if not props["MainPID"].isdigit() or not props["ControlPID"].isdigit():
420
+ raise ServiceError("systemd did not report exact process state")
421
+ running = active not in {"inactive", "failed"} or any(
422
+ int(props[key]) > 0 for key in ("MainPID", "ControlPID")
423
+ )
424
+ unit_state = props["UnitFileState"]
425
+ enabled = (True if unit_state in {"enabled", "enabled-runtime"}
426
+ else False if unit_state == "disabled" else None)
427
+ loaded = props["LoadState"] == "loaded"
428
+ else:
429
+ jobs = self._launchd_jobs()
430
+ name = self.service_name(root)
431
+ exact = self._launchctl("print", f"gui/{self.uid}/{name}", check=False)
432
+ loaded = name in jobs
433
+ if (exact.returncode == 0) != loaded:
434
+ raise ServiceError("launchctl namespace/state disagrees with the exact GUI target")
435
+ if binding is None and loaded:
436
+ raise ServiceError("LaunchAgent definition missing but target is still loaded")
437
+ running = loaded and jobs[name] is not None
438
+ # print-disabled is diagnostic text, not a machine API. Do not guess.
439
+ enabled = None
440
+ lock_free = _lock_available(root, self.uid)
441
+ if lifetime_descriptor is not None:
442
+ from .cli_data import validate_lifetime_lock
443
+
444
+ try:
445
+ validate_lifetime_lock(root, lifetime_descriptor)
446
+ except (OSError, RuntimeError) as exc:
447
+ raise ServiceError(f"caller does not hold this instance's lifetime lock: {exc}") from exc
448
+ if running:
449
+ raise ServiceError("manager reports a running instance while caller owns its lifetime lock")
450
+ lock_free = True # Exact ownership, not mere observation of a busy inode.
451
+ if not running and not lock_free and not stopping:
452
+ raise ServiceError("instance lifetime lock is held despite a stopped/unbound service")
453
+ if binding is None:
454
+ return None
455
+ identity_proven = True
456
+ if self.platform == "darwin" and running and not stopping:
457
+ identity_proven = not lock_free and self._identity_matches(binding, jobs[name], starting=starting)
458
+ if not identity_proven and not starting:
459
+ raise ServiceError("running LaunchAgent identity is not yet proven by its lifetime lock "
460
+ "and runtime identity; retry after startup")
461
+ return ServiceStatus(binding, running, enabled,
462
+ running and not lock_free and identity_proven and self._ready(root), loaded)
463
+
464
+ def list_instances(self, *, prefix: Path | None = None) -> list[ServiceStatus]:
465
+ """Inspect all owned candidates before a caller can begin stopping any."""
466
+ pattern = "netizen*.service" if self.platform == "linux" else "io.github.lijingda.netizen*.plist"
467
+ candidates = {path for path in self.service_dir.glob(pattern)
468
+ if self._instance_name(path.name if self.platform == "linux" else path.stem)}
469
+ if self.platform == "linux":
470
+ for command, key in (("list-units", "unit"), ("list-unit-files", "unit_file")):
471
+ args = [command, "netizen*.service", "--output=json", "--no-pager"]
472
+ if command == "list-units":
473
+ args.append("--all")
474
+ try:
475
+ result = self._systemctl(*args, check=False)
476
+ rows = json.loads(result.stdout)
477
+ if not isinstance(rows, list):
478
+ raise ValueError("expected service array")
479
+ # systemd 252's list-unit-files returns 1 for no matches.
480
+ # Only its validated empty JSON array has this meaning;
481
+ # no diagnostic text or other failed query proves absence.
482
+ if result.returncode and not (command == "list-unit-files"
483
+ and result.returncode == 1 and not rows
484
+ and not result.stderr.strip()):
485
+ raise ServiceError(f"systemd inventory query failed ({result.returncode})")
486
+ for row in rows:
487
+ name = row[key]
488
+ if not isinstance(name, str) or Path(name).name != name:
489
+ raise ValueError("invalid unit name")
490
+ if self._instance_name(name):
491
+ candidates.add(self.service_dir / name)
492
+ except (ValueError, TypeError, KeyError) as exc:
493
+ raise ServiceError("unsupported systemd service inventory response") from exc
494
+ else:
495
+ for name in self._launchd_jobs():
496
+ if self._instance_name(name):
497
+ candidates.add(self.service_dir / (name + ".plist"))
498
+ statuses = []
499
+ for path in sorted(candidates):
500
+ binding = self._binding_from_file(path)
501
+ if prefix is not None and binding.prefix != prefix.resolve():
502
+ continue
503
+ status = self.inspect(binding.root)
504
+ if status is None:
505
+ raise ServiceError("service inventory changed while being inspected")
506
+ statuses.append(status)
507
+ return statuses
508
+
509
+ def register(self, binding: ServiceBinding, *, enabled: bool = True,
510
+ lifetime_descriptor: int | None = None) -> None:
511
+ self._root(binding.root)
512
+ try:
513
+ require_instance_root_marker(binding.root, uid=self.uid)
514
+ except (OSError, ValueError) as exc:
515
+ raise ServiceError(f"instance must be prepared before service registration: {exc}") from exc
516
+ existing = self.inspect(binding.root, lifetime_descriptor=lifetime_descriptor)
517
+ if existing is not None:
518
+ if existing.binding != binding:
519
+ raise ServiceError("instance already bound to another environment; remove first")
520
+ return
521
+ if not binding.python.is_file() or not binding.prefix.is_dir():
522
+ raise ServiceError("bound Python environment is missing; select an installed environment")
523
+ self.service_dir.mkdir(parents=True, exist_ok=True)
524
+ if self.service_dir.is_symlink():
525
+ raise ServiceError("service directory must not be a symlink")
526
+ metadata = self.service_dir.stat()
527
+ if metadata.st_uid != self.uid or stat.S_IMODE(metadata.st_mode) & 0o022:
528
+ raise ServiceError("service directory must be owned by the current user and not group/world writable")
529
+ path = self.service_file(binding.root)
530
+ try:
531
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC, 0o600)
532
+ with os.fdopen(fd, "wb") as dest:
533
+ dest.write(self._render(binding, enabled=enabled))
534
+ dest.flush()
535
+ os.fsync(dest.fileno())
536
+ except OSError as exc:
537
+ raise ServiceError(f"could not register service definition {path}: {exc}") from exc
538
+ if self.platform == "linux":
539
+ self._systemctl("daemon-reload")
540
+ self._systemctl("enable" if enabled else "disable", self.service_name(binding.root))
541
+ else:
542
+ self._run("plutil", "-lint", str(path))
543
+ self._launchctl("enable" if enabled else "disable",
544
+ f"gui/{self.uid}/{self.service_name(binding.root)}")
545
+ self.inspect(binding.root, lifetime_descriptor=lifetime_descriptor)
546
+
547
+ def start(self, root: Path, *, timeout: float = 120) -> ServiceStatus:
548
+ root = self._root(root)
549
+ status = self._inspect(root, starting=True)
550
+ if status is None:
551
+ raise ServiceError("instance has no service binding; register it before starting")
552
+ if status.ready:
553
+ return status
554
+ if not status.binding.python.is_file() or not status.binding.prefix.is_dir():
555
+ raise ServiceError("bound Python environment is missing; remove and start from the new environment")
556
+ if not status.running:
557
+ self._clear_ready(root)
558
+ if self.platform == "linux":
559
+ self._systemctl("start", self.service_name(root))
560
+ elif not status.loaded:
561
+ # Do not enable: a pre-existing disable override must remain visible.
562
+ self._launchctl("bootstrap", f"gui/{self.uid}", str(self.service_file(root)))
563
+ else:
564
+ self._launchctl("kickstart", f"gui/{self.uid}/{self.service_name(root)}")
565
+ deadline = time.monotonic() + timeout
566
+ while True:
567
+ current = self._inspect(root, starting=True)
568
+ if current is None:
569
+ raise ServiceError("service was removed while starting")
570
+ if current.ready:
571
+ return current
572
+ if time.monotonic() >= deadline:
573
+ raise ServiceError(f"service did not become ready within {timeout:g}s; "
574
+ "inspect status/logs; startup failure does not imply it is stopped")
575
+ time.sleep(min(0.25, max(0.0, deadline - time.monotonic())))
576
+
577
+ def stop(self, root: Path, *, timeout: float = 90) -> ServiceStatus:
578
+ root = self._root(root)
579
+ status = self.inspect(root)
580
+ if status is None:
581
+ raise ServiceError("instance has no registered service to stop")
582
+ if self.platform == "linux":
583
+ # stop cancels pending restarts without changing boot-time enable intent.
584
+ self._systemctl("stop", self.service_name(root))
585
+ elif status.loaded:
586
+ self._launchctl("bootout", f"gui/{self.uid}/{self.service_name(root)}")
587
+ deadline = time.monotonic() + timeout
588
+ while True:
589
+ current = self._inspect(root, stopping=True)
590
+ if current is None:
591
+ raise ServiceError("service disappeared during stop confirmation")
592
+ if not current.running and _lock_available(root, self.uid):
593
+ if self.platform == "darwin" and current.loaded:
594
+ raise ServiceError("launchd target remained loaded after bootout")
595
+ self._clear_ready(root)
596
+ return current
597
+ if time.monotonic() >= deadline:
598
+ raise ServiceError(f"service did not fully exit within {timeout:g}s; no further action performed")
599
+ time.sleep(min(0.25, max(0.0, deadline - time.monotonic())))
600
+
601
+ def restart(self, root: Path) -> ServiceStatus:
602
+ self.stop(root)
603
+ return self.start(root)
604
+
605
+ def remove(self, root: Path) -> None:
606
+ root = self._root(root)
607
+ if self.inspect(root) is None:
608
+ return
609
+ self.stop(root)
610
+ path = self.service_file(root)
611
+ self._binding_from_file(path)
612
+ if self.platform == "linux":
613
+ self._systemctl("disable", self.service_name(root))
614
+ else:
615
+ self._launchctl("disable", f"gui/{self.uid}/{self.service_name(root)}")
616
+ path.unlink()
617
+ if self.platform == "linux":
618
+ self._systemctl("daemon-reload")
619
+ self._systemctl("reset-failed", self.service_name(root), check=False)
620
+ if self.inspect(root) is not None:
621
+ raise ServiceError("service removal not confirmed")
622
+
623
+ def logs(self, root: Path, *, lines: int = 100) -> str:
624
+ root = self._root(root)
625
+ if lines < 1:
626
+ raise ServiceError("log line count must be positive")
627
+ try:
628
+ require_instance_root_marker(root, uid=self.uid)
629
+ except (OSError, ValueError) as exc:
630
+ raise ServiceError(f"cannot identify the requested instance logs: {exc}") from exc
631
+ sections: list[str] = []
632
+ paths = [("Runtime log", root / "state/netizen.log")]
633
+ if self.platform == "darwin":
634
+ paths.append(("LaunchAgent startup stderr", root / "state/launchd.stderr.log"))
635
+ for label, path in paths:
636
+ try:
637
+ content = _tail_log(path, uid=self.uid, lines=lines)
638
+ content = "(not created)" if content is None else content or "(empty)"
639
+ except (OSError, ServiceError) as exc:
640
+ content = f"(unavailable: {exc})"
641
+ sections.append(f"{label} ({path}):\n{content}")
642
+ if self.platform == "linux":
643
+ try:
644
+ result = self._run("journalctl", "--user", "--unit", self.service_name(root),
645
+ "--no-pager", "--lines", str(lines), "--output=cat")
646
+ diagnostics = result.stdout.strip() or "(no entries)"
647
+ except ServiceError as exc:
648
+ diagnostics = f"(unavailable: {exc})"
649
+ sections.append("Systemd startup diagnostics:\n" + diagnostics)
650
+ # Text from each sink is only displayed, never parsed as service state.
651
+ return "\n\n".join(sections)