neruva-control 0.1.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.
- neruva_control/__init__.py +22 -0
- neruva_control/_cli.py +163 -0
- neruva_control/_config.py +85 -0
- neruva_control/_install.py +243 -0
- neruva_control/_projects.py +167 -0
- neruva_control/_recorder.py +196 -0
- neruva_control/_sessions.py +329 -0
- neruva_control/daemon.py +503 -0
- neruva_control/py.typed +0 -0
- neruva_control-0.1.0.dist-info/METADATA +103 -0
- neruva_control-0.1.0.dist-info/RECORD +15 -0
- neruva_control-0.1.0.dist-info/WHEEL +5 -0
- neruva_control-0.1.0.dist-info/entry_points.txt +3 -0
- neruva_control-0.1.0.dist-info/licenses/LICENSE +21 -0
- neruva_control-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Neruva Control -- the local daemon behind Neruva Cockpit.
|
|
2
|
+
|
|
3
|
+
The browser at app.neruva.io connects to this daemon over a loopback
|
|
4
|
+
WebSocket (localhost:7331) to spawn and tail Claude Code sessions on
|
|
5
|
+
the user's machine. Every session's events are auto-recorded into the
|
|
6
|
+
Neruva substrate so the agent has a brain that survives across runs.
|
|
7
|
+
|
|
8
|
+
Install: ``pip install neruva-control && neruva-control-install``
|
|
9
|
+
|
|
10
|
+
After install, open https://app.neruva.io/cockpit -- the install
|
|
11
|
+
script prints a one-time URL with your machine's auth token.
|
|
12
|
+
|
|
13
|
+
Public surface for library users (rare; most users only need the CLI):
|
|
14
|
+
|
|
15
|
+
from neruva_control import daemon # FastAPI app
|
|
16
|
+
from neruva_control._sessions import SessionManager
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = ["__version__"]
|
neruva_control/_cli.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""CLI entry-point for `neruva-control`.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
start -- run the daemon in the foreground (blocks). Used by the OS
|
|
5
|
+
service registration. Power users can run it manually.
|
|
6
|
+
stop -- find a running daemon and SIGTERM it.
|
|
7
|
+
status -- print health: token present? daemon listening? PID?
|
|
8
|
+
link -- print the fragment URL again (for re-link or new browser).
|
|
9
|
+
serve -- alias for start (matches `uvicorn` muscle memory).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import signal
|
|
15
|
+
import socket
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from ._config import config_dir, cockpit_link_url, load_token, token_path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
PORT = 7331
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_listening(host: str = "127.0.0.1", port: int = PORT, timeout: float = 0.5) -> bool:
|
|
28
|
+
"""True if something is bound to host:port. Used by `status` and by
|
|
29
|
+
install's "is the service already up" check."""
|
|
30
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
31
|
+
s.settimeout(timeout)
|
|
32
|
+
try:
|
|
33
|
+
s.connect((host, port))
|
|
34
|
+
s.close()
|
|
35
|
+
return True
|
|
36
|
+
except OSError:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cmd_start(args: argparse.Namespace) -> int:
|
|
41
|
+
from .daemon import serve
|
|
42
|
+
host = args.bind or "127.0.0.1"
|
|
43
|
+
port = args.port or PORT
|
|
44
|
+
if host != "127.0.0.1":
|
|
45
|
+
print(
|
|
46
|
+
f"[neruva-control] WARNING: binding on {host}:{port} -- this exposes the daemon\n"
|
|
47
|
+
f" beyond loopback. The auth token is the only thing protecting the API.\n"
|
|
48
|
+
f" Recommended for remote access: Tailscale Serve over loopback instead.\n",
|
|
49
|
+
file=sys.stderr,
|
|
50
|
+
)
|
|
51
|
+
serve(host=host, port=port)
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _cmd_status(_args: argparse.Namespace) -> int:
|
|
56
|
+
has_token = token_path().exists()
|
|
57
|
+
listening = _is_listening()
|
|
58
|
+
print(f"neruva-control {__version__}")
|
|
59
|
+
print(f" config dir : {config_dir()}")
|
|
60
|
+
print(f" token : {'present' if has_token else 'MISSING (run neruva-control-install)'}")
|
|
61
|
+
print(f" daemon : {'listening on 127.0.0.1:' + str(PORT) if listening else 'NOT running'}")
|
|
62
|
+
if has_token:
|
|
63
|
+
try:
|
|
64
|
+
token = load_token()
|
|
65
|
+
print(f" link URL : {cockpit_link_url(token)}")
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
return 0 if (has_token and listening) else 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cmd_link(_args: argparse.Namespace) -> int:
|
|
72
|
+
try:
|
|
73
|
+
token = load_token()
|
|
74
|
+
except FileNotFoundError as e:
|
|
75
|
+
print(str(e), file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
print()
|
|
78
|
+
print("Open this URL in your browser to link this machine to Cockpit:")
|
|
79
|
+
print()
|
|
80
|
+
print(f" {cockpit_link_url(token)}")
|
|
81
|
+
print()
|
|
82
|
+
print("(Token will be stashed in your browser's localStorage; the URL")
|
|
83
|
+
print(" fragment is stripped after first load. Same machine, same browser.)")
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _cmd_stop(_args: argparse.Namespace) -> int:
|
|
88
|
+
"""Best-effort daemon stop. Reads PID from pidfile if present;
|
|
89
|
+
falls back to telling user to kill it themselves on weird OSes."""
|
|
90
|
+
pidfile = config_dir() / "control.pid"
|
|
91
|
+
if not pidfile.exists():
|
|
92
|
+
print("No pidfile -- daemon not started by neruva-control or already stopped.")
|
|
93
|
+
return 0 if not _is_listening() else 1
|
|
94
|
+
try:
|
|
95
|
+
pid = int(pidfile.read_text().strip())
|
|
96
|
+
except Exception:
|
|
97
|
+
print(f"Bad pidfile at {pidfile}; remove it manually.", file=sys.stderr)
|
|
98
|
+
return 1
|
|
99
|
+
try:
|
|
100
|
+
if sys.platform == "win32":
|
|
101
|
+
import ctypes
|
|
102
|
+
PROCESS_TERMINATE = 1
|
|
103
|
+
handle = ctypes.windll.kernel32.OpenProcess(PROCESS_TERMINATE, False, pid)
|
|
104
|
+
if handle:
|
|
105
|
+
ctypes.windll.kernel32.TerminateProcess(handle, 0)
|
|
106
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
107
|
+
else:
|
|
108
|
+
import os as _os
|
|
109
|
+
_os.kill(pid, signal.SIGTERM)
|
|
110
|
+
# Wait briefly for socket to free
|
|
111
|
+
for _ in range(20):
|
|
112
|
+
if not _is_listening():
|
|
113
|
+
break
|
|
114
|
+
time.sleep(0.1)
|
|
115
|
+
try: pidfile.unlink()
|
|
116
|
+
except Exception: pass
|
|
117
|
+
print(f"Stopped daemon (pid {pid}).")
|
|
118
|
+
return 0
|
|
119
|
+
except ProcessLookupError:
|
|
120
|
+
print("Daemon process already gone.")
|
|
121
|
+
try: pidfile.unlink()
|
|
122
|
+
except Exception: pass
|
|
123
|
+
return 0
|
|
124
|
+
except Exception as e:
|
|
125
|
+
print(f"Failed to stop pid {pid}: {e}", file=sys.stderr)
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main(argv: list[str] | None = None) -> int:
|
|
130
|
+
parser = argparse.ArgumentParser(
|
|
131
|
+
prog="neruva-control",
|
|
132
|
+
description="Local controller daemon for Neruva Cockpit.",
|
|
133
|
+
)
|
|
134
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
135
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
136
|
+
p_start = sub.add_parser("start", help="Run the daemon (foreground; blocks).")
|
|
137
|
+
p_start.add_argument("--bind", default=None,
|
|
138
|
+
help="Bind address (default 127.0.0.1, loopback only). Pass "
|
|
139
|
+
"0.0.0.0 to expose on LAN -- only do this if you trust "
|
|
140
|
+
"your network. For remote access prefer Tailscale Serve.")
|
|
141
|
+
p_start.add_argument("--port", type=int, default=None, help=f"Port (default {PORT})")
|
|
142
|
+
p_serve = sub.add_parser("serve", help="Alias for start.")
|
|
143
|
+
p_serve.add_argument("--bind", default=None)
|
|
144
|
+
p_serve.add_argument("--port", type=int, default=None)
|
|
145
|
+
sub.add_parser("status", help="Show install + daemon status.")
|
|
146
|
+
sub.add_parser("link", help="Print the browser link URL.")
|
|
147
|
+
sub.add_parser("stop", help="Stop the daemon.")
|
|
148
|
+
args = parser.parse_args(argv)
|
|
149
|
+
handler = {
|
|
150
|
+
"start": _cmd_start,
|
|
151
|
+
"serve": _cmd_start,
|
|
152
|
+
"status": _cmd_status,
|
|
153
|
+
"link": _cmd_link,
|
|
154
|
+
"stop": _cmd_stop,
|
|
155
|
+
}.get(args.cmd)
|
|
156
|
+
if not handler:
|
|
157
|
+
parser.print_help()
|
|
158
|
+
return 2
|
|
159
|
+
return handler(args)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
sys.exit(main())
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Config + token I/O for neruva-control.
|
|
2
|
+
|
|
3
|
+
Single source of truth for where files live (cross-platform via
|
|
4
|
+
platformdirs). The token is the shared secret between the daemon
|
|
5
|
+
process and the browser at app.neruva.io -- generated once at
|
|
6
|
+
install, never rotated unless the user explicitly re-installs.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from platformdirs import user_config_dir
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
CONFIG_APP = "neruva"
|
|
17
|
+
CONFIG_AUTHOR = "neruva"
|
|
18
|
+
|
|
19
|
+
# Default substrate base. Overridable via NERUVA_API_BASE env (or future
|
|
20
|
+
# control.toml setting) for self-hosted / staging.
|
|
21
|
+
DEFAULT_API_BASE = "https://api.neruva.io"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def neruva_api_base() -> str:
|
|
25
|
+
return os.environ.get("NERUVA_API_BASE", DEFAULT_API_BASE)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def neruva_api_key() -> str | None:
|
|
29
|
+
"""User's Neruva API key for posting recorded events. Read from env
|
|
30
|
+
first, then ~/.config/neruva/api.key as a stable per-machine option.
|
|
31
|
+
Returns None if unset -- daemon then runs in 'no-record' mode."""
|
|
32
|
+
k = os.environ.get("NERUVA_API_KEY")
|
|
33
|
+
if k:
|
|
34
|
+
return k.strip()
|
|
35
|
+
p = config_dir() / "api.key"
|
|
36
|
+
if p.exists():
|
|
37
|
+
try:
|
|
38
|
+
return p.read_text(encoding="utf-8").strip() or None
|
|
39
|
+
except Exception:
|
|
40
|
+
return None
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def config_dir() -> Path:
|
|
45
|
+
p = Path(user_config_dir(CONFIG_APP, CONFIG_AUTHOR))
|
|
46
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
return p
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def token_path() -> Path:
|
|
51
|
+
return config_dir() / "control.token"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def config_path() -> Path:
|
|
55
|
+
return config_dir() / "control.toml"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_token() -> str:
|
|
59
|
+
"""Read the shared token. Raises FileNotFoundError if not installed."""
|
|
60
|
+
p = token_path()
|
|
61
|
+
if not p.exists():
|
|
62
|
+
raise FileNotFoundError(
|
|
63
|
+
f"No token at {p}. Run `neruva-control-install` first."
|
|
64
|
+
)
|
|
65
|
+
return p.read_text(encoding="utf-8").strip()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_token(token: str) -> Path:
|
|
69
|
+
"""Write a new token (overwrite). Returns the path."""
|
|
70
|
+
p = token_path()
|
|
71
|
+
p.write_text(token, encoding="utf-8")
|
|
72
|
+
# chmod 600 on Unix; on Windows ACLs already restrict to user
|
|
73
|
+
if os.name == "posix":
|
|
74
|
+
os.chmod(p, 0o600)
|
|
75
|
+
return p
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def gen_token() -> str:
|
|
79
|
+
return secrets.token_urlsafe(32)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cockpit_link_url(token: str, base: str = "https://app.neruva.io") -> str:
|
|
83
|
+
"""Fragment URL the installer prints. Browser reads #token=..., stashes
|
|
84
|
+
in localStorage, then strips via history.replaceState."""
|
|
85
|
+
return f"{base}/cockpit#token={token}"
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""`neruva-control-install` -- the one-shot install command.
|
|
2
|
+
|
|
3
|
+
Goals (Kyle: "make sure UX is world class"):
|
|
4
|
+
- User runs `pip install neruva-control && neruva-control-install`
|
|
5
|
+
- We generate a token, write config, register an OS service, start
|
|
6
|
+
the daemon, and print a clickable URL with the token in the
|
|
7
|
+
fragment so the browser can link in one click.
|
|
8
|
+
- No flags needed in the common path. `--help` documents the rare
|
|
9
|
+
overrides (--no-service, --reinstall, --port).
|
|
10
|
+
|
|
11
|
+
Cross-platform service registration:
|
|
12
|
+
- macOS : ~/Library/LaunchAgents/io.neruva.control.plist + launchctl load
|
|
13
|
+
- Linux : ~/.config/systemd/user/neruva-control.service + systemctl --user
|
|
14
|
+
- Windows: schtasks /Create with ONLOGON trigger + immediate start
|
|
15
|
+
|
|
16
|
+
If service registration fails (rootless containers, weird init systems),
|
|
17
|
+
we degrade to "you're set up, but you'll need to run `neruva-control
|
|
18
|
+
start` yourself in a background shell".
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import socket
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import textwrap
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from ._config import config_dir, gen_token, write_token, cockpit_link_url, token_path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
PORT = 7331
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Cross-platform service registration
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def _python_exe() -> str:
|
|
43
|
+
return sys.executable or "python"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _start_cmd() -> list[str]:
|
|
47
|
+
"""The command that runs the daemon foreground. Used by service
|
|
48
|
+
registration on every OS."""
|
|
49
|
+
return [_python_exe(), "-m", "neruva_control._cli", "start"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _install_macos() -> tuple[bool, str]:
|
|
53
|
+
label = "io.neruva.control"
|
|
54
|
+
plist_dir = Path.home() / "Library" / "LaunchAgents"
|
|
55
|
+
plist_dir.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
plist = plist_dir / f"{label}.plist"
|
|
57
|
+
cmd_args = "".join(f" <string>{a}</string>\n" for a in _start_cmd())
|
|
58
|
+
plist.write_text(textwrap.dedent(f"""\
|
|
59
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
60
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
61
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
62
|
+
<plist version="1.0">
|
|
63
|
+
<dict>
|
|
64
|
+
<key>Label</key><string>{label}</string>
|
|
65
|
+
<key>ProgramArguments</key>
|
|
66
|
+
<array>
|
|
67
|
+
{cmd_args} </array>
|
|
68
|
+
<key>RunAtLoad</key><true/>
|
|
69
|
+
<key>KeepAlive</key><true/>
|
|
70
|
+
<key>StandardOutPath</key>
|
|
71
|
+
<string>{config_dir()}/daemon.log</string>
|
|
72
|
+
<key>StandardErrorPath</key>
|
|
73
|
+
<string>{config_dir()}/daemon.err</string>
|
|
74
|
+
</dict>
|
|
75
|
+
</plist>
|
|
76
|
+
"""), encoding="utf-8")
|
|
77
|
+
try:
|
|
78
|
+
# Unload first in case we're re-installing
|
|
79
|
+
subprocess.run(["launchctl", "unload", str(plist)], capture_output=True)
|
|
80
|
+
subprocess.run(["launchctl", "load", str(plist)], check=True, capture_output=True)
|
|
81
|
+
return True, f"launchd: loaded {plist}"
|
|
82
|
+
except Exception as e:
|
|
83
|
+
return False, f"launchctl load failed: {e}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _install_linux() -> tuple[bool, str]:
|
|
87
|
+
unit_dir = Path.home() / ".config" / "systemd" / "user"
|
|
88
|
+
unit_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
unit = unit_dir / "neruva-control.service"
|
|
90
|
+
exec_line = " ".join(_start_cmd())
|
|
91
|
+
unit.write_text(textwrap.dedent(f"""\
|
|
92
|
+
[Unit]
|
|
93
|
+
Description=Neruva Control daemon (Cockpit local controller)
|
|
94
|
+
After=network.target
|
|
95
|
+
|
|
96
|
+
[Service]
|
|
97
|
+
ExecStart={exec_line}
|
|
98
|
+
Restart=always
|
|
99
|
+
RestartSec=3
|
|
100
|
+
Environment=PYTHONUNBUFFERED=1
|
|
101
|
+
|
|
102
|
+
[Install]
|
|
103
|
+
WantedBy=default.target
|
|
104
|
+
"""), encoding="utf-8")
|
|
105
|
+
if not shutil.which("systemctl"):
|
|
106
|
+
return False, "systemctl not found; skip auto-service. Run `neruva-control start` manually."
|
|
107
|
+
try:
|
|
108
|
+
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True, capture_output=True)
|
|
109
|
+
subprocess.run(["systemctl", "--user", "enable", "--now", "neruva-control.service"],
|
|
110
|
+
check=True, capture_output=True)
|
|
111
|
+
return True, f"systemd-user: enabled {unit}"
|
|
112
|
+
except Exception as e:
|
|
113
|
+
return False, f"systemctl --user enable failed: {e}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _install_windows() -> tuple[bool, str]:
|
|
117
|
+
"""Use schtasks to register an ONLOGON task. Avoids needing admin."""
|
|
118
|
+
task_name = "NeruvaControl"
|
|
119
|
+
cmd_str = " ".join(f'"{a}"' for a in _start_cmd())
|
|
120
|
+
try:
|
|
121
|
+
# Delete any prior task silently
|
|
122
|
+
subprocess.run(["schtasks", "/Delete", "/TN", task_name, "/F"],
|
|
123
|
+
capture_output=True)
|
|
124
|
+
# Create on-logon task that runs in current session
|
|
125
|
+
subprocess.run([
|
|
126
|
+
"schtasks", "/Create", "/TN", task_name,
|
|
127
|
+
"/SC", "ONLOGON",
|
|
128
|
+
"/TR", cmd_str,
|
|
129
|
+
"/RL", "LIMITED",
|
|
130
|
+
"/F",
|
|
131
|
+
], check=True, capture_output=True)
|
|
132
|
+
# Run it now so user doesn't need to log out
|
|
133
|
+
subprocess.run(["schtasks", "/Run", "/TN", task_name],
|
|
134
|
+
check=True, capture_output=True)
|
|
135
|
+
return True, f"Task Scheduler: registered task '{task_name}'"
|
|
136
|
+
except FileNotFoundError:
|
|
137
|
+
return False, "schtasks not on PATH; skipping auto-service"
|
|
138
|
+
except subprocess.CalledProcessError as e:
|
|
139
|
+
msg = (e.stderr or b"").decode(errors="replace").strip()
|
|
140
|
+
return False, f"schtasks /Create failed: {msg or e}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _install_service() -> tuple[bool, str]:
|
|
144
|
+
if sys.platform == "darwin":
|
|
145
|
+
return _install_macos()
|
|
146
|
+
if sys.platform == "win32":
|
|
147
|
+
return _install_windows()
|
|
148
|
+
if sys.platform.startswith("linux"):
|
|
149
|
+
return _install_linux()
|
|
150
|
+
return False, f"unsupported platform: {sys.platform}"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _wait_for_daemon(timeout: float = 10.0) -> bool:
|
|
154
|
+
deadline = time.monotonic() + timeout
|
|
155
|
+
while time.monotonic() < deadline:
|
|
156
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
157
|
+
s.settimeout(0.4)
|
|
158
|
+
try:
|
|
159
|
+
s.connect(("127.0.0.1", PORT))
|
|
160
|
+
s.close()
|
|
161
|
+
return True
|
|
162
|
+
except OSError:
|
|
163
|
+
time.sleep(0.3)
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Main
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def main(argv: list[str] | None = None) -> int:
|
|
172
|
+
p = argparse.ArgumentParser(
|
|
173
|
+
prog="neruva-control-install",
|
|
174
|
+
description=(
|
|
175
|
+
"One-shot install for the Neruva Control daemon. "
|
|
176
|
+
"Generates a token, registers a background service, and "
|
|
177
|
+
"prints a one-time URL to link your browser to Cockpit."
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
p.add_argument("--no-service", action="store_true",
|
|
181
|
+
help="Skip OS service registration (run `neruva-control start` yourself).")
|
|
182
|
+
p.add_argument("--reinstall", action="store_true",
|
|
183
|
+
help="Rotate the token even if one already exists.")
|
|
184
|
+
p.add_argument("--base", default="https://app.neruva.io",
|
|
185
|
+
help="Cockpit base URL (default: https://app.neruva.io).")
|
|
186
|
+
args = p.parse_args(argv)
|
|
187
|
+
|
|
188
|
+
print()
|
|
189
|
+
print("Installing Neruva Control...")
|
|
190
|
+
print()
|
|
191
|
+
|
|
192
|
+
# 1. Token
|
|
193
|
+
if token_path().exists() and not args.reinstall:
|
|
194
|
+
from ._config import load_token
|
|
195
|
+
token = load_token()
|
|
196
|
+
print(f" [skip] Token already present at {token_path()}")
|
|
197
|
+
print(f" (re-run with --reinstall to rotate it)")
|
|
198
|
+
else:
|
|
199
|
+
token = gen_token()
|
|
200
|
+
write_token(token)
|
|
201
|
+
print(f" [ok] Token written to {token_path()}")
|
|
202
|
+
|
|
203
|
+
# 2. Service registration
|
|
204
|
+
if args.no_service:
|
|
205
|
+
print(" [skip] Service registration (--no-service set)")
|
|
206
|
+
service_msg = "skipped"
|
|
207
|
+
service_ok = False
|
|
208
|
+
else:
|
|
209
|
+
service_ok, service_msg = _install_service()
|
|
210
|
+
marker = "[ok]" if service_ok else "[warn]"
|
|
211
|
+
print(f" {marker} {service_msg}")
|
|
212
|
+
|
|
213
|
+
# 3. Wait for daemon to come up (if we just registered + started one)
|
|
214
|
+
if service_ok:
|
|
215
|
+
up = _wait_for_daemon()
|
|
216
|
+
if up:
|
|
217
|
+
print(f" [ok] Daemon listening on 127.0.0.1:{PORT}")
|
|
218
|
+
else:
|
|
219
|
+
print(f" [warn] Daemon didn't come up within 10s; check logs at {config_dir()}/daemon.log")
|
|
220
|
+
else:
|
|
221
|
+
print()
|
|
222
|
+
print(f" Auto-service didn't register. Start the daemon manually with:")
|
|
223
|
+
print(f" neruva-control start")
|
|
224
|
+
print(f" (then re-run this command, or run `neruva-control link`)")
|
|
225
|
+
|
|
226
|
+
# 4. Print the fragment URL
|
|
227
|
+
print()
|
|
228
|
+
print("=" * 70)
|
|
229
|
+
print()
|
|
230
|
+
print("Open this URL in your browser to link this machine:")
|
|
231
|
+
print()
|
|
232
|
+
print(f" {cockpit_link_url(token, args.base)}")
|
|
233
|
+
print()
|
|
234
|
+
print("Same machine, same browser. Token is stashed in localStorage; the")
|
|
235
|
+
print("URL fragment is stripped after first load. Bookmark app.neruva.io/cockpit.")
|
|
236
|
+
print()
|
|
237
|
+
print("=" * 70)
|
|
238
|
+
print()
|
|
239
|
+
return 0 if service_ok or args.no_service else 1
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
sys.exit(main())
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Projects -- the unit of organization in Cockpit.
|
|
2
|
+
|
|
3
|
+
A Project bundles:
|
|
4
|
+
- A name (also the records substrate namespace)
|
|
5
|
+
- A working directory on disk (the cwd for spawned sessions)
|
|
6
|
+
- A list of sessions (active + recent)
|
|
7
|
+
- A list of "pinned" files the user has attached to context
|
|
8
|
+
|
|
9
|
+
Persisted at ``~/.config/neruva/projects.json``. Single-user single-
|
|
10
|
+
machine for v1; multi-machine sync is post-v1 (decision 23238 #11).
|
|
11
|
+
|
|
12
|
+
Default project: created lazily on first session spawn if none exist.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import secrets
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import asdict, dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from ._config import config_dir
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_LOCK = threading.Lock()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Project:
|
|
34
|
+
id: str
|
|
35
|
+
name: str
|
|
36
|
+
path: str # absolute working directory
|
|
37
|
+
created_at_ms: int
|
|
38
|
+
last_active_ms: Optional[int] = None
|
|
39
|
+
pinned_files: list[str] = field(default_factory=list) # paths relative to project path
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def namespace(self) -> str:
|
|
43
|
+
"""Records-substrate namespace. Slug of name; safe for URLs."""
|
|
44
|
+
s = re.sub(r"[^a-z0-9_-]+", "-", self.name.lower()).strip("-")
|
|
45
|
+
return s or self.id
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _projects_path() -> Path:
|
|
49
|
+
return config_dir() / "projects.json"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_all() -> list[Project]:
|
|
53
|
+
p = _projects_path()
|
|
54
|
+
if not p.exists():
|
|
55
|
+
return []
|
|
56
|
+
try:
|
|
57
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
58
|
+
return [Project(**d) for d in data.get("projects", [])]
|
|
59
|
+
except Exception:
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def save_all(projects: list[Project]) -> None:
|
|
64
|
+
p = _projects_path()
|
|
65
|
+
p.write_text(
|
|
66
|
+
json.dumps({"projects": [asdict(pr) for pr in projects]}, indent=2),
|
|
67
|
+
encoding="utf-8",
|
|
68
|
+
)
|
|
69
|
+
if os.name == "posix":
|
|
70
|
+
os.chmod(p, 0o600)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get(project_id: str) -> Optional[Project]:
|
|
74
|
+
for pr in load_all():
|
|
75
|
+
if pr.id == project_id:
|
|
76
|
+
return pr
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_by_name(name: str) -> Optional[Project]:
|
|
81
|
+
for pr in load_all():
|
|
82
|
+
if pr.name == name:
|
|
83
|
+
return pr
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def create(name: str, path: Optional[str] = None) -> Project:
|
|
88
|
+
"""Create a new project. Path defaults to ~/.neruva/<name>/ which is
|
|
89
|
+
auto-created. If user passes a real existing dir, we use it as-is."""
|
|
90
|
+
with _LOCK:
|
|
91
|
+
all_p = load_all()
|
|
92
|
+
if any(p.name == name for p in all_p):
|
|
93
|
+
raise ValueError(f"project '{name}' already exists")
|
|
94
|
+
if not path:
|
|
95
|
+
base = Path.home() / ".neruva" / "projects" / re.sub(r"[^a-zA-Z0-9_-]+", "-", name)
|
|
96
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
path = str(base.resolve())
|
|
98
|
+
else:
|
|
99
|
+
p = Path(path).expanduser().resolve()
|
|
100
|
+
if not p.exists():
|
|
101
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
path = str(p)
|
|
103
|
+
proj = Project(
|
|
104
|
+
id="p_" + secrets.token_hex(6),
|
|
105
|
+
name=name,
|
|
106
|
+
path=path,
|
|
107
|
+
created_at_ms=int(time.time() * 1000),
|
|
108
|
+
)
|
|
109
|
+
all_p.append(proj)
|
|
110
|
+
save_all(all_p)
|
|
111
|
+
return proj
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def delete(project_id: str) -> bool:
|
|
115
|
+
"""Remove project entry (does NOT delete files on disk)."""
|
|
116
|
+
with _LOCK:
|
|
117
|
+
all_p = load_all()
|
|
118
|
+
before = len(all_p)
|
|
119
|
+
all_p = [p for p in all_p if p.id != project_id]
|
|
120
|
+
if len(all_p) == before:
|
|
121
|
+
return False
|
|
122
|
+
save_all(all_p)
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def touch(project_id: str) -> None:
|
|
127
|
+
"""Mark project as active (updates last_active_ms)."""
|
|
128
|
+
with _LOCK:
|
|
129
|
+
all_p = load_all()
|
|
130
|
+
for p in all_p:
|
|
131
|
+
if p.id == project_id:
|
|
132
|
+
p.last_active_ms = int(time.time() * 1000)
|
|
133
|
+
save_all(all_p)
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_or_create_default() -> Project:
|
|
138
|
+
"""Return an existing 'default' project or create one. Used when a
|
|
139
|
+
session is spawned without an explicit project."""
|
|
140
|
+
existing = get_by_name("default")
|
|
141
|
+
if existing:
|
|
142
|
+
return existing
|
|
143
|
+
return create("default")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def pin_file(project_id: str, rel_path: str) -> Optional[Project]:
|
|
147
|
+
with _LOCK:
|
|
148
|
+
all_p = load_all()
|
|
149
|
+
for p in all_p:
|
|
150
|
+
if p.id == project_id:
|
|
151
|
+
if rel_path not in p.pinned_files:
|
|
152
|
+
p.pinned_files.append(rel_path)
|
|
153
|
+
save_all(all_p)
|
|
154
|
+
return p
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def unpin_file(project_id: str, rel_path: str) -> Optional[Project]:
|
|
159
|
+
with _LOCK:
|
|
160
|
+
all_p = load_all()
|
|
161
|
+
for p in all_p:
|
|
162
|
+
if p.id == project_id:
|
|
163
|
+
if rel_path in p.pinned_files:
|
|
164
|
+
p.pinned_files.remove(rel_path)
|
|
165
|
+
save_all(all_p)
|
|
166
|
+
return p
|
|
167
|
+
return None
|