appecosystem 0.3.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.
- appecosystem-0.3.0.dist-info/METADATA +103 -0
- appecosystem-0.3.0.dist-info/RECORD +30 -0
- appecosystem-0.3.0.dist-info/WHEEL +5 -0
- appecosystem-0.3.0.dist-info/entry_points.txt +2 -0
- appecosystem-0.3.0.dist-info/licenses/LICENSE +21 -0
- appecosystem-0.3.0.dist-info/top_level.txt +4 -0
- cli/__init__.py +1 -0
- cli/__main__.py +4 -0
- cli/commands.py +635 -0
- cli/main.py +99 -0
- events/__init__.py +6 -0
- events/event_bus.py +137 -0
- events/event_types.py +48 -0
- events/schemas.py +40 -0
- llm/__init__.py +1 -0
- llm/command_router.py +188 -0
- llm/discovery_agent.py +107 -0
- llm/project_manifest.py +135 -0
- registry/__init__.py +1 -0
- registry/app.py +556 -0
- registry/app_store.py +153 -0
- registry/credential_store.py +160 -0
- registry/discovery.py +112 -0
- registry/health_monitor.py +182 -0
- registry/logging_config.py +49 -0
- registry/metrics.py +86 -0
- registry/models.py +73 -0
- registry/placement.py +54 -0
- registry/profile_store.py +109 -0
- registry/registry.py +153 -0
cli/commands.py
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
"""CLI command implementations for ecosystem management."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
ECOSYSTEM_DIR = Path(__file__).resolve().parent.parent
|
|
16
|
+
CONFIG_PATH = ECOSYSTEM_DIR / "ecosystem.yaml"
|
|
17
|
+
PID_FILE = ECOSYSTEM_DIR / "data" / "registry.pid"
|
|
18
|
+
DATA_DIR = ECOSYSTEM_DIR / "data"
|
|
19
|
+
PLIST_NAME = "com.ecosystem.registry"
|
|
20
|
+
PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / f"{PLIST_NAME}.plist"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_config() -> dict:
|
|
24
|
+
"""Load ecosystem.yaml config."""
|
|
25
|
+
if not CONFIG_PATH.exists():
|
|
26
|
+
print(f"Error: {CONFIG_PATH} not found")
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
with open(CONFIG_PATH) as f:
|
|
29
|
+
return yaml.safe_load(f)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Project helpers
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def _resolve_projects(config: dict) -> list[dict]:
|
|
37
|
+
"""Resolve project definitions from config into a list of project dicts.
|
|
38
|
+
|
|
39
|
+
The base path is taken from ECOSYSTEM_BASE_PATH when set, otherwise from
|
|
40
|
+
ecosystem.yaml. Keeping it overridable avoids hardcoding developer-specific
|
|
41
|
+
absolute paths in the committed config.
|
|
42
|
+
"""
|
|
43
|
+
base_str = (
|
|
44
|
+
os.environ.get("ECOSYSTEM_BASE_PATH")
|
|
45
|
+
or config.get("ecosystem", {}).get("base_path", "")
|
|
46
|
+
)
|
|
47
|
+
# Default to the repo's parent directory (sibling project layout).
|
|
48
|
+
base = Path(base_str) if base_str else ECOSYSTEM_DIR.parent
|
|
49
|
+
projects = []
|
|
50
|
+
for key, proj in (config.get("projects") or {}).items():
|
|
51
|
+
projects.append({
|
|
52
|
+
"key": key,
|
|
53
|
+
"name": proj.get("name", key),
|
|
54
|
+
"abs_path": base / proj["path"],
|
|
55
|
+
"host": proj.get("host", "localhost"),
|
|
56
|
+
"port": proj["port"],
|
|
57
|
+
"health_endpoint": proj.get("health_endpoint", "/health"),
|
|
58
|
+
"start_command": proj.get("start_command", ""),
|
|
59
|
+
})
|
|
60
|
+
return projects
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _check_health(host: str, port: int, endpoint: str) -> bool:
|
|
64
|
+
"""Check if a service is healthy by hitting its health endpoint."""
|
|
65
|
+
try:
|
|
66
|
+
resp = httpx.get(f"http://{host}:{port}{endpoint}", timeout=3.0)
|
|
67
|
+
return resp.status_code == 200
|
|
68
|
+
except Exception:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _format_status_line(name: str, port: int, healthy: bool) -> str:
|
|
73
|
+
"""Format a single status line for a project."""
|
|
74
|
+
status = "HEALTHY" if healthy else "DOWN"
|
|
75
|
+
return f"{name:<20} port {port:<6} {status}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _get_pid_file(key: str) -> Path:
|
|
79
|
+
"""Return the PID file path for a project."""
|
|
80
|
+
return DATA_DIR / f"{key}.pid"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _get_log_file(key: str) -> Path:
|
|
84
|
+
"""Return the log file path for a project."""
|
|
85
|
+
return DATA_DIR / f"{key}.log"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_running(key: str) -> bool:
|
|
89
|
+
"""Check if a project process is still running."""
|
|
90
|
+
pid_file = _get_pid_file(key)
|
|
91
|
+
if not pid_file.exists():
|
|
92
|
+
return False
|
|
93
|
+
try:
|
|
94
|
+
pid = int(pid_file.read_text().strip())
|
|
95
|
+
os.kill(pid, 0) # signal 0 = check existence
|
|
96
|
+
return True
|
|
97
|
+
except (ProcessLookupError, ValueError, OSError):
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _pid_alive(pid: int) -> bool:
|
|
102
|
+
"""Return True if a process with the given PID currently exists."""
|
|
103
|
+
try:
|
|
104
|
+
os.kill(pid, 0)
|
|
105
|
+
return True
|
|
106
|
+
except (ProcessLookupError, OSError):
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _terminate_pid(pid: int, label: str, grace: float = 5.0) -> str:
|
|
111
|
+
"""Gracefully stop a process, escalating to SIGKILL if it does not exit.
|
|
112
|
+
|
|
113
|
+
Sends SIGTERM, polls for up to ``grace`` seconds, then sends SIGKILL.
|
|
114
|
+
Returns a human-readable outcome string.
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
os.kill(pid, signal.SIGTERM)
|
|
118
|
+
except ProcessLookupError:
|
|
119
|
+
return f"{label}: already stopped (stale PID)"
|
|
120
|
+
|
|
121
|
+
waited = 0.0
|
|
122
|
+
interval = 0.2
|
|
123
|
+
while waited < grace:
|
|
124
|
+
if not _pid_alive(pid):
|
|
125
|
+
return f"{label}: stopped (PID {pid})"
|
|
126
|
+
time.sleep(interval)
|
|
127
|
+
waited += interval
|
|
128
|
+
|
|
129
|
+
# Grace period exceeded — force kill to avoid orphaned processes.
|
|
130
|
+
try:
|
|
131
|
+
os.kill(pid, signal.SIGKILL)
|
|
132
|
+
return f"{label}: force-killed after {grace:.0f}s (PID {pid})"
|
|
133
|
+
except ProcessLookupError:
|
|
134
|
+
return f"{label}: stopped (PID {pid})"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _registry_host(config: dict) -> str:
|
|
138
|
+
"""Resolve the registry bind host.
|
|
139
|
+
|
|
140
|
+
Defaults to loopback so the control plane is not exposed on all interfaces.
|
|
141
|
+
Set ECOSYSTEM_REGISTRY_HOST=0.0.0.0 only when the registry is firewalled
|
|
142
|
+
to a trusted network.
|
|
143
|
+
"""
|
|
144
|
+
return os.environ.get(
|
|
145
|
+
"ECOSYSTEM_REGISTRY_HOST",
|
|
146
|
+
config.get("registry", {}).get("host", "127.0.0.1"),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cmd_start() -> int:
|
|
151
|
+
"""Start the ecosystem registry."""
|
|
152
|
+
config = _load_config()
|
|
153
|
+
port = config.get("registry", {}).get("port", 8500)
|
|
154
|
+
host = _registry_host(config)
|
|
155
|
+
|
|
156
|
+
print(f"Starting ecosystem registry on {host}:{port}...")
|
|
157
|
+
|
|
158
|
+
# Ensure data dir exists
|
|
159
|
+
(ECOSYSTEM_DIR / "data").mkdir(exist_ok=True)
|
|
160
|
+
|
|
161
|
+
proc = subprocess.Popen(
|
|
162
|
+
[
|
|
163
|
+
sys.executable, "-m", "uvicorn",
|
|
164
|
+
"registry.app:app",
|
|
165
|
+
"--host", host,
|
|
166
|
+
"--port", str(port),
|
|
167
|
+
],
|
|
168
|
+
cwd=str(ECOSYSTEM_DIR),
|
|
169
|
+
stdout=subprocess.DEVNULL,
|
|
170
|
+
stderr=subprocess.DEVNULL,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
PID_FILE.write_text(str(proc.pid))
|
|
175
|
+
print(f"Registry started (PID {proc.pid})")
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cmd_stop() -> int:
|
|
180
|
+
"""Stop the ecosystem registry."""
|
|
181
|
+
if not PID_FILE.exists():
|
|
182
|
+
print("Registry is not running (no PID file)")
|
|
183
|
+
return 1
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
pid = int(PID_FILE.read_text().strip())
|
|
187
|
+
except ValueError:
|
|
188
|
+
print("Registry PID file is corrupt - cleaning up")
|
|
189
|
+
PID_FILE.unlink(missing_ok=True)
|
|
190
|
+
return 1
|
|
191
|
+
|
|
192
|
+
print(_terminate_pid(pid, "Registry"))
|
|
193
|
+
PID_FILE.unlink(missing_ok=True)
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def cmd_status() -> int:
|
|
198
|
+
"""Show health status of all ecosystem services."""
|
|
199
|
+
config = _load_config()
|
|
200
|
+
port = config.get("registry", {}).get("port", 8500)
|
|
201
|
+
registry_url = f"http://localhost:{port}"
|
|
202
|
+
|
|
203
|
+
# Check registry itself
|
|
204
|
+
try:
|
|
205
|
+
resp = httpx.get(f"{registry_url}/health", timeout=3.0)
|
|
206
|
+
print(f"{'Registry':<20} port {port:<6} {'HEALTHY' if resp.status_code == 200 else 'UNHEALTHY'}")
|
|
207
|
+
except Exception:
|
|
208
|
+
print(f"{'Registry':<20} port {port:<6} DOWN")
|
|
209
|
+
print("\nRegistry is not running. Start it with: ecosystem start")
|
|
210
|
+
# Fall back to config-based status
|
|
211
|
+
print("\nConfigured projects:")
|
|
212
|
+
for key, proj in (config.get("projects") or {}).items():
|
|
213
|
+
print(f" {proj['name']:<20} port {proj['port']:<6} (not checked)")
|
|
214
|
+
return 1
|
|
215
|
+
|
|
216
|
+
# Check registered services (signed so it works if read-auth is enabled)
|
|
217
|
+
try:
|
|
218
|
+
from ecosystem_auth.tokens import get_ecosystem_secret, sign_request
|
|
219
|
+
services_url = f"{registry_url}/services"
|
|
220
|
+
headers = sign_request("GET", services_url, get_ecosystem_secret())
|
|
221
|
+
resp = httpx.get(services_url, timeout=3.0, headers=headers)
|
|
222
|
+
services = resp.json()
|
|
223
|
+
except Exception:
|
|
224
|
+
print("Could not fetch services from registry")
|
|
225
|
+
return 1
|
|
226
|
+
|
|
227
|
+
if not services:
|
|
228
|
+
print("\nNo services registered.")
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
print(f"\n{'Service':<20} {'Port':<8} {'Status':<12} {'Last Check'}")
|
|
232
|
+
print("-" * 60)
|
|
233
|
+
for svc in services:
|
|
234
|
+
name = svc.get("name", "?")
|
|
235
|
+
port = svc.get("port", "?")
|
|
236
|
+
status = svc.get("status", "unknown").upper()
|
|
237
|
+
last_check = svc.get("last_health_check")
|
|
238
|
+
check_str = "never" if not last_check else f"{last_check:.0f}"
|
|
239
|
+
print(f"{name:<20} {str(port):<8} {status:<12} {check_str}")
|
|
240
|
+
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def cmd_start_all() -> int:
|
|
245
|
+
"""Start the registry and all configured projects."""
|
|
246
|
+
# Start registry first
|
|
247
|
+
cmd_start()
|
|
248
|
+
|
|
249
|
+
config = _load_config()
|
|
250
|
+
projects = _resolve_projects(config)
|
|
251
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
|
|
253
|
+
for proj in projects:
|
|
254
|
+
key = proj["key"]
|
|
255
|
+
name = proj["name"]
|
|
256
|
+
cmd = proj["start_command"]
|
|
257
|
+
abs_path = proj["abs_path"]
|
|
258
|
+
|
|
259
|
+
if not cmd:
|
|
260
|
+
print(f" {name}: no start_command configured, skipping")
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
if not abs_path.exists():
|
|
264
|
+
print(f" {name}: path {abs_path} not found, skipping")
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
if _is_running(key):
|
|
268
|
+
print(f" {name}: already running")
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
log_file = _get_log_file(key)
|
|
272
|
+
with open(log_file, "a") as log_fh:
|
|
273
|
+
cmd_parts = shlex.split(cmd)
|
|
274
|
+
if cmd_parts and cmd_parts[0] == "python":
|
|
275
|
+
cmd_parts[0] = sys.executable
|
|
276
|
+
proc = subprocess.Popen(
|
|
277
|
+
cmd_parts,
|
|
278
|
+
cwd=str(abs_path),
|
|
279
|
+
stdout=log_fh,
|
|
280
|
+
stderr=subprocess.STDOUT,
|
|
281
|
+
)
|
|
282
|
+
_get_pid_file(key).write_text(str(proc.pid))
|
|
283
|
+
print(f" {name}: started (PID {proc.pid})")
|
|
284
|
+
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def cmd_stop_all() -> int:
|
|
289
|
+
"""Stop all projects and then the registry."""
|
|
290
|
+
config = _load_config()
|
|
291
|
+
projects = _resolve_projects(config)
|
|
292
|
+
|
|
293
|
+
for proj in reversed(projects):
|
|
294
|
+
key = proj["key"]
|
|
295
|
+
name = proj["name"]
|
|
296
|
+
pid_file = _get_pid_file(key)
|
|
297
|
+
|
|
298
|
+
if not pid_file.exists():
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
pid = int(pid_file.read_text().strip())
|
|
303
|
+
except ValueError:
|
|
304
|
+
print(f" {name}: corrupt PID file, cleaning up")
|
|
305
|
+
pid_file.unlink(missing_ok=True)
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
print(f" {_terminate_pid(pid, name)}")
|
|
309
|
+
pid_file.unlink(missing_ok=True)
|
|
310
|
+
|
|
311
|
+
# Stop registry last
|
|
312
|
+
cmd_stop()
|
|
313
|
+
return 0
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def cmd_restart() -> int:
|
|
317
|
+
"""Restart the ecosystem registry (stop if running, then start)."""
|
|
318
|
+
if PID_FILE.exists():
|
|
319
|
+
cmd_stop()
|
|
320
|
+
# Brief pause so the port is released before re-binding.
|
|
321
|
+
time.sleep(1.0)
|
|
322
|
+
return cmd_start()
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def cmd_monitor(interval: int = 5, once: bool = False) -> int:
|
|
326
|
+
"""Continuously display ecosystem health status.
|
|
327
|
+
|
|
328
|
+
Refreshes every ``interval`` seconds until interrupted. With ``once``,
|
|
329
|
+
renders a single snapshot and returns (useful for scripting/tests).
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
while True:
|
|
333
|
+
# Clear screen for a live dashboard feel (skipped in --once mode).
|
|
334
|
+
if not once:
|
|
335
|
+
print("\033[2J\033[H", end="")
|
|
336
|
+
print(f"Ecosystem monitor — refresh every {interval}s (Ctrl+C to exit)")
|
|
337
|
+
print(f"Last update: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
|
|
338
|
+
cmd_status()
|
|
339
|
+
if once:
|
|
340
|
+
return 0
|
|
341
|
+
time.sleep(interval)
|
|
342
|
+
except KeyboardInterrupt:
|
|
343
|
+
print("\nMonitor stopped.")
|
|
344
|
+
return 0
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def cmd_logs(project: str | None = None, lines: int = 50) -> int:
|
|
348
|
+
"""Tail log files for a project (or list available logs)."""
|
|
349
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
350
|
+
|
|
351
|
+
if project is None:
|
|
352
|
+
# List available log files
|
|
353
|
+
log_files = sorted(DATA_DIR.glob("*.log"))
|
|
354
|
+
if not log_files:
|
|
355
|
+
print("No log files found.")
|
|
356
|
+
return 0
|
|
357
|
+
print("Available logs:")
|
|
358
|
+
for lf in log_files:
|
|
359
|
+
size = lf.stat().st_size
|
|
360
|
+
print(f" {lf.stem:<20} {size:>8} bytes {lf}")
|
|
361
|
+
return 0
|
|
362
|
+
|
|
363
|
+
log_file = _get_log_file(project)
|
|
364
|
+
if not log_file.exists():
|
|
365
|
+
print(f"No log file found for '{project}'")
|
|
366
|
+
print(f"Expected: {log_file}")
|
|
367
|
+
return 1
|
|
368
|
+
|
|
369
|
+
# Read last N lines
|
|
370
|
+
try:
|
|
371
|
+
all_lines = log_file.read_text().splitlines()
|
|
372
|
+
tail = all_lines[-lines:]
|
|
373
|
+
for line in tail:
|
|
374
|
+
print(line)
|
|
375
|
+
except Exception as e:
|
|
376
|
+
print(f"Error reading log: {e}")
|
|
377
|
+
return 1
|
|
378
|
+
return 0
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def cmd_install() -> int:
|
|
382
|
+
"""Install ecosystem registry as a macOS launchd service."""
|
|
383
|
+
config = _load_config()
|
|
384
|
+
port = config.get("registry", {}).get("port", 8500)
|
|
385
|
+
host = _registry_host(config)
|
|
386
|
+
python = sys.executable
|
|
387
|
+
ecosystem_dir = str(ECOSYSTEM_DIR)
|
|
388
|
+
supervisor = str(ECOSYSTEM_DIR / "scripts" / "process_manager.py")
|
|
389
|
+
# Wrap uvicorn in the supervisor so macOS gets the same graceful-shutdown
|
|
390
|
+
# and SIGKILL escalation behaviour as the Linux systemd unit.
|
|
391
|
+
uvicorn_cmd = f"{python} -m uvicorn registry.app:app --host {host} --port {port}"
|
|
392
|
+
|
|
393
|
+
plist_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
394
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
395
|
+
<plist version="1.0">
|
|
396
|
+
<dict>
|
|
397
|
+
<key>Label</key>
|
|
398
|
+
<string>{PLIST_NAME}</string>
|
|
399
|
+
<key>ProgramArguments</key>
|
|
400
|
+
<array>
|
|
401
|
+
<string>{python}</string>
|
|
402
|
+
<string>{supervisor}</string>
|
|
403
|
+
<string>--cmd</string>
|
|
404
|
+
<string>{uvicorn_cmd}</string>
|
|
405
|
+
<string>--cwd</string>
|
|
406
|
+
<string>{ecosystem_dir}</string>
|
|
407
|
+
<string>--log</string>
|
|
408
|
+
<string>{ecosystem_dir}/data/registry.log</string>
|
|
409
|
+
</array>
|
|
410
|
+
<key>WorkingDirectory</key>
|
|
411
|
+
<string>{ecosystem_dir}</string>
|
|
412
|
+
<key>RunAtLoad</key>
|
|
413
|
+
<true/>
|
|
414
|
+
<key>KeepAlive</key>
|
|
415
|
+
<true/>
|
|
416
|
+
<key>StandardOutPath</key>
|
|
417
|
+
<string>{ecosystem_dir}/data/registry.stdout.log</string>
|
|
418
|
+
<key>StandardErrorPath</key>
|
|
419
|
+
<string>{ecosystem_dir}/data/registry.stderr.log</string>
|
|
420
|
+
</dict>
|
|
421
|
+
</plist>"""
|
|
422
|
+
|
|
423
|
+
PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
424
|
+
PLIST_PATH.write_text(plist_content)
|
|
425
|
+
subprocess.run(["launchctl", "load", str(PLIST_PATH)])
|
|
426
|
+
print(f"Installed and loaded {PLIST_NAME}")
|
|
427
|
+
print(f"Plist: {PLIST_PATH}")
|
|
428
|
+
return 0
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def cmd_uninstall() -> int:
|
|
432
|
+
"""Remove ecosystem launchd service."""
|
|
433
|
+
if PLIST_PATH.exists():
|
|
434
|
+
subprocess.run(["launchctl", "unload", str(PLIST_PATH)])
|
|
435
|
+
PLIST_PATH.unlink()
|
|
436
|
+
print(f"Unloaded and removed {PLIST_NAME}")
|
|
437
|
+
else:
|
|
438
|
+
print(f"No plist found at {PLIST_PATH}")
|
|
439
|
+
return 0
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def cmd_secret(action: str, value: str | None = None) -> int:
|
|
443
|
+
"""Manage the shared ecosystem HMAC secret (file-backed, device-local).
|
|
444
|
+
|
|
445
|
+
Actions: generate (create if absent), show (print for copying to another
|
|
446
|
+
device), import <value> (set on this device), path (print the file location).
|
|
447
|
+
"""
|
|
448
|
+
try:
|
|
449
|
+
from ecosystem_auth.tokens import (
|
|
450
|
+
ensure_ecosystem_secret, get_ecosystem_secret, write_secret, secret_file_path,
|
|
451
|
+
)
|
|
452
|
+
except Exception as e: # pragma: no cover
|
|
453
|
+
print(f"ecosystem_auth not available: {e}")
|
|
454
|
+
return 1
|
|
455
|
+
|
|
456
|
+
if action == "path":
|
|
457
|
+
print(secret_file_path())
|
|
458
|
+
return 0
|
|
459
|
+
|
|
460
|
+
if action == "generate":
|
|
461
|
+
secret = ensure_ecosystem_secret()
|
|
462
|
+
print(f"Shared secret ready at {secret_file_path()}")
|
|
463
|
+
print("\nTo join another device to this ecosystem, run there:")
|
|
464
|
+
print(f" ecosystem secret import {secret}")
|
|
465
|
+
return 0
|
|
466
|
+
|
|
467
|
+
if action == "import":
|
|
468
|
+
if not value:
|
|
469
|
+
print("Usage: ecosystem secret import <value>")
|
|
470
|
+
return 1
|
|
471
|
+
p = write_secret(value)
|
|
472
|
+
print(f"Saved shared secret to {p}")
|
|
473
|
+
return 0
|
|
474
|
+
|
|
475
|
+
if action == "show":
|
|
476
|
+
try:
|
|
477
|
+
print(get_ecosystem_secret())
|
|
478
|
+
return 0
|
|
479
|
+
except Exception as e:
|
|
480
|
+
print(str(e))
|
|
481
|
+
return 1
|
|
482
|
+
|
|
483
|
+
print(f"Unknown secret action: {action} (use generate|show|import|path)")
|
|
484
|
+
return 1
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def cmd_provider(action: str, name: str | None = None, value: str | None = None) -> int:
|
|
488
|
+
"""Manage cloud AI provider API keys (anthropic / openai / gemini)."""
|
|
489
|
+
from registry.credential_store import SUPPORTED_PROVIDERS, ProviderCredentialStore
|
|
490
|
+
|
|
491
|
+
store = ProviderCredentialStore()
|
|
492
|
+
|
|
493
|
+
if action == "list":
|
|
494
|
+
for prov, st in store.status().items():
|
|
495
|
+
state = f"configured (…{st['last4']})" if st["configured"] else "not configured"
|
|
496
|
+
print(f" {prov:<10} {state}")
|
|
497
|
+
return 0
|
|
498
|
+
|
|
499
|
+
if not name:
|
|
500
|
+
print(f"Usage: ecosystem provider {action} <{'|'.join(SUPPORTED_PROVIDERS)}>")
|
|
501
|
+
return 2
|
|
502
|
+
|
|
503
|
+
if action == "set":
|
|
504
|
+
key = value
|
|
505
|
+
if not key:
|
|
506
|
+
# Prompt without echoing so the key never lands in shell history or the screen.
|
|
507
|
+
import getpass
|
|
508
|
+
|
|
509
|
+
key = getpass.getpass(f"Paste the {name} API key (input hidden): ")
|
|
510
|
+
try:
|
|
511
|
+
store.set_key(name, key)
|
|
512
|
+
except ValueError as e:
|
|
513
|
+
print(f"Error: {e}")
|
|
514
|
+
return 1
|
|
515
|
+
print(f"Stored {name} key (…{store.status()[name]['last4']}).")
|
|
516
|
+
return 0
|
|
517
|
+
|
|
518
|
+
if action == "delete":
|
|
519
|
+
if name not in SUPPORTED_PROVIDERS:
|
|
520
|
+
print(f"Error: unknown provider {name!r}")
|
|
521
|
+
return 1
|
|
522
|
+
print(f"Deleted {name} key." if store.delete_key(name) else f"No {name} key was stored.")
|
|
523
|
+
return 0
|
|
524
|
+
|
|
525
|
+
print(f"Unknown action {action!r}. Use: list | set | delete")
|
|
526
|
+
return 2
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _device_apps() -> list[dict]:
|
|
530
|
+
"""Per-device enabled-apps record: which ecosystem apps are present here.
|
|
531
|
+
|
|
532
|
+
An app is "present" (enabled on this device) when its repo directory exists
|
|
533
|
+
under the resolved base path — the same signal the registry uses to decide
|
|
534
|
+
what to pre-register. This is what makes subset / multi-device installs
|
|
535
|
+
legible: apps absent here are simply hosted elsewhere (or not installed),
|
|
536
|
+
not "broken"."""
|
|
537
|
+
config = _load_config()
|
|
538
|
+
apps = []
|
|
539
|
+
for proj in _resolve_projects(config):
|
|
540
|
+
apps.append({
|
|
541
|
+
"key": proj["key"],
|
|
542
|
+
"name": proj["name"],
|
|
543
|
+
"present": Path(proj["abs_path"]).exists(),
|
|
544
|
+
"path": str(proj["abs_path"]),
|
|
545
|
+
"port": proj["port"],
|
|
546
|
+
})
|
|
547
|
+
return apps
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def cmd_apps(as_json: bool = False) -> int:
|
|
551
|
+
"""List which ecosystem apps are installed/enabled on this device."""
|
|
552
|
+
apps = _device_apps()
|
|
553
|
+
if as_json:
|
|
554
|
+
print(json.dumps(apps, indent=2))
|
|
555
|
+
return 0
|
|
556
|
+
|
|
557
|
+
present = [a for a in apps if a["present"]]
|
|
558
|
+
absent = [a for a in apps if not a["present"]]
|
|
559
|
+
print(f"Ecosystem apps on this device ({len(present)}/{len(apps)} present):\n")
|
|
560
|
+
for a in apps:
|
|
561
|
+
mark = "✓ present" if a["present"] else "· not here"
|
|
562
|
+
print(f" {mark} {a['name']:<20} :{a['port']} {a['path']}")
|
|
563
|
+
if absent:
|
|
564
|
+
print(
|
|
565
|
+
f"\n{len(absent)} app(s) not installed here are expected on other "
|
|
566
|
+
"devices (or simply not used) — this is normal for subset/networked "
|
|
567
|
+
"deployments."
|
|
568
|
+
)
|
|
569
|
+
return 0
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def cmd_partner(action: str, app_id: str | None = None, name: str | None = None,
|
|
573
|
+
owner: str | None = None, service_names: str | None = None) -> int:
|
|
574
|
+
"""Manage third-party partner app credentials (per-app HMAC keys)."""
|
|
575
|
+
from registry.app_store import AppStore
|
|
576
|
+
store = AppStore()
|
|
577
|
+
|
|
578
|
+
if action == "add":
|
|
579
|
+
if not (app_id and name and owner and service_names):
|
|
580
|
+
print("Usage: ecosystem partner add <app_id> --name <display> "
|
|
581
|
+
"--owner <contact> --service-names a,b")
|
|
582
|
+
return 1
|
|
583
|
+
names = [n.strip() for n in service_names.split(",") if n.strip()]
|
|
584
|
+
try:
|
|
585
|
+
rec, secret = store.add(app_id, name, owner, names)
|
|
586
|
+
except ValueError as e:
|
|
587
|
+
print(f"Error: {e}")
|
|
588
|
+
return 1
|
|
589
|
+
print(f"Created partner app '{rec['app_id']}' (owns: {', '.join(rec['owned_names'])})")
|
|
590
|
+
print("Copy these now — the secret is shown only once:")
|
|
591
|
+
print(f" key_id: {rec['key_id']}")
|
|
592
|
+
print(f" secret: {secret}")
|
|
593
|
+
return 0
|
|
594
|
+
|
|
595
|
+
if action == "list":
|
|
596
|
+
apps = store.list()
|
|
597
|
+
if not apps:
|
|
598
|
+
print("No partner apps registered.")
|
|
599
|
+
return 0
|
|
600
|
+
print(f"Partner apps ({len(apps)}):\n")
|
|
601
|
+
for a in apps:
|
|
602
|
+
print(f" {a['status']:<9} {a['app_id']:<24} {a['display_name']:<18} "
|
|
603
|
+
f"owns={','.join(a['owned_names'])} created={a['created_at']}")
|
|
604
|
+
return 0
|
|
605
|
+
|
|
606
|
+
if action == "show":
|
|
607
|
+
if not app_id:
|
|
608
|
+
print("Usage: ecosystem partner show <app_id>")
|
|
609
|
+
return 1
|
|
610
|
+
a = store.get(app_id)
|
|
611
|
+
if not a:
|
|
612
|
+
print(f"No such partner app: {app_id}")
|
|
613
|
+
return 1
|
|
614
|
+
for k in ("app_id", "key_id", "display_name", "owner", "owned_names",
|
|
615
|
+
"scopes", "status", "created_at"):
|
|
616
|
+
print(f" {k}: {a.get(k)}")
|
|
617
|
+
return 0
|
|
618
|
+
|
|
619
|
+
if action in ("suspend", "resume", "remove"):
|
|
620
|
+
if not app_id:
|
|
621
|
+
print(f"Usage: ecosystem partner {action} <app_id>")
|
|
622
|
+
return 1
|
|
623
|
+
if action == "remove":
|
|
624
|
+
ok = store.remove(app_id)
|
|
625
|
+
else:
|
|
626
|
+
ok = store.set_status(app_id, "suspended" if action == "suspend" else "approved")
|
|
627
|
+
if not ok:
|
|
628
|
+
print(f"No such partner app: {app_id}")
|
|
629
|
+
return 1
|
|
630
|
+
past = {"remove": "removed", "suspend": "suspended", "resume": "resumed"}[action]
|
|
631
|
+
print(f"Partner app '{app_id}' {past}.")
|
|
632
|
+
return 0
|
|
633
|
+
|
|
634
|
+
print(f"Unknown partner action: {action} (use add|list|show|suspend|resume|remove)")
|
|
635
|
+
return 1
|