pairling 0.0.1 → 0.2.0

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 (61) hide show
  1. package/package.json +5 -1
  2. package/payload/mac/SOURCE_BRANCH +1 -0
  3. package/payload/mac/SOURCE_DIRTY +1 -0
  4. package/payload/mac/SOURCE_REVISION +1 -0
  5. package/payload/mac/VERSION +1 -0
  6. package/payload/mac/companiond/integrations/__init__.py +1 -0
  7. package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
  8. package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
  9. package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
  10. package/payload/mac/companiond/live_activity_publisher.py +380 -0
  11. package/payload/mac/companiond/llm_route.py +108 -0
  12. package/payload/mac/companiond/local_mcp_bridge.py +156 -0
  13. package/payload/mac/companiond/model_status_contract.py +101 -0
  14. package/payload/mac/companiond/pairdrop_store.py +920 -0
  15. package/payload/mac/companiond/pairling_connectd_status.py +149 -0
  16. package/payload/mac/companiond/pairling_devices.py +459 -0
  17. package/payload/mac/companiond/pairling_pairing.py +404 -0
  18. package/payload/mac/companiond/pairling_relay_claims.py +232 -0
  19. package/payload/mac/companiond/pairling_tools.py +706 -0
  20. package/payload/mac/companiond/pairlingd.py +18438 -0
  21. package/payload/mac/companiond/providers/__init__.py +1 -0
  22. package/payload/mac/companiond/providers/base.py +255 -0
  23. package/payload/mac/companiond/providers/claude.py +127 -0
  24. package/payload/mac/companiond/providers/codex.py +124 -0
  25. package/payload/mac/companiond/providers/external.py +46 -0
  26. package/payload/mac/companiond/providers/registry.py +70 -0
  27. package/payload/mac/companiond/pty_broker.py +887 -0
  28. package/payload/mac/companiond/push_dispatcher.py +1990 -0
  29. package/payload/mac/companiond/push_event_catalog.py +566 -0
  30. package/payload/mac/companiond/request_proof.py +142 -0
  31. package/payload/mac/companiond/runtime_contract.py +47 -0
  32. package/payload/mac/companiond/runtime_manifest.py +197 -0
  33. package/payload/mac/companiond/runtime_paths.py +87 -0
  34. package/payload/mac/companiond/safety_monitor.py +542 -0
  35. package/payload/mac/companiond/sentinel_notifications.py +491 -0
  36. package/payload/mac/companiond/standard_push_publisher.py +516 -0
  37. package/payload/mac/companiond/substrate_status_contract.py +139 -0
  38. package/payload/mac/companiond/terminal_screen_backend.py +332 -0
  39. package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
  40. package/payload/mac/companiond/workstate_feed_contract.py +108 -0
  41. package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
  42. package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
  43. package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
  44. package/payload/mac/connectd/go.mod +51 -0
  45. package/payload/mac/connectd/go.sum +229 -0
  46. package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
  47. package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
  48. package/payload/mac/connectd/internal/runtime/config.go +99 -0
  49. package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
  50. package/payload/mac/connectd/internal/status/status.go +300 -0
  51. package/payload/mac/connectd/internal/status/status_test.go +263 -0
  52. package/payload/mac/guardian/companion-power-guardian.py +613 -0
  53. package/payload/mac/guardian/guardian_contract.py +67 -0
  54. package/payload/mac/install/bootstrap-first-run.sh +206 -0
  55. package/payload/mac/install/doctor.sh +694 -0
  56. package/payload/mac/install/install-runtime.sh +1307 -0
  57. package/payload/mac/install/render-launchd.py +119 -0
  58. package/payload/mac/install/uninstall-runtime.sh +136 -0
  59. package/payload/mac/mcp/phone_tools.py +210 -0
  60. package/payload/mac/packaging/bin/pairling +63 -0
  61. package/payload-manifest.json +255 -0
