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