@@ -0,0 +1,1307 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ export PYTHONDONTWRITEBYTECODE=1
5
+ if [[ -z "${PYTHONPYCACHEPREFIX:-}" ]]; then
6
+ PYTHONPYCACHEPREFIX="${TMPDIR:-/tmp}/pairling-pycache-$(id -u)"
7
+ mkdir -p "$PYTHONPYCACHEPREFIX" 2>/dev/null || true
8
+ export PYTHONPYCACHEPREFIX
9
+ fi
10
+
11
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
12
+ VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/mac/VERSION")"
13
+ read_source_stamp() {
14
+ local path="$1"
15
+ if [[ -f "$path" ]]; then
16
+ tr -d '[:space:]' < "$path"
17
+ fi
18
+ }
19
+ REVISION="${PAIRLING_SOURCE_REVISION:-$(read_source_stamp "$REPO_ROOT/mac/SOURCE_REVISION")}"
20
+ if [[ -z "$REVISION" ]]; then
21
+ REVISION="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || true)"
22
+ fi
23
+ REVISION="${REVISION:-unknown}"
24
+ BRANCH="${PAIRLING_SOURCE_BRANCH:-$(read_source_stamp "$REPO_ROOT/mac/SOURCE_BRANCH")}"
25
+ if [[ -z "$BRANCH" ]]; then
26
+ BRANCH="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
27
+ fi
28
+ BRANCH="${BRANCH:-unknown}"
29
+ PACKAGED_SOURCE_PATHS=(
30
+ "mac/VERSION"
31
+ "mac/companiond"
32
+ "mac/connectd/cmd"
33
+ "mac/connectd/internal"
34
+ "mac/connectd/go.mod"
35
+ "mac/connectd/go.sum"
36
+ "mac/guardian"
37
+ "mac/install"
38
+ "mac/mcp"
39
+ )
40
+ SOURCE_DIRTY="${PAIRLING_SOURCE_DIRTY:-$(read_source_stamp "$REPO_ROOT/mac/SOURCE_DIRTY")}"
41
+ if [[ -z "$SOURCE_DIRTY" ]]; then
42
+ SOURCE_DIRTY="false"
43
+ if git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1 && \
44
+ [[ -n "$(git -C "$REPO_ROOT" status --porcelain=v1 --untracked-files=all -- "${PACKAGED_SOURCE_PATHS[@]}" 2>/dev/null)" ]]; then
45
+ SOURCE_DIRTY="true"
46
+ fi
47
+ fi
48
+
49
+ PAIRLING_RUNTIME_PORT="${PAIRLING_RUNTIME_PORT:-7773}"
50
+ PAIRLING_DAEMON_LABEL="dev.pairling.companiond"
51
+ PAIRLING_GUARDIAN_LABEL="dev.pairling.power-guardian"
52
+ PAIRLING_CONNECTD_LABEL="dev.pairling.connectd"
53
+ LEGACY_DAEMON_LABEL="com.mghome.notify-webhook"
54
+ APP_SUPPORT="${PAIRLING_APP_SUPPORT_ROOT:-${COMPANION_APP_SUPPORT_ROOT:-$HOME/Library/Application Support/Pairling}}"
55
+ RUNTIME_ROOT="$APP_SUPPORT/runtime"
56
+ RELEASES_ROOT="$RUNTIME_ROOT/releases"
57
+ STATE_ROOT="$APP_SUPPORT/state"
58
+ PAIR_ROOT="$APP_SUPPORT/pair"
59
+ LOGS_ROOT="${PAIRLING_LOGS_ROOT:-${COMPANION_LOGS_ROOT:-$HOME/Library/Logs/Pairling}}"
60
+ PLIST_BUILD_DIR="$RUNTIME_ROOT/plists"
61
+ CURRENT_LINK="$RUNTIME_ROOT/current"
62
+ PREVIOUS_LINK="$RUNTIME_ROOT/previous"
63
+ RELEASE_NAME="$VERSION-$REVISION"
64
+ RELEASE_ROOT="$RELEASES_ROOT/$RELEASE_NAME"
65
+ CONFIG_FILE="$APP_SUPPORT/config.json"
66
+ DEVICES_DB="$APP_SUPPORT/devices.sqlite"
67
+ MCP_CREDENTIAL="$APP_SUPPORT/mcp-bridge.json"
68
+ INSTALL_HISTORY="$STATE_ROOT/install-history.jsonl"
69
+ USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_DAEMON_LABEL.plist"
70
+ CONNECTD_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_CONNECTD_LABEL.plist"
71
+ LEGACY_USER_PLIST="$HOME/Library/LaunchAgents/$LEGACY_DAEMON_LABEL.plist"
72
+ SYSTEM_PLIST="/Library/LaunchDaemons/$PAIRLING_GUARDIAN_LABEL.plist"
73
+ LEGACY_SYSTEM_PLIST="/Library/LaunchDaemons/com.mghome.companion-power-guardian.plist"
74
+ MCP_SERVER_DIR="$HOME/.claude/mcp-servers"
75
+ MCP_SERVER_SHIM="$MCP_SERVER_DIR/phone-tools.py"
76
+ PYTHON3_BIN="${PAIRLING_DAEMON_PYTHON:-${COMPANION_DAEMON_PYTHON:-$(command -v python3)}}"
77
+ GUARDIAN_PYTHON_BIN="${PAIRLING_GUARDIAN_PYTHON:-${COMPANION_GUARDIAN_PYTHON:-/usr/bin/python3}}"
78
+ # P3 Python custody: the npm shim points PAIRLING_DAEMON_PYTHON at the vendored
79
+ # CPython inside the platform runtime package (…/python/bin/python3). When that
80
+ # is in play we stage the whole interpreter into the release tree and run the
81
+ # daemon under it, so a Pairling-signed python (identity dev.pairling.python),
82
+ # not a generic system python3, owns the daemon's TCC grants — and npm churn
83
+ # can't remove the running interpreter.
84
+ PYTHON_CODESIGN_IDENTIFIER="dev.pairling.python"
85
+ DRY_RUN="${PAIRLING_DRY_RUN:-0}"
86
+
87
+ log() {
88
+ printf '%s\n' "$*"
89
+ }
90
+
91
+ is_dry_run() {
92
+ [[ "$DRY_RUN" == "1" || "$DRY_RUN" == "true" || "$DRY_RUN" == "TRUE" ]]
93
+ }
94
+
95
+ append_history() {
96
+ local status="$1"
97
+ local detail="$2"
98
+ mkdir -p "$STATE_ROOT"
99
+ python3 - "$INSTALL_HISTORY" "$status" "$detail" "$VERSION" "$REVISION" "$RELEASE_ROOT" <<'PY'
100
+ import json
101
+ import sys
102
+ import time
103
+ path, status, detail, version, revision, release_root = sys.argv[1:]
104
+ row = {
105
+ "ts": time.time(),
106
+ "status": status,
107
+ "detail": detail,
108
+ "runtime_version": version,
109
+ "source_revision": revision,
110
+ "release_root": release_root,
111
+ }
112
+ with open(path, "a") as fh:
113
+ fh.write(json.dumps(row, sort_keys=True) + "\n")
114
+ PY
115
+ }
116
+
117
+ run_compile_checks() {
118
+ local pycache_root
119
+ pycache_root="$(mktemp -d)"
120
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairlingd.py"
121
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/runtime_contract.py"
122
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/runtime_manifest.py"
123
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/runtime_paths.py"
124
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairdrop_store.py"
125
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairling_connectd_status.py"
126
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairling_devices.py"
127
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/local_mcp_bridge.py"
128
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/llm_route.py"
129
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairling_tools.py"
130
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairling_pairing.py"
131
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairling_relay_claims.py"
132
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/request_proof.py"
133
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pty_broker.py"
134
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/terminal_screen_backend.py"
135
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/terminal_text_sanitizer.py"
136
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/push_dispatcher.py"
137
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/push_event_catalog.py"
138
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/live_activity_publisher.py"
139
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/standard_push_publisher.py"
140
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/safety_monitor.py"
141
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/sentinel_notifications.py"
142
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/workstate_feed_contract.py"
143
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/model_status_contract.py"
144
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/substrate_status_contract.py"
145
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/integrations/__init__.py"
146
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/integrations/aperture_cli/__init__.py"
147
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/integrations/aperture_cli/launch.py"
148
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/integrations/aperture_cli/status.py"
149
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/providers/__init__.py"
150
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/providers/base.py"
151
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/providers/claude.py"
152
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/providers/codex.py"
153
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/providers/external.py"
154
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/providers/registry.py"
155
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/mcp/phone_tools.py"
156
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/guardian/companion-power-guardian.py"
157
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/guardian/guardian_contract.py"
158
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/install/render-launchd.py"
159
+ rm -rf "$pycache_root"
160
+ }
161
+
162
+ ensure_state() {
163
+ mkdir -p "$RELEASES_ROOT" "$STATE_ROOT" "$PAIR_ROOT" "$LOGS_ROOT" "$PLIST_BUILD_DIR" "$APP_SUPPORT/modules"
164
+ chmod 700 "$APP_SUPPORT" "$PAIR_ROOT" 2>/dev/null || true
165
+ if [[ ! -f "$CONFIG_FILE" ]]; then
166
+ python3 - "$CONFIG_FILE" "$PAIRLING_RUNTIME_PORT" <<'PY'
167
+ import json
168
+ import secrets
169
+ import sys
170
+ from datetime import datetime, timezone
171
+ path, port = sys.argv[1:]
172
+ payload = {
173
+ "schema_version": 1,
174
+ "product": "Pairling",
175
+ "install_id": "inst_" + secrets.token_urlsafe(18),
176
+ "runtime": {
177
+ "label": "dev.pairling.companiond",
178
+ "port": int(port),
179
+ },
180
+ "created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
181
+ }
182
+ with open(path, "w") as fh:
183
+ json.dump(payload, fh, indent=2, sort_keys=True)
184
+ fh.write("\n")
185
+ PY
186
+ chmod 600 "$CONFIG_FILE" 2>/dev/null || true
187
+ fi
188
+ python3 - "$DEVICES_DB" <<'PY'
189
+ import sqlite3
190
+ import sys
191
+ path = sys.argv[1]
192
+ with sqlite3.connect(path) as db:
193
+ db.executescript("""
194
+ CREATE TABLE IF NOT EXISTS devices (
195
+ device_id TEXT PRIMARY KEY,
196
+ device_name TEXT NOT NULL,
197
+ token_hash TEXT NOT NULL UNIQUE,
198
+ scopes_json TEXT NOT NULL,
199
+ install_id TEXT NOT NULL,
200
+ created_at REAL NOT NULL,
201
+ last_seen_at REAL,
202
+ revoked_at REAL
203
+ );
204
+ CREATE INDEX IF NOT EXISTS idx_devices_token_hash ON devices(token_hash);
205
+ CREATE TABLE IF NOT EXISTS audit_events (
206
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
207
+ ts REAL NOT NULL,
208
+ event TEXT NOT NULL,
209
+ device_id TEXT,
210
+ outcome TEXT NOT NULL,
211
+ path TEXT,
212
+ detail_json TEXT NOT NULL
213
+ );
214
+ """)
215
+ PY
216
+ chmod 600 "$DEVICES_DB" 2>/dev/null || true
217
+ PAIRLING_APP_SUPPORT_ROOT="$APP_SUPPORT" PAIRLING_MCP_CREDENTIAL="$MCP_CREDENTIAL" python3 - "$REPO_ROOT" <<'PY'
218
+ import sys
219
+
220
+ repo_root = sys.argv[1]
221
+ sys.path.insert(0, repo_root + "/mac/companiond")
222
+
223
+ from local_mcp_bridge import ensure_local_mcp_bridge_device
224
+
225
+ ensure_local_mcp_bridge_device()
226
+ PY
227
+ }
228
+
229
+ clear_release_quarantine() {
230
+ local target="$1"
231
+ if command -v xattr >/dev/null 2>&1; then
232
+ xattr -dr com.apple.quarantine "$target" >/dev/null 2>&1 || true
233
+ fi
234
+ }
235
+
236
+ copy_release() {
237
+ local tmp="$RELEASE_ROOT.tmp"
238
+ rm -rf "$tmp"
239
+ mkdir -p "$tmp/bin" "$tmp/companiond" "$tmp/companiond/providers" "$tmp/companiond/integrations/aperture_cli" "$tmp/connectd" "$tmp/guardian" "$tmp/mac" "$tmp/mcp"
240
+ cp "$REPO_ROOT/mac/companiond/pairlingd.py" "$tmp/companiond/"
241
+ cp "$REPO_ROOT/mac/companiond/runtime_contract.py" "$tmp/companiond/"
242
+ cp "$REPO_ROOT/mac/companiond/runtime_manifest.py" "$tmp/companiond/"
243
+ cp "$REPO_ROOT/mac/companiond/runtime_paths.py" "$tmp/companiond/"
244
+ cp "$REPO_ROOT/mac/companiond/pairdrop_store.py" "$tmp/companiond/"
245
+ cp "$REPO_ROOT/mac/companiond/pairling_connectd_status.py" "$tmp/companiond/"
246
+ cp "$REPO_ROOT/mac/companiond/pairling_devices.py" "$tmp/companiond/"
247
+ cp "$REPO_ROOT/mac/companiond/local_mcp_bridge.py" "$tmp/companiond/"
248
+ cp "$REPO_ROOT/mac/companiond/llm_route.py" "$tmp/companiond/"
249
+ cp "$REPO_ROOT/mac/companiond/pairling_tools.py" "$tmp/companiond/"
250
+ cp "$REPO_ROOT/mac/companiond/pairling_pairing.py" "$tmp/companiond/"
251
+ cp "$REPO_ROOT/mac/companiond/pairling_relay_claims.py" "$tmp/companiond/"
252
+ cp "$REPO_ROOT/mac/companiond/request_proof.py" "$tmp/companiond/"
253
+ cp "$REPO_ROOT/mac/companiond/pty_broker.py" "$tmp/companiond/"
254
+ cp "$REPO_ROOT/mac/companiond/terminal_screen_backend.py" "$tmp/companiond/"
255
+ cp "$REPO_ROOT/mac/companiond/terminal_text_sanitizer.py" "$tmp/companiond/"
256
+ cp "$REPO_ROOT/mac/companiond/push_dispatcher.py" "$tmp/companiond/"
257
+ cp "$REPO_ROOT/mac/companiond/push_event_catalog.py" "$tmp/companiond/"
258
+ cp "$REPO_ROOT/mac/companiond/live_activity_publisher.py" "$tmp/companiond/"
259
+ cp "$REPO_ROOT/mac/companiond/standard_push_publisher.py" "$tmp/companiond/"
260
+ cp "$REPO_ROOT/mac/companiond/safety_monitor.py" "$tmp/companiond/"
261
+ cp "$REPO_ROOT/mac/companiond/sentinel_notifications.py" "$tmp/companiond/"
262
+ cp "$REPO_ROOT/mac/companiond/workstate_feed_contract.py" "$tmp/companiond/"
263
+ cp "$REPO_ROOT/mac/companiond/model_status_contract.py" "$tmp/companiond/"
264
+ cp "$REPO_ROOT/mac/companiond/substrate_status_contract.py" "$tmp/companiond/"
265
+ cp "$REPO_ROOT/mac/companiond/integrations/__init__.py" "$tmp/companiond/integrations/"
266
+ cp "$REPO_ROOT/mac/companiond/integrations/aperture_cli/"*.py "$tmp/companiond/integrations/aperture_cli/"
267
+ cp "$REPO_ROOT/mac/companiond/providers/"*.py "$tmp/companiond/providers/"
268
+ cp "$REPO_ROOT/mac/mcp/phone_tools.py" "$tmp/mcp/"
269
+ cp "$REPO_ROOT/mac/guardian/companion-power-guardian.py" "$tmp/guardian/"
270
+ cp "$REPO_ROOT/mac/guardian/guardian_contract.py" "$tmp/guardian/"
271
+ build_connectd_binary "$tmp/connectd/pairling-connectd"
272
+ stage_vendored_python "$tmp/python"
273
+ copy_runtime_source_tree "$tmp/mac" "$tmp/connectd/pairling-connectd"
274
+ write_installed_pairling_launcher "$tmp/bin/pairling"
275
+ chmod 755 "$tmp/bin/pairling" "$tmp/companiond/pairlingd.py" "$tmp/mcp/phone_tools.py" "$tmp/guardian/companion-power-guardian.py"
276
+ chmod 755 "$tmp/connectd/pairling-connectd"
277
+ chmod 644 "$tmp/companiond/"*.py "$tmp/mcp/"*.py "$tmp/guardian/"*.py
278
+ chmod 644 "$tmp/companiond/providers/"*.py
279
+ chmod 644 "$tmp/companiond/integrations/"*.py "$tmp/companiond/integrations/aperture_cli/"*.py
280
+ chmod 755 "$tmp/companiond/pairlingd.py" "$tmp/mcp/phone_tools.py" "$tmp/guardian/companion-power-guardian.py"
281
+ clear_release_quarantine "$tmp"
282
+ rm -rf "$RELEASE_ROOT"
283
+ mv "$tmp" "$RELEASE_ROOT"
284
+ write_manifest "$RELEASE_ROOT"
285
+ }
286
+
287
+ copy_runtime_source_tree() {
288
+ local mac_root="$1"
289
+ local connectd_binary="$2"
290
+ mkdir -p \
291
+ "$mac_root/companiond" \
292
+ "$mac_root/companiond/providers" \
293
+ "$mac_root/companiond/integrations/aperture_cli" \
294
+ "$mac_root/connectd/bin" \
295
+ "$mac_root/guardian" \
296
+ "$mac_root/install" \
297
+ "$mac_root/mcp" \
298
+ "$mac_root/packaging/bin"
299
+ cp "$REPO_ROOT/mac/VERSION" "$mac_root/"
300
+ printf '%s\n' "$REVISION" > "$mac_root/SOURCE_REVISION"
301
+ printf '%s\n' "$BRANCH" > "$mac_root/SOURCE_BRANCH"
302
+ printf '%s\n' "$SOURCE_DIRTY" > "$mac_root/SOURCE_DIRTY"
303
+ cp "$REPO_ROOT/mac/companiond/"*.py "$mac_root/companiond/"
304
+ cp "$REPO_ROOT/mac/companiond/providers/"*.py "$mac_root/companiond/providers/"
305
+ cp "$REPO_ROOT/mac/companiond/integrations/__init__.py" "$mac_root/companiond/integrations/"
306
+ cp "$REPO_ROOT/mac/companiond/integrations/aperture_cli/"*.py "$mac_root/companiond/integrations/aperture_cli/"
307
+ cp "$REPO_ROOT/mac/connectd/go.mod" "$mac_root/connectd/"
308
+ cp "$REPO_ROOT/mac/connectd/go.sum" "$mac_root/connectd/"
309
+ cp -R "$REPO_ROOT/mac/connectd/cmd" "$mac_root/connectd/"
310
+ cp -R "$REPO_ROOT/mac/connectd/internal" "$mac_root/connectd/"
311
+ cp "$connectd_binary" "$mac_root/connectd/bin/pairling-connectd"
312
+ cp "$REPO_ROOT/mac/guardian/"*.py "$mac_root/guardian/"
313
+ cp "$REPO_ROOT/mac/install/"*.sh "$mac_root/install/"
314
+ cp "$REPO_ROOT/mac/install/"*.py "$mac_root/install/"
315
+ cp "$REPO_ROOT/mac/mcp/"*.py "$mac_root/mcp/"
316
+ cp "$REPO_ROOT/mac/packaging/bin/pairling" "$mac_root/packaging/bin/"
317
+ chmod 755 "$mac_root/connectd/bin/pairling-connectd" "$mac_root/install/"*.sh "$mac_root/mcp/phone_tools.py" "$mac_root/packaging/bin/pairling"
318
+ chmod 644 "$mac_root/VERSION" "$mac_root/SOURCE_REVISION" "$mac_root/SOURCE_BRANCH" "$mac_root/SOURCE_DIRTY"
319
+ }
320
+
321
+ write_installed_pairling_launcher() {
322
+ local out="$1"
323
+ cat >"$out" <<'SH'
324
+ #!/usr/bin/env bash
325
+ set -euo pipefail
326
+
327
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
328
+ exec "$ROOT/mac/packaging/bin/pairling" "$@"
329
+ SH
330
+ }
331
+
332
+ # Stage the vendored CPython (P3 custody) into the release tree when the npm
333
+ # shim provided one via PAIRLING_DAEMON_PYTHON pointing at …/python/bin/python3.
334
+ # Fail-closed: the interpreter must carry a valid signature, the pinned Team ID,
335
+ # and the dev.pairling.python identifier. On success, repoint PYTHON3_BIN at the
336
+ # STAGED interpreter so the daemon plist never references the npm package path.
337
+ stage_vendored_python() {
338
+ local dest="$1"
339
+ local provided="${PAIRLING_DAEMON_PYTHON:-}"
340
+ # Only act on a vendored interpreter living under a runtime package's python/
341
+ # tree. A bare system python3 (no sibling python/ tree) is left as-is.
342
+ case "$provided" in
343
+ */python/bin/python3) : ;;
344
+ *) return 0 ;;
345
+ esac
346
+ local src_tree
347
+ src_tree="$(cd "$(dirname "$provided")/.." && pwd)"
348
+ if [[ ! -x "$src_tree/bin/python3" ]]; then
349
+ return 0
350
+ fi
351
+ local required_team="${PAIRLING_CONNECTD_TEAM_ID:-965AVD34A3}"
352
+ # Always enforce signature integrity and the dev.pairling.python identity
353
+ # (cert-independent defense in depth). Pin the Apple Team ID unless the dev
354
+ # switch (-) disables that one check for local ad-hoc builds.
355
+ if ! /usr/bin/codesign --verify --strict "$src_tree/bin/python3" >/dev/null 2>&1; then
356
+ log "ERROR: vendored python failed codesign verification; refusing to stage: $src_tree/bin/python3" >&2
357
+ exit 1
358
+ fi
359
+ local team identifier
360
+ identifier="$(/usr/bin/codesign -dvv "$src_tree/bin/python3" 2>&1 | sed -n 's/^Identifier=//p')"
361
+ if [[ "$identifier" != "$PYTHON_CODESIGN_IDENTIFIER" ]]; then
362
+ log "ERROR: vendored python identifier '${identifier:-none}' is not '$PYTHON_CODESIGN_IDENTIFIER'; refusing to stage." >&2
363
+ exit 1
364
+ fi
365
+ if [[ "$required_team" == "-" ]]; then
366
+ log "WARNING: vendored python Team ID pin disabled (PAIRLING_CONNECTD_TEAM_ID=-). Dev builds only."
367
+ else
368
+ team="$(/usr/bin/codesign -dvv "$src_tree/bin/python3" 2>&1 | sed -n 's/^TeamIdentifier=//p')"
369
+ if [[ "$team" != "$required_team" ]]; then
370
+ log "ERROR: vendored python TeamIdentifier '${team:-none}' does not match required '$required_team'; refusing to stage." >&2
371
+ exit 1
372
+ fi
373
+ fi
374
+ rm -rf "$dest"
375
+ mkdir -p "$(dirname "$dest")"
376
+ cp -R "$src_tree" "$dest"
377
+ chmod 755 "$dest/bin/python3" 2>/dev/null || true
378
+ # Point the daemon at the interpreter through the stable `current` symlink
379
+ # (not $dest, which is the pre-move temp path) so the plist resolves after the
380
+ # release is moved into place and after rollback — exactly like connectd.
381
+ PYTHON3_BIN="$CURRENT_LINK/python/bin/python3"
382
+ log "Staged vendored CPython (daemon will run under dev.pairling.python via $PYTHON3_BIN)"
383
+ }
384
+
385
+ build_connectd_binary() {
386
+ local out="$1"
387
+ # npm-delivered binary: the shim points PAIRLING_CONNECTD_PREBUILT at the
388
+ # platform runtime package. This path is fail-closed: the binary must carry
389
+ # a valid signature from the pinned Team ID or setup refuses to stage it.
390
+ local prebuilt_env="${PAIRLING_CONNECTD_PREBUILT:-}"
391
+ if [[ -n "$prebuilt_env" ]]; then
392
+ if [[ ! -f "$prebuilt_env" ]]; then
393
+ log "ERROR: PAIRLING_CONNECTD_PREBUILT points at a missing file: $prebuilt_env" >&2
394
+ exit 1
395
+ fi
396
+ local required_team="${PAIRLING_CONNECTD_TEAM_ID:-965AVD34A3}"
397
+ if [[ "$required_team" == "-" ]]; then
398
+ log "WARNING: connectd signature verification disabled (PAIRLING_CONNECTD_TEAM_ID=-). Dev builds only."
399
+ else
400
+ if ! /usr/bin/codesign --verify --strict "$prebuilt_env" >/dev/null 2>&1; then
401
+ log "ERROR: connectd binary failed codesign verification; refusing to stage: $prebuilt_env" >&2
402
+ exit 1
403
+ fi
404
+ local team
405
+ team="$(/usr/bin/codesign -dvv "$prebuilt_env" 2>&1 | sed -n 's/^TeamIdentifier=//p')"
406
+ if [[ "$team" != "$required_team" ]]; then
407
+ log "ERROR: connectd binary TeamIdentifier '${team:-none}' does not match required '$required_team'; refusing to stage: $prebuilt_env" >&2
408
+ exit 1
409
+ fi
410
+ fi
411
+ cp "$prebuilt_env" "$out"
412
+ chmod 755 "$out"
413
+ return
414
+ fi
415
+ local prebuilt="$REPO_ROOT/mac/connectd/bin/pairling-connectd"
416
+ if [[ -x "$prebuilt" ]]; then
417
+ cp "$prebuilt" "$out"
418
+ chmod 755 "$out"
419
+ return
420
+ fi
421
+ local go_bin
422
+ go_bin="$(command -v go || true)"
423
+ if [[ -z "$go_bin" ]]; then
424
+ for candidate in /opt/homebrew/bin/go /usr/local/go/bin/go /usr/local/bin/go; do
425
+ if [[ -x "$candidate" ]]; then
426
+ go_bin="$candidate"
427
+ break
428
+ fi
429
+ done
430
+ fi
431
+ if [[ -z "$go_bin" ]]; then
432
+ log "ERROR: go is required to build pairling-connectd" >&2
433
+ exit 1
434
+ fi
435
+ (
436
+ cd "$REPO_ROOT/mac/connectd"
437
+ "$go_bin" build -o "$out" ./cmd/pairling-connectd
438
+ )
439
+ }
440
+
441
+ write_manifest() {
442
+ local root="$1"
443
+ python3 - "$REPO_ROOT" "$root" "$VERSION" "$REVISION" "$BRANCH" "$SOURCE_DIRTY" "$APP_SUPPORT" "$LOGS_ROOT" "$DEVICES_DB" "$PAIRLING_RUNTIME_PORT" <<'PY'
444
+ import getpass
445
+ import hashlib
446
+ import json
447
+ import sys
448
+ from datetime import datetime, timezone
449
+ from pathlib import Path
450
+
451
+ repo_root, install_root, version, revision, branch, dirty, app_support, logs_root, devices_db, port = sys.argv[1:]
452
+ root = Path(install_root)
453
+ files = []
454
+ for rel in [
455
+ "bin/pairling",
456
+ "companiond/pairlingd.py",
457
+ "companiond/runtime_contract.py",
458
+ "companiond/runtime_manifest.py",
459
+ "companiond/runtime_paths.py",
460
+ "companiond/pairdrop_store.py",
461
+ "companiond/pairling_connectd_status.py",
462
+ "companiond/pairling_devices.py",
463
+ "companiond/local_mcp_bridge.py",
464
+ "companiond/llm_route.py",
465
+ "companiond/pairling_tools.py",
466
+ "companiond/pairling_pairing.py",
467
+ "companiond/pairling_relay_claims.py",
468
+ "companiond/request_proof.py",
469
+ "companiond/pty_broker.py",
470
+ "companiond/terminal_screen_backend.py",
471
+ "companiond/terminal_text_sanitizer.py",
472
+ "companiond/push_dispatcher.py",
473
+ "companiond/push_event_catalog.py",
474
+ "companiond/live_activity_publisher.py",
475
+ "companiond/standard_push_publisher.py",
476
+ "companiond/safety_monitor.py",
477
+ "companiond/sentinel_notifications.py",
478
+ "companiond/workstate_feed_contract.py",
479
+ "companiond/model_status_contract.py",
480
+ "companiond/substrate_status_contract.py",
481
+ "companiond/integrations/__init__.py",
482
+ "companiond/integrations/aperture_cli/__init__.py",
483
+ "companiond/integrations/aperture_cli/launch.py",
484
+ "companiond/integrations/aperture_cli/status.py",
485
+ "companiond/providers/__init__.py",
486
+ "companiond/providers/base.py",
487
+ "companiond/providers/claude.py",
488
+ "companiond/providers/codex.py",
489
+ "companiond/providers/external.py",
490
+ "companiond/providers/registry.py",
491
+ "connectd/pairling-connectd",
492
+ "mcp/phone_tools.py",
493
+ "guardian/companion-power-guardian.py",
494
+ "guardian/guardian_contract.py",
495
+ ]:
496
+ path = root / rel
497
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
498
+ files.append({"path": rel, "sha256": digest})
499
+
500
+ now = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
501
+ manifest = {
502
+ "schema_version": 1,
503
+ "runtime_name": "pairlingd",
504
+ "runtime_version": version,
505
+ "contract_version": "pairling-runtime-v1",
506
+ "source_revision": revision,
507
+ "source_branch": branch,
508
+ "source_dirty": dirty == "true",
509
+ "built_at": now,
510
+ "installed_at": now,
511
+ "installed_by": getpass.getuser(),
512
+ "repo_path": repo_root,
513
+ "install_root": str(root),
514
+ "current_symlink": str(root.parent.parent / "current"),
515
+ "runtime": {
516
+ "port": int(port),
517
+ "auth": "per-device-scoped-bearer",
518
+ "token_registry": devices_db,
519
+ },
520
+ "launchd": {
521
+ "daemon_label": "dev.pairling.companiond",
522
+ "connectd_label": "dev.pairling.connectd",
523
+ "guardian_label": "dev.pairling.power-guardian",
524
+ "legacy_daemon_label": "com.mghome.notify-webhook",
525
+ },
526
+ "paths": {
527
+ "app_support": app_support,
528
+ "logs": logs_root,
529
+ "pair_records": str(Path(app_support) / "pair"),
530
+ "guardian_state": "/var/run/pairling-power-state.json",
531
+ },
532
+ "migration": {
533
+ "legacy_port": 7723,
534
+ "legacy_daemon_unloaded_by_setup": True,
535
+ "public_v1_dual_bind": False,
536
+ },
537
+ "packaging": {
538
+ "helper_bundle_id": "dev.pairling.helper",
539
+ "homebrew_tap": "pairling-app/tap",
540
+ "homebrew_cask": "pairling-helper",
541
+ },
542
+ "files": files,
543
+ }
544
+ (root / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
545
+ PY
546
+ }
547
+
548
+ switch_current() {
549
+ if [[ -L "$CURRENT_LINK" ]]; then
550
+ local old
551
+ old="$(readlink "$CURRENT_LINK")"
552
+ if [[ -n "$old" ]]; then
553
+ rm -f "$PREVIOUS_LINK"
554
+ ln -s "$old" "$PREVIOUS_LINK"
555
+ fi
556
+ fi
557
+ rm -f "$CURRENT_LINK"
558
+ ln -s "$RELEASE_ROOT" "$CURRENT_LINK"
559
+ }
560
+
561
+ install_mcp_adapter_shim() {
562
+ mkdir -p "$MCP_SERVER_DIR"
563
+ python3 - "$MCP_SERVER_SHIM" "$CURRENT_LINK/mcp/phone_tools.py" <<'PY'
564
+ import os
565
+ import sys
566
+ from pathlib import Path
567
+
568
+ shim = Path(sys.argv[1])
569
+ adapter = Path(sys.argv[2])
570
+ shim.write_text(f'''#!/usr/bin/env python3
571
+ """Installed shim for the Pairling daemon-first phone-tools MCP server."""
572
+
573
+ from __future__ import annotations
574
+
575
+ import runpy
576
+ import sys
577
+ from pathlib import Path
578
+
579
+ PAIRLING_MCP_ADAPTER = Path({str(adapter)!r})
580
+
581
+ if not PAIRLING_MCP_ADAPTER.is_file():
582
+ print(
583
+ f"FATAL: Pairling MCP adapter is missing at {{PAIRLING_MCP_ADAPTER}}. "
584
+ "Run Pairling setup or restore the runtime install.",
585
+ file=sys.stderr,
586
+ )
587
+ raise SystemExit(1)
588
+
589
+ runpy.run_path(str(PAIRLING_MCP_ADAPTER), run_name="__main__")
590
+ ''')
591
+ os.chmod(shim, 0o755)
592
+ PY
593
+ }
594
+
595
+ install_shell_wrapper() {
596
+ local user_bin="${PAIRLING_USER_BIN_DIR:-$HOME/.local/bin}"
597
+ local target="$user_bin/pairling"
598
+ local tmp="$target.tmp"
599
+ mkdir -p "$user_bin"
600
+ cat >"$tmp" <<'SH'
601
+ #!/usr/bin/env bash
602
+ set -euo pipefail
603
+
604
+ if [[ -n "${PAIRLING_REPO_ROOT:-}" ]]; then
605
+ exec "$PAIRLING_REPO_ROOT/mac/packaging/bin/pairling" "$@"
606
+ fi
607
+
608
+ APP_SUPPORT="${PAIRLING_APP_SUPPORT_ROOT:-${COMPANION_APP_SUPPORT_ROOT:-$HOME/Library/Application Support/Pairling}}"
609
+ RUNTIME_PAIRLING="$APP_SUPPORT/runtime/current/bin/pairling"
610
+ if [[ -x "$RUNTIME_PAIRLING" ]]; then
611
+ exec "$RUNTIME_PAIRLING" "$@"
612
+ fi
613
+
614
+ printf 'Pairling runtime command is not installed. Run: npm install -g pairling && pairling setup (or use a repo-local mac/packaging/bin/pairling).\n' >&2
615
+ exit 127
616
+ SH
617
+ chmod 755 "$tmp"
618
+ mv "$tmp" "$target"
619
+ }
620
+
621
+ render_plists() {
622
+ # Prefer the staged vendored interpreter whenever it exists, so start/
623
+ # rollback (which don't re-stage) also run the daemon under dev.pairling.python.
624
+ local daemon_python="$PYTHON3_BIN"
625
+ if [[ -x "$CURRENT_LINK/python/bin/python3" ]]; then
626
+ daemon_python="$CURRENT_LINK/python/bin/python3"
627
+ fi
628
+ python3 "$REPO_ROOT/mac/install/render-launchd.py" \
629
+ --current-root "$CURRENT_LINK" \
630
+ --logs-root "$LOGS_ROOT" \
631
+ --output-dir "$PLIST_BUILD_DIR" \
632
+ --daemon-python "$daemon_python" \
633
+ --guardian-python "$GUARDIAN_PYTHON_BIN"
634
+ }
635
+
636
+ unload_legacy_daemon() {
637
+ if is_dry_run; then
638
+ log "dry-run: would unload $LEGACY_DAEMON_LABEL"
639
+ return
640
+ fi
641
+ launchctl bootout "gui/$(id -u)/$LEGACY_DAEMON_LABEL" >/dev/null 2>&1 || true
642
+ launchctl bootout "gui/$(id -u)" "$LEGACY_USER_PLIST" >/dev/null 2>&1 || true
643
+ }
644
+
645
+ start_user_agent() {
646
+ mkdir -p "$HOME/Library/LaunchAgents"
647
+ cp "$PLIST_BUILD_DIR/$PAIRLING_DAEMON_LABEL.plist" "$USER_PLIST"
648
+ chmod 644 "$USER_PLIST"
649
+ if is_dry_run; then
650
+ log "dry-run: rendered $USER_PLIST"
651
+ return
652
+ fi
653
+ launchctl bootout "gui/$(id -u)" "$USER_PLIST" >/dev/null 2>&1 || true
654
+ launchctl bootstrap "gui/$(id -u)" "$USER_PLIST" >/dev/null 2>&1 || true
655
+ launchctl kickstart -k "gui/$(id -u)/$PAIRLING_DAEMON_LABEL"
656
+ }
657
+
658
+ start_connectd_agent() {
659
+ mkdir -p "$HOME/Library/LaunchAgents"
660
+ cp "$PLIST_BUILD_DIR/$PAIRLING_CONNECTD_LABEL.plist" "$CONNECTD_USER_PLIST"
661
+ chmod 644 "$CONNECTD_USER_PLIST"
662
+ if is_dry_run; then
663
+ log "dry-run: rendered $CONNECTD_USER_PLIST"
664
+ return
665
+ fi
666
+ launchctl bootout "gui/$(id -u)" "$CONNECTD_USER_PLIST" >/dev/null 2>&1 || true
667
+ launchctl bootstrap "gui/$(id -u)" "$CONNECTD_USER_PLIST" >/dev/null 2>&1 || true
668
+ launchctl kickstart -k "gui/$(id -u)/$PAIRLING_CONNECTD_LABEL"
669
+ }
670
+
671
+ stop_user_agent() {
672
+ if is_dry_run; then
673
+ log "dry-run: would stop $PAIRLING_DAEMON_LABEL"
674
+ return
675
+ fi
676
+ launchctl bootout "gui/$(id -u)/$PAIRLING_DAEMON_LABEL" >/dev/null 2>&1 || true
677
+ launchctl bootout "gui/$(id -u)" "$USER_PLIST" >/dev/null 2>&1 || true
678
+ }
679
+
680
+ stop_connectd_agent() {
681
+ if is_dry_run; then
682
+ log "dry-run: would stop $PAIRLING_CONNECTD_LABEL"
683
+ return
684
+ fi
685
+ launchctl bootout "gui/$(id -u)/$PAIRLING_CONNECTD_LABEL" >/dev/null 2>&1 || true
686
+ launchctl bootout "gui/$(id -u)" "$CONNECTD_USER_PLIST" >/dev/null 2>&1 || true
687
+ }
688
+
689
+ install_guardian_if_possible() {
690
+ local rendered="$PLIST_BUILD_DIR/$PAIRLING_GUARDIAN_LABEL.plist"
691
+ if [[ "${PAIRLING_INSTALL_GUARDIAN:-0}" != "1" ]]; then
692
+ log "Guardian LaunchDaemon rendered but not installed. Set PAIRLING_INSTALL_GUARDIAN=1 to install it."
693
+ return
694
+ fi
695
+ if is_dry_run; then
696
+ log "dry-run: would install $PAIRLING_GUARDIAN_LABEL"
697
+ return
698
+ fi
699
+ if sudo -n true >/dev/null 2>&1; then
700
+ sudo cp "$rendered" "$SYSTEM_PLIST"
701
+ sudo chown root:wheel "$SYSTEM_PLIST"
702
+ sudo chmod 644 "$SYSTEM_PLIST"
703
+ sudo launchctl bootout system "$SYSTEM_PLIST" >/dev/null 2>&1 || true
704
+ sudo launchctl bootstrap system "$SYSTEM_PLIST" >/dev/null 2>&1 || true
705
+ sudo launchctl kickstart -k "system/$PAIRLING_GUARDIAN_LABEL"
706
+ else
707
+ log "Skipping guardian install: passwordless sudo is unavailable. Re-run with privileges when ready."
708
+ fi
709
+ }
710
+
711
+ run_doctor() {
712
+ "$REPO_ROOT/mac/install/doctor.sh" --json
713
+ }
714
+
715
+ rollback() {
716
+ if [[ ! -L "$PREVIOUS_LINK" ]]; then
717
+ log "ERROR: no previous runtime symlink exists at $PREVIOUS_LINK" >&2
718
+ exit 1
719
+ fi
720
+ local current_target previous_target
721
+ current_target="$(readlink "$CURRENT_LINK" 2>/dev/null || true)"
722
+ previous_target="$(readlink "$PREVIOUS_LINK")"
723
+ rm -f "$CURRENT_LINK"
724
+ ln -s "$previous_target" "$CURRENT_LINK"
725
+ rm -f "$PREVIOUS_LINK"
726
+ if [[ -n "$current_target" ]]; then
727
+ ln -s "$current_target" "$PREVIOUS_LINK"
728
+ fi
729
+ render_plists
730
+ start_user_agent
731
+ start_connectd_agent
732
+ append_history "rollback" "rolled back to $previous_target"
733
+ run_doctor
734
+ }
735
+
736
+ install_runtime() {
737
+ log "Pairling setup preview:"
738
+ log " app support: $APP_SUPPORT"
739
+ log " logs: $LOGS_ROOT"
740
+ log " LaunchAgent: $PAIRLING_DAEMON_LABEL"
741
+ log " Connect LaunchAgent: $PAIRLING_CONNECTD_LABEL"
742
+ log " runtime port: $PAIRLING_RUNTIME_PORT"
743
+ log " old Pairling predecessor cleanup label: $LEGACY_DAEMON_LABEL"
744
+ run_compile_checks
745
+ ensure_state
746
+ copy_release
747
+ switch_current
748
+ install_mcp_adapter_shim
749
+ install_shell_wrapper
750
+ render_plists
751
+ unload_legacy_daemon
752
+ start_user_agent
753
+ start_connectd_agent
754
+ install_guardian_if_possible
755
+ append_history "installed" "installed $RELEASE_NAME"
756
+ if is_dry_run; then
757
+ log "dry-run: skipping doctor gate"
758
+ else
759
+ run_doctor || true
760
+ fi
761
+ log "Installed Pairling runtime $RELEASE_NAME"
762
+ }
763
+
764
+ status_runtime() {
765
+ "$REPO_ROOT/mac/install/doctor.sh" --json || true
766
+ }
767
+
768
+ start_runtime() {
769
+ ensure_state
770
+ render_plists
771
+ unload_legacy_daemon
772
+ start_user_agent
773
+ start_connectd_agent
774
+ log "Started $PAIRLING_DAEMON_LABEL"
775
+ }
776
+
777
+ stop_runtime() {
778
+ stop_connectd_agent
779
+ stop_user_agent
780
+ log "Stopped $PAIRLING_DAEMON_LABEL"
781
+ }
782
+
783
+ pair_runtime() {
784
+ local ttl="180"
785
+ local show_qr="0"
786
+ local json_requested="0"
787
+ while [[ $# -gt 0 ]]; do
788
+ case "$1" in
789
+ --json)
790
+ json_requested="1"
791
+ ;;
792
+ --qr)
793
+ show_qr="1"
794
+ ;;
795
+ --ttl)
796
+ shift
797
+ ttl="${1:-}"
798
+ if [[ -z "$ttl" ]]; then
799
+ log "usage: pairling pair [--ttl seconds] [--json] [--qr]" >&2
800
+ exit 2
801
+ fi
802
+ ;;
803
+ --help|-h)
804
+ log "usage: pairling pair [--ttl seconds] [--json] [--qr]"
805
+ return
806
+ ;;
807
+ *)
808
+ log "usage: pairling pair [--ttl seconds] [--json] [--qr]" >&2
809
+ exit 2
810
+ ;;
811
+ esac
812
+ shift
813
+ done
814
+ local payload_file
815
+ payload_file="$(mktemp)"
816
+ if python3 - "$PAIRLING_RUNTIME_PORT" "$ttl" "$REPO_ROOT" >"$payload_file" <<'PY'
817
+ import json
818
+ import os
819
+ import socket
820
+ import subprocess
821
+ import sys
822
+ import urllib.parse
823
+ import urllib.error
824
+ import urllib.request
825
+
826
+ port, ttl_raw, repo_root = sys.argv[1:]
827
+ sys.path.insert(0, os.path.join(repo_root, "mac", "companiond"))
828
+ from pairling_connectd_status import advertised_pairling_connect_routes, fetch_connectd_status
829
+
830
+ try:
831
+ ttl = int(ttl_raw)
832
+ except ValueError:
833
+ print(json.dumps({
834
+ "ok": False,
835
+ "error": {"code": "invalid_ttl", "message": "ttl must be an integer"},
836
+ }, indent=2, sort_keys=True), file=sys.stderr)
837
+ raise SystemExit(2)
838
+
839
+ url = f"http://127.0.0.1:{int(port)}/pair/start"
840
+ body = json.dumps({"ttl_seconds": ttl}).encode("utf-8")
841
+ request = urllib.request.Request(
842
+ url,
843
+ data=body,
844
+ method="POST",
845
+ headers={"Content-Type": "application/json"},
846
+ )
847
+ try:
848
+ with urllib.request.urlopen(request, timeout=5) as response:
849
+ payload = json.loads(response.read().decode("utf-8"))
850
+ except urllib.error.HTTPError as exc:
851
+ try:
852
+ payload = json.loads(exc.read().decode("utf-8"))
853
+ except Exception:
854
+ payload = {
855
+ "ok": False,
856
+ "error": {"code": "http_error", "message": str(exc)},
857
+ }
858
+ print(json.dumps(payload, indent=2, sort_keys=True), file=sys.stderr)
859
+ raise SystemExit(1)
860
+ except Exception as exc:
861
+ print(json.dumps({
862
+ "ok": False,
863
+ "error": {
864
+ "code": "runtime_unreachable",
865
+ "message": f"Pairling runtime is not reachable at {url}: {type(exc).__name__}: {exc}",
866
+ },
867
+ "repair": "Run `pairling start` or `pairling doctor --json`, then retry `pairling pair`.",
868
+ }, indent=2, sort_keys=True), file=sys.stderr)
869
+ raise SystemExit(1)
870
+
871
+ pair_id = str(payload.get("pair_id") or (payload.get("claim") or {}).get("pair_id") or "")
872
+ secret = str(
873
+ payload.get("secret")
874
+ or payload.get("secret_qr")
875
+ or (payload.get("claim") or {}).get("secret")
876
+ or ""
877
+ )
878
+ install_id = str(payload.get("install_id") or "")
879
+ mac_name = str(((payload.get("pair_service") or {}).get("txt") or {}).get("mac_name") or socket.gethostname())
880
+
881
+ def detected_tailnet_ip() -> str:
882
+ override = os.environ.get("PAIRLING_TEST_TAILSCALE_IP")
883
+ if override is not None:
884
+ value = override.strip()
885
+ return value if value.startswith("100.") else ""
886
+ try:
887
+ proc = subprocess.run(["tailscale", "ip", "-4"], capture_output=True, text=True, timeout=3)
888
+ except Exception:
889
+ return ""
890
+ if proc.returncode != 0:
891
+ return ""
892
+ for line in (proc.stdout or "").splitlines():
893
+ ip = line.strip()
894
+ if ip.startswith("100."):
895
+ return ip
896
+ return ""
897
+
898
+ def default_pair_route(port_number: int) -> dict:
899
+ for key in ("PAIRLING_PAIR_BASE_URL", "PAIRLING_PUBLIC_BASE_URL"):
900
+ value = os.environ.get(key)
901
+ if value:
902
+ return {"base_url": value, "source": "explicit_override", "status": "override"}
903
+ connect_routes = advertised_pairling_connect_routes(fetch_connectd_status(timeout_seconds=0.7))
904
+ if connect_routes:
905
+ route = connect_routes[0]
906
+ return {
907
+ "base_url": route["base_url"],
908
+ "source": route["source"],
909
+ "status": route["status"],
910
+ "kind": route["kind"],
911
+ }
912
+ tailnet_ip = detected_tailnet_ip()
913
+ if tailnet_ip:
914
+ return {"base_url": f"http://{tailnet_ip}:{port_number}", "source": "standalone_tailnet", "status": "fallback"}
915
+ try:
916
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
917
+ try:
918
+ sock.connect(("8.8.8.8", 80))
919
+ ip = sock.getsockname()[0]
920
+ finally:
921
+ sock.close()
922
+ if ip and not ip.startswith(("127.", "169.254.")):
923
+ return {"base_url": f"http://{ip}:{port_number}", "source": "lan", "status": "fallback"}
924
+ except Exception:
925
+ pass
926
+ return {"base_url": f"http://{socket.gethostname()}.local:{port_number}", "source": "bonjour", "status": "fallback"}
927
+
928
+ pair_route = default_pair_route(int(port))
929
+ base_url = str(pair_route.get("base_url") or "")
930
+ if pair_id and secret:
931
+ pair_params = {
932
+ "base": base_url,
933
+ "pair_id": pair_id,
934
+ "secret": secret,
935
+ }
936
+ if pair_route.get("source") == "pairling_connectd" and pair_route.get("status") == "ready":
937
+ pair_params["route_source"] = "pairling_connectd"
938
+ pair_params["route_status"] = "ready"
939
+ pair_params["route_kind"] = str(pair_route.get("kind") or "tailnet")
940
+ pair_params["route_contract"] = "pairling-runtime-v1"
941
+ manual = {
942
+ "base_url": base_url,
943
+ "pair_id": pair_id,
944
+ "secret": secret,
945
+ }
946
+ if install_id:
947
+ pair_params["install_id"] = install_id
948
+ pair_params["mac_name"] = mac_name
949
+ manual["install_id"] = install_id
950
+ manual["mac_name"] = mac_name
951
+ payload.setdefault("pair_url", "pairling://pair?" + urllib.parse.urlencode(pair_params))
952
+ payload.setdefault("manual", manual)
953
+
954
+ print(json.dumps(payload, indent=2, sort_keys=True))
955
+ raise SystemExit(0 if payload.get("ok") else 1)
956
+ PY
957
+ then
958
+ :
959
+ else
960
+ local code=$?
961
+ cat "$payload_file" >&2
962
+ rm -f "$payload_file"
963
+ exit "$code"
964
+ fi
965
+
966
+ if [[ "$show_qr" == "0" ]]; then
967
+ cat "$payload_file"
968
+ rm -f "$payload_file"
969
+ return
970
+ fi
971
+
972
+ local pair_url
973
+ pair_url="$(python3 - "$payload_file" <<'PY'
974
+ import json
975
+ import sys
976
+ payload = json.load(open(sys.argv[1]))
977
+ print(payload.get("pair_url", ""))
978
+ PY
979
+ )"
980
+
981
+ python3 - "$payload_file" <<'PY'
982
+ import json
983
+ import sys
984
+ payload = json.load(open(sys.argv[1]))
985
+ manual = payload.get("manual") or {}
986
+ print("Pairling pairing invitation ready")
987
+ print("")
988
+ print("Scan this QR in Pairling, or paste the pair URL below.")
989
+ print("")
990
+ if payload.get("pair_url"):
991
+ print("Pair URL:")
992
+ print(payload["pair_url"])
993
+ print("")
994
+ if manual:
995
+ print("Manual values:")
996
+ print(" base_url:", manual.get("base_url", ""))
997
+ print(" pair_id:", manual.get("pair_id", ""))
998
+ print(" secret:", manual.get("secret", ""))
999
+ print("")
1000
+ PY
1001
+ if [[ -n "$pair_url" ]]; then
1002
+ if ! render_pair_qr "$pair_url"; then
1003
+ log "QR rendering unavailable because Swift/CoreImage is not available. Use the pair URL above."
1004
+ fi
1005
+ fi
1006
+ if [[ "$json_requested" == "1" ]]; then
1007
+ log ""
1008
+ log "JSON:"
1009
+ cat "$payload_file"
1010
+ fi
1011
+ rm -f "$payload_file"
1012
+ }
1013
+
1014
+ devices_runtime() {
1015
+ python3 - "$DEVICES_DB" <<'PY'
1016
+ import json
1017
+ import sqlite3
1018
+ import sys
1019
+ path = sys.argv[1]
1020
+ try:
1021
+ with sqlite3.connect(path) as db:
1022
+ rows = db.execute("SELECT device_id, device_name, scopes_json, created_at, last_seen_at, revoked_at FROM devices ORDER BY created_at").fetchall()
1023
+ except Exception as exc:
1024
+ print(json.dumps({"ok": False, "error": str(exc)}))
1025
+ raise SystemExit(1)
1026
+ print(json.dumps({
1027
+ "ok": True,
1028
+ "devices": [
1029
+ {
1030
+ "device_id": row[0],
1031
+ "device_name": row[1],
1032
+ "scopes": json.loads(row[2]),
1033
+ "created_at": row[3],
1034
+ "last_seen_at": row[4],
1035
+ "revoked_at": row[5],
1036
+ }
1037
+ for row in rows
1038
+ ],
1039
+ }, indent=2, sort_keys=True))
1040
+ PY
1041
+ }
1042
+
1043
+ unpair_runtime() {
1044
+ local device_id="${1:-}"
1045
+ if [[ -z "$device_id" ]]; then
1046
+ log "usage: pairling unpair <device_id>" >&2
1047
+ exit 2
1048
+ fi
1049
+ python3 - "$REPO_ROOT" "$DEVICES_DB" "$LOGS_ROOT/audit.jsonl" "$device_id" <<'PY'
1050
+ import json
1051
+ import sys
1052
+ from pathlib import Path
1053
+
1054
+ repo_root, db_path, audit_path, device_id = sys.argv[1:]
1055
+ sys.path.insert(0, str(Path(repo_root) / "mac" / "companiond"))
1056
+ from pairling_devices import DeviceRegistry
1057
+
1058
+ registry = DeviceRegistry(Path(db_path), Path(audit_path))
1059
+ ok = registry.revoke_device(device_id, reason="cli")
1060
+ payload = {"ok": ok, "device_id": device_id}
1061
+ if not ok:
1062
+ payload["error"] = {"code": "device_not_found", "message": "device was not found or is already revoked"}
1063
+ print(json.dumps(payload, indent=2, sort_keys=True))
1064
+ raise SystemExit(0 if ok else 1)
1065
+ PY
1066
+ }
1067
+
1068
+ rotate_runtime() {
1069
+ local device_id="${1:-}"
1070
+ if [[ -z "$device_id" ]]; then
1071
+ log "usage: pairling rotate-token <device_id>" >&2
1072
+ exit 2
1073
+ fi
1074
+ python3 - "$REPO_ROOT" "$DEVICES_DB" "$LOGS_ROOT/audit.jsonl" "$device_id" <<'PY'
1075
+ import json
1076
+ import sys
1077
+ from pathlib import Path
1078
+
1079
+ repo_root, db_path, audit_path, device_id = sys.argv[1:]
1080
+ sys.path.insert(0, str(Path(repo_root) / "mac" / "companiond"))
1081
+ from pairling_devices import DeviceRegistry
1082
+
1083
+ registry = DeviceRegistry(Path(db_path), Path(audit_path))
1084
+ token = registry.rotate_token(device_id)
1085
+ payload = {"ok": token is not None, "device_id": device_id}
1086
+ if token is None:
1087
+ payload["error"] = {"code": "device_not_found", "message": "device was not found"}
1088
+ else:
1089
+ payload["token"] = token
1090
+ print(json.dumps(payload, indent=2, sort_keys=True))
1091
+ raise SystemExit(0 if token is not None else 1)
1092
+ PY
1093
+ }
1094
+
1095
+ logs_runtime() {
1096
+ log "$LOGS_ROOT"
1097
+ }
1098
+
1099
+ connect_auth_open() {
1100
+ local json_mode="false"
1101
+ while [[ $# -gt 0 ]]; do
1102
+ case "$1" in
1103
+ --json)
1104
+ json_mode="true"
1105
+ ;;
1106
+ --help|-h)
1107
+ log "usage: pairling connect-auth-open [--json]"
1108
+ return
1109
+ ;;
1110
+ *)
1111
+ log "usage: pairling connect-auth-open [--json]" >&2
1112
+ exit 2
1113
+ ;;
1114
+ esac
1115
+ shift
1116
+ done
1117
+ local output
1118
+ if output="$(/usr/bin/curl -sS --max-time 5 -X POST http://127.0.0.1:7774/auth/open 2>/dev/null)"; then
1119
+ local response_status
1120
+ if python3 -c 'import json,sys; sys.exit(0 if json.load(sys.stdin).get("ok") else 1)' <<<"$output"; then
1121
+ response_status=0
1122
+ else
1123
+ response_status=1
1124
+ fi
1125
+ if [[ "$json_mode" == "true" ]]; then
1126
+ printf '%s\n' "$output"
1127
+ else
1128
+ python3 -c 'import json,sys; data=json.load(sys.stdin); print("Pairling Connect browser approval opened." if data.get("opened") else data.get("error", "Pairling Connect browser approval is not available."))' <<<"$output"
1129
+ fi
1130
+ exit "$response_status"
1131
+ fi
1132
+ if [[ "$json_mode" == "true" ]]; then
1133
+ printf '{"ok":false,"opened":false,"auth_url_present":false,"error":"Pairling Connect auth endpoint unavailable."}\n'
1134
+ else
1135
+ printf 'Pairling Connect auth endpoint unavailable.\n' >&2
1136
+ fi
1137
+ exit 1
1138
+ }
1139
+
1140
+ render_pair_qr() {
1141
+ local pair_url="$1"
1142
+ if ! command -v swift >/dev/null 2>&1; then
1143
+ return 1
1144
+ fi
1145
+ swift - "$pair_url" <<'SWIFT'
1146
+ import CoreGraphics
1147
+ import CoreImage
1148
+ import Foundation
1149
+
1150
+ guard CommandLine.arguments.count > 1,
1151
+ let message = CommandLine.arguments[1].data(using: .utf8),
1152
+ let filter = CIFilter(name: "CIQRCodeGenerator") else {
1153
+ exit(2)
1154
+ }
1155
+
1156
+ filter.setValue(message, forKey: "inputMessage")
1157
+ filter.setValue("M", forKey: "inputCorrectionLevel")
1158
+
1159
+ guard let output = filter.outputImage else {
1160
+ exit(2)
1161
+ }
1162
+
1163
+ let extent = output.extent.integral
1164
+ let ciContext = CIContext(options: nil)
1165
+ guard let cgImage = ciContext.createCGImage(output, from: extent) else {
1166
+ exit(2)
1167
+ }
1168
+
1169
+ let width = cgImage.width
1170
+ let height = cgImage.height
1171
+ let bytesPerRow = width * 4
1172
+ var raw = [UInt8](repeating: 255, count: height * bytesPerRow)
1173
+ let colorSpace = CGColorSpaceCreateDeviceRGB()
1174
+ let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
1175
+
1176
+ guard let bitmapContext = CGContext(
1177
+ data: &raw,
1178
+ width: width,
1179
+ height: height,
1180
+ bitsPerComponent: 8,
1181
+ bytesPerRow: bytesPerRow,
1182
+ space: colorSpace,
1183
+ bitmapInfo: bitmapInfo
1184
+ ) else {
1185
+ exit(2)
1186
+ }
1187
+
1188
+ bitmapContext.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 1))
1189
+ bitmapContext.fill(CGRect(x: 0, y: 0, width: width, height: height))
1190
+ bitmapContext.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
1191
+
1192
+ func isDark(_ x: Int, _ y: Int) -> Bool {
1193
+ let index = y * bytesPerRow + x * 4
1194
+ return raw[index] < 128 && raw[index + 1] < 128 && raw[index + 2] < 128
1195
+ }
1196
+
1197
+ let quietZone = 4
1198
+ let reset = "\u{001B}[0m"
1199
+ let black = "\u{001B}[40m "
1200
+ let white = "\u{001B}[47m "
1201
+
1202
+ for y in (-quietZone)..<(height + quietZone) {
1203
+ var line = ""
1204
+ for x in (-quietZone)..<(width + quietZone) {
1205
+ let dark = x >= 0 && y >= 0 && x < width && y < height && isDark(x, y)
1206
+ line += dark ? black : white
1207
+ }
1208
+ print(line + reset)
1209
+ }
1210
+ SWIFT
1211
+ }
1212
+
1213
+ diagnose_runtime() {
1214
+ "$REPO_ROOT/mac/install/doctor.sh" --json | python3 -c 'import json,sys; data=json.load(sys.stdin); print(json.dumps(data, indent=2, sort_keys=True))' || true
1215
+ }
1216
+
1217
+ usage() {
1218
+ cat <<EOF
1219
+ usage: pairling <command>
1220
+
1221
+ commands:
1222
+ setup|install
1223
+ setup --first-run
1224
+ first-run
1225
+ start
1226
+ stop
1227
+ restart
1228
+ status
1229
+ doctor --json
1230
+ doctor --first-run --json
1231
+ pair
1232
+ connect-auth-open
1233
+ devices
1234
+ unpair <device_id>
1235
+ rotate-token <device_id>
1236
+ logs
1237
+ diagnose --redact
1238
+ uninstall
1239
+ rollback
1240
+ EOF
1241
+ }
1242
+
1243
+ cmd="${1:-setup}"
1244
+ shift || true
1245
+ case "$cmd" in
1246
+ setup|install)
1247
+ if [[ "${1:-}" == "--first-run" ]]; then
1248
+ shift
1249
+ "$REPO_ROOT/mac/install/bootstrap-first-run.sh" "$@"
1250
+ else
1251
+ install_runtime
1252
+ fi
1253
+ ;;
1254
+ first-run)
1255
+ "$REPO_ROOT/mac/install/bootstrap-first-run.sh" "$@"
1256
+ ;;
1257
+ start)
1258
+ start_runtime
1259
+ ;;
1260
+ stop)
1261
+ stop_runtime
1262
+ ;;
1263
+ restart)
1264
+ stop_runtime
1265
+ start_runtime
1266
+ ;;
1267
+ status)
1268
+ status_runtime
1269
+ ;;
1270
+ doctor)
1271
+ "$REPO_ROOT/mac/install/doctor.sh" "$@"
1272
+ ;;
1273
+ pair)
1274
+ pair_runtime "$@"
1275
+ ;;
1276
+ devices)
1277
+ devices_runtime
1278
+ ;;
1279
+ unpair)
1280
+ unpair_runtime "$@"
1281
+ ;;
1282
+ rotate-token)
1283
+ rotate_runtime "$@"
1284
+ ;;
1285
+ logs)
1286
+ logs_runtime
1287
+ ;;
1288
+ connect-auth-open)
1289
+ connect_auth_open "$@"
1290
+ ;;
1291
+ diagnose)
1292
+ diagnose_runtime "$@"
1293
+ ;;
1294
+ uninstall)
1295
+ "$REPO_ROOT/mac/install/uninstall-runtime.sh" "$@"
1296
+ ;;
1297
+ rollback|--rollback)
1298
+ rollback
1299
+ ;;
1300
+ help|--help|-h)
1301
+ usage
1302
+ ;;
1303
+ *)
1304
+ usage >&2
1305
+ exit 2
1306
+ ;;
1307
+ esac