relay-companion 0.1.407 → 0.1.409
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.
- package/bin/relay.js +24 -23
- package/bootstrap/relay-setup.cjs +54 -14
- package/bootstrap/runtime-executables.cjs +88 -0
- package/bootstrap/runtime-health.cjs +149 -1
- package/npm-shrinkwrap.json +2 -2
- package/overlay/inbox.html +155 -23
- package/overlay/main.cjs +91 -14
- package/overlay/preload.cjs +4 -0
- package/package.json +1 -1
- package/src/auto-update.js +1 -1
- package/src/canonical-runtime.js +21 -15
- package/src/canonical-updater.js +16 -103
- package/src/config.js +20 -8
- package/src/install.js +11 -5
- package/src/installation-authorization.js +177 -33
package/bin/relay.js
CHANGED
|
@@ -217,10 +217,8 @@ async function applyInstall({
|
|
|
217
217
|
requireLiveTools = false,
|
|
218
218
|
requiredHosts = requiredLiveHosts(),
|
|
219
219
|
claim = false,
|
|
220
|
+
reload = true,
|
|
220
221
|
} = {}) {
|
|
221
|
-
// Installing or repairing is also the migration point for old local
|
|
222
|
-
// capability settings. writeConfig sanitizes them before touching disk.
|
|
223
|
-
writeConfig({});
|
|
224
222
|
const {
|
|
225
223
|
installed,
|
|
226
224
|
missing,
|
|
@@ -232,7 +230,7 @@ async function applyInstall({
|
|
|
232
230
|
codexHooks = null,
|
|
233
231
|
desktopRestarts = [],
|
|
234
232
|
sweptStaleEntries = [],
|
|
235
|
-
} = await runSetupInstall({ claim });
|
|
233
|
+
} = await runSetupInstall({ claim, reload });
|
|
236
234
|
if (installed.length) console.log(`Added Relay to ${installed.join(" and ")} on this machine.`);
|
|
237
235
|
if (!binStable) {
|
|
238
236
|
console.log(
|
|
@@ -351,6 +349,10 @@ async function cmdSetup(flags) {
|
|
|
351
349
|
// An explicit setup is the migration/ownership handoff. Runtime verification
|
|
352
350
|
// in runSetupInstall completes before either platform's autostart is changed.
|
|
353
351
|
claim: true,
|
|
352
|
+
// The signed thin bootstrap asks setup to write all registrations without
|
|
353
|
+
// starting them, then performs the same exact-root lifecycle as the updater.
|
|
354
|
+
// Ordinary setup keeps its historical install-and-start behaviour.
|
|
355
|
+
reload: !(flags["no-restart"] && process.env.RELAY_BOOTSTRAP_ACTIVATED === "1"),
|
|
354
356
|
});
|
|
355
357
|
const token = setupOpenRelayToken(flags);
|
|
356
358
|
if (token && readConfig().deviceToken) {
|
|
@@ -389,8 +391,11 @@ async function cmdInstall(flags = {}) {
|
|
|
389
391
|
}
|
|
390
392
|
|
|
391
393
|
function cmdRepairDesktop(flags = {}) {
|
|
392
|
-
writeConfig({});
|
|
393
394
|
const reload = !flags["no-restart"];
|
|
395
|
+
// Repair is deliberately local and non-destructive. It may rewrite Relay's
|
|
396
|
+
// launchers and Relay-owned hook registrations, but it never rewrites account
|
|
397
|
+
// config, deletes credentials/E2EE state, clears messages/outboxes/preferences,
|
|
398
|
+
// or calls a cloud revocation endpoint.
|
|
394
399
|
// The updater invokes this command from the verified candidate tree before it
|
|
395
400
|
// switches/restarts services. First atomically migrate any existing raw hook
|
|
396
401
|
// registrations to Relay's stable bridge; if that fails, do not claim the
|
|
@@ -726,7 +731,7 @@ async function cmdDoctor(flags = {}) {
|
|
|
726
731
|
const since = failure.firstAt ? new Date(Number(failure.firstAt)).toISOString() : "unknown";
|
|
727
732
|
console.log(` UPDATES ARE FAILING: ${failure.count} consecutive attempt(s) to install ${failure.target} since ${since}`);
|
|
728
733
|
console.log(` cause: see ${updateLogPath}`);
|
|
729
|
-
console.log(" if autostart is broken, run: relay repair-
|
|
734
|
+
console.log(" if autostart is broken, run: relay repair-installation");
|
|
730
735
|
} else if (failureInFlight) {
|
|
731
736
|
console.log(` update attempt in flight: ${failure.target} (launched ${new Date(Number(failure.lastAt)).toISOString()})`);
|
|
732
737
|
} else if (!canonicalFailures.length) {
|
|
@@ -786,7 +791,7 @@ async function cmdDoctor(flags = {}) {
|
|
|
786
791
|
const missingTasks = taskStatus.missing;
|
|
787
792
|
if (missingTasks.length) {
|
|
788
793
|
console.log(` MISSING Scheduled Task(s): ${missingTasks.join(", ")}`);
|
|
789
|
-
console.log(" fix with: relay repair-
|
|
794
|
+
console.log(" fix with: relay repair-installation");
|
|
790
795
|
} else if (taskStatus.unavailable.length) {
|
|
791
796
|
console.log(" Scheduled Tasks: unable to verify from this process");
|
|
792
797
|
console.log(" run relay doctor in a normal terminal to check autostart");
|
|
@@ -795,7 +800,7 @@ async function cmdDoctor(flags = {}) {
|
|
|
795
800
|
// Windows 11 overflow chevron) is the only way back to the pill.
|
|
796
801
|
if (windowsStartMenuShortcutMissing()) {
|
|
797
802
|
console.log(` MISSING Start Menu shortcut: ${windowsStartMenuShortcutPath()}`);
|
|
798
|
-
console.log(" fix with: relay repair-
|
|
803
|
+
console.log(" fix with: relay repair-installation");
|
|
799
804
|
} else {
|
|
800
805
|
console.log(" Start Menu shortcut: ok (Start → \"relay\" opens the pill)");
|
|
801
806
|
}
|
|
@@ -876,9 +881,8 @@ function cmdEnv(positional = [], flags = {}) {
|
|
|
876
881
|
|
|
877
882
|
function cmdUpdateChannel(positional = []) {
|
|
878
883
|
const requested = String(positional[0] || "").trim().toLowerCase();
|
|
879
|
-
const overridden = Boolean(process.env.RELAY_UPDATE_CHANNEL);
|
|
880
884
|
if (!requested) {
|
|
881
|
-
const source =
|
|
885
|
+
const source = process.env.RELAY_UPDATE_CHANNEL ? " (from RELAY_UPDATE_CHANNEL)" : "";
|
|
882
886
|
console.log(`[relay] update channel: ${updateChannel()}${source}`);
|
|
883
887
|
console.log(`[relay] switch with: relay update-channel ${UPDATE_CHANNEL_DEV} | relay update-channel ${UPDATE_CHANNEL_STAGING} | relay update-channel ${UPDATE_CHANNEL_STABLE}`);
|
|
884
888
|
return;
|
|
@@ -888,18 +892,13 @@ function cmdUpdateChannel(positional = []) {
|
|
|
888
892
|
process.exitCode = 1;
|
|
889
893
|
return;
|
|
890
894
|
}
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
if (requested ===
|
|
897
|
-
|
|
898
|
-
} else if (requested === UPDATE_CHANNEL_STAGING) {
|
|
899
|
-
console.log("[relay] update channel set to staging — this machine now installs production-like release candidates deliberately promoted to staging");
|
|
900
|
-
} else {
|
|
901
|
-
console.log("[relay] update channel set to stable — this machine keeps its current build and follows production releases from here on");
|
|
902
|
-
}
|
|
895
|
+
// Release code and its server contract are one environment. Keeping these as
|
|
896
|
+
// independent switches allowed a dev Companion to keep calling production,
|
|
897
|
+
// where unreleased routes (notably Slack) correctly do not exist. Preserve
|
|
898
|
+
// the old command as an alias, but make the resulting state coherent.
|
|
899
|
+
if (requested === UPDATE_CHANNEL_DEV) return cmdEnv(["dev"]);
|
|
900
|
+
if (requested === UPDATE_CHANNEL_STAGING) return cmdEnv(["staging"]);
|
|
901
|
+
if (requested === UPDATE_CHANNEL_STABLE) return cmdEnv(["prod"]);
|
|
903
902
|
}
|
|
904
903
|
|
|
905
904
|
async function cmdE2eeHistoryImport(flags) {
|
|
@@ -974,6 +973,7 @@ async function main() {
|
|
|
974
973
|
return cmdPair(flags);
|
|
975
974
|
case "install":
|
|
976
975
|
return cmdInstall(flags);
|
|
976
|
+
case "repair-installation":
|
|
977
977
|
case "repair-desktop":
|
|
978
978
|
return cmdRepairDesktop(flags);
|
|
979
979
|
case "repair-runtime":
|
|
@@ -1048,7 +1048,8 @@ async function main() {
|
|
|
1048
1048
|
" relay install Add Relay to your agents",
|
|
1049
1049
|
" relay uninstall Remove Relay from your agents and stop the daemon (keeps the pairing)",
|
|
1050
1050
|
" relay uninstall --purge Also revoke this device and delete all local Relay state, as if never installed",
|
|
1051
|
-
" relay repair-
|
|
1051
|
+
" relay repair-installation [--no-restart] Non-destructively repair Relay.app and services; preserves account, encryption, messages, outbox, and preferences",
|
|
1052
|
+
" relay repair-desktop [--no-restart] Compatibility alias for repair-installation",
|
|
1052
1053
|
" relay repair-runtime [--no-restart] Internal: refresh existing Relay registrations and runtime services",
|
|
1053
1054
|
" relay pair [--api URL] [--web URL] [--code CODE] [--no-restart] Pair this machine only (no agent install); restarts running Relay services onto the new account",
|
|
1054
1055
|
" relay tasks Pull and process the developer Request runtime once",
|
|
@@ -11,7 +11,8 @@ const os = require("node:os");
|
|
|
11
11
|
const path = require("node:path");
|
|
12
12
|
const { spawnSync } = require("node:child_process");
|
|
13
13
|
const { pipeline } = require("node:stream/promises");
|
|
14
|
-
const { exactRuntimeHealth } = require("./runtime-health.cjs");
|
|
14
|
+
const { activateMacRuntimeServices, exactRuntimeHealth } = require("./runtime-health.cjs");
|
|
15
|
+
const { verifyRuntimeExecutables } = require("./runtime-executables.cjs");
|
|
15
16
|
|
|
16
17
|
const RELEASE_ORIGIN = "https://api.sendrelays.com";
|
|
17
18
|
const RELEASE_BASE_PATH = "/v1/companion-releases";
|
|
@@ -19,7 +20,7 @@ const PACKAGE_NAME = "relay-companion";
|
|
|
19
20
|
const WINDOWS_RELAY_TASKS = ["Relay Companion Pill", "Relay Companion Daemon"];
|
|
20
21
|
const WINDOWS_STOP_RELAY_SERVICES_PS = [
|
|
21
22
|
"$p=Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {",
|
|
22
|
-
" $_.CommandLine -and (($_.CommandLine -match '[\\\\/]relay\\.js.*\\bdaemon\\b') -or ($_.CommandLine -match '[\\\\/]overlay[\\\\/]main\\.cjs'))",
|
|
23
|
+
" $_.CommandLine -and ($_.CommandLine -match '[\\\\/]node_modules[\\\\/]relay-companion[\\\\/]') -and (($_.CommandLine -match '[\\\\/]relay\\.js.*\\bdaemon\\b') -or ($_.CommandLine -match '[\\\\/]overlay[\\\\/]main\\.cjs'))",
|
|
23
24
|
"}; foreach($x in $p){ try { Invoke-CimMethod -InputObject $x -MethodName Terminate -ErrorAction Stop | Out-Null } catch {} }",
|
|
24
25
|
].join(" ");
|
|
25
26
|
const packageJson = require("../package.json");
|
|
@@ -426,11 +427,21 @@ function verifyExtractedRuntime(packageRoot, version, platformKey) {
|
|
|
426
427
|
}
|
|
427
428
|
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
|
|
428
429
|
if (manifest.name !== PACKAGE_NAME || manifest.version !== version) fail("Relay runtime package identity is invalid.");
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
430
|
+
const bin = path.join(packageRoot, "bin", "relay.js");
|
|
431
|
+
if (!platformKey.startsWith("win32-")) {
|
|
432
|
+
try { fs.accessSync(bin, fs.constants.X_OK); }
|
|
433
|
+
catch { fail("Relay runtime CLI is not executable."); }
|
|
434
|
+
}
|
|
435
|
+
const executables = verifyRuntimeExecutables(packageRoot, { platform: platformKey });
|
|
436
|
+
if (!executables.ok) {
|
|
437
|
+
fail(`Relay runtime executable verification failed (${executables.reason}${executables.detail ? `: ${executables.detail}` : ""}).`);
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
packageRoot,
|
|
441
|
+
bin,
|
|
442
|
+
electron: executables.electronPath,
|
|
443
|
+
executablePaths: executables.paths.map((entry) => entry.path),
|
|
444
|
+
};
|
|
434
445
|
}
|
|
435
446
|
|
|
436
447
|
function runtimeLayout(version, platformKey) {
|
|
@@ -586,6 +597,7 @@ function stopPreviousWindowsRuntime({ platform = process.platform, spawnImpl = s
|
|
|
586
597
|
|
|
587
598
|
async function activateRuntime(layout, runtime, version, {
|
|
588
599
|
platform = process.platform,
|
|
600
|
+
homeDir = os.homedir(),
|
|
589
601
|
spawnImpl = spawnSync,
|
|
590
602
|
existsSync = fs.existsSync,
|
|
591
603
|
readFileSync = fs.readFileSync,
|
|
@@ -595,6 +607,9 @@ async function activateRuntime(layout, runtime, version, {
|
|
|
595
607
|
healthAttempts = 60,
|
|
596
608
|
healthIntervalMs = 500,
|
|
597
609
|
sleep,
|
|
610
|
+
activateMacServices = activateMacRuntimeServices,
|
|
611
|
+
activationDeadlineMs = 90_000,
|
|
612
|
+
now = Date.now,
|
|
598
613
|
} = {}) {
|
|
599
614
|
let previous = null;
|
|
600
615
|
try { previous = JSON.parse(readFileSync(layout.pointerPath, "utf8")); } catch {}
|
|
@@ -674,7 +689,12 @@ async function activateRuntime(layout, runtime, version, {
|
|
|
674
689
|
fail(message);
|
|
675
690
|
}
|
|
676
691
|
|
|
677
|
-
|
|
692
|
+
// On macOS setup owns registration, but the shared activation state machine
|
|
693
|
+
// owns service restart. This keeps first-install MCP/plist creation in the
|
|
694
|
+
// candidate while bootstrap and the updater use the same exact-root handoff.
|
|
695
|
+
const setupArgs = [runtime.bin, "setup", "--no-trampoline", "--claim"];
|
|
696
|
+
if (platform === "darwin") setupArgs.push("--no-restart");
|
|
697
|
+
const result = spawnImpl(process.execPath, setupArgs, {
|
|
678
698
|
stdio: "inherit",
|
|
679
699
|
windowsHide: true,
|
|
680
700
|
env: { ...process.env, RELAY_BOOTSTRAP_ACTIVATED: "1" },
|
|
@@ -684,12 +704,32 @@ async function activateRuntime(layout, runtime, version, {
|
|
|
684
704
|
rollback(message);
|
|
685
705
|
fail(message);
|
|
686
706
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
707
|
+
let health = null;
|
|
708
|
+
if (platform === "darwin") {
|
|
709
|
+
const activated = await activateMacServices(runtime, {
|
|
710
|
+
homeDir,
|
|
711
|
+
platform,
|
|
712
|
+
run: spawnImpl,
|
|
713
|
+
healthCheck,
|
|
714
|
+
activationDeadlineMs,
|
|
715
|
+
now,
|
|
716
|
+
...(sleep ? { sleep } : {}),
|
|
717
|
+
});
|
|
718
|
+
if (!activated?.ok) {
|
|
719
|
+
const detail = activated?.detail ? ` (${activated.detail})` : "";
|
|
720
|
+
const message = `Relay runtime activation could not start the exact registered macOS services${detail}.`;
|
|
721
|
+
rollback(message);
|
|
722
|
+
fail(message);
|
|
723
|
+
}
|
|
724
|
+
health = activated.health;
|
|
725
|
+
} else {
|
|
726
|
+
health = await waitForRuntimeHealth(runtime, {
|
|
727
|
+
healthCheck,
|
|
728
|
+
attempts: healthAttempts,
|
|
729
|
+
intervalMs: healthIntervalMs,
|
|
730
|
+
...(sleep ? { sleep } : {}),
|
|
731
|
+
});
|
|
732
|
+
}
|
|
693
733
|
if (!health?.ok) {
|
|
694
734
|
const missing = [health?.daemon ? "" : "daemon", health?.pill ? "" : "pill"].filter(Boolean).join(" and ");
|
|
695
735
|
const message = `Relay runtime activation failed exact-root health${missing ? ` (${missing} did not start)` : ""}.`;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
|
|
6
|
+
const MAC_HELPER_EXECUTABLES = [
|
|
7
|
+
["Electron Helper.app", "Electron Helper"],
|
|
8
|
+
["Electron Helper (GPU).app", "Electron Helper (GPU)"],
|
|
9
|
+
["Electron Helper (Plugin).app", "Electron Helper (Plugin)"],
|
|
10
|
+
["Electron Helper (Renderer).app", "Electron Helper (Renderer)"],
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function platformName(value = process.platform) {
|
|
14
|
+
return String(value).split("-")[0];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function pathsFor(platform) {
|
|
18
|
+
return platformName(platform) === "win32" ? path.win32 : path.posix;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function runtimeExecutableInventory(packageRoot, {
|
|
22
|
+
platform = process.platform,
|
|
23
|
+
existsSync = fs.existsSync,
|
|
24
|
+
} = {}) {
|
|
25
|
+
const name = platformName(platform);
|
|
26
|
+
const api = pathsFor(name);
|
|
27
|
+
const roots = [api.join(packageRoot, "node_modules", "electron"), api.join(api.dirname(packageRoot), "electron")];
|
|
28
|
+
const mainRelative = name === "win32"
|
|
29
|
+
? api.join("dist", "electron.exe")
|
|
30
|
+
: name === "darwin"
|
|
31
|
+
? api.join("dist", "Electron.app", "Contents", "MacOS", "Electron")
|
|
32
|
+
: api.join("dist", "electron");
|
|
33
|
+
const electronRoot = roots.find((root) => {
|
|
34
|
+
try { return existsSync(api.join(root, mainRelative)); } catch { return false; }
|
|
35
|
+
});
|
|
36
|
+
if (!electronRoot) return { ok: false, reason: "candidate-electron-missing", paths: [] };
|
|
37
|
+
|
|
38
|
+
const entries = [{ role: "electron", path: api.join(electronRoot, mainRelative) }];
|
|
39
|
+
if (name === "darwin") {
|
|
40
|
+
const frameworks = api.join(electronRoot, "dist", "Electron.app", "Contents", "Frameworks");
|
|
41
|
+
for (const [app, executable] of MAC_HELPER_EXECUTABLES) {
|
|
42
|
+
entries.push({ role: `helper:${executable}`, path: api.join(frameworks, app, "Contents", "MacOS", executable) });
|
|
43
|
+
}
|
|
44
|
+
entries.push({
|
|
45
|
+
role: "helper:chrome_crashpad_handler",
|
|
46
|
+
path: api.join(
|
|
47
|
+
frameworks,
|
|
48
|
+
"Electron Framework.framework",
|
|
49
|
+
"Versions",
|
|
50
|
+
"A",
|
|
51
|
+
"Helpers",
|
|
52
|
+
"chrome_crashpad_handler",
|
|
53
|
+
),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, electronRoot, electronPath: entries[0].path, paths: entries };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function verifyRuntimeExecutables(packageRoot, {
|
|
60
|
+
platform = process.platform,
|
|
61
|
+
existsSync = fs.existsSync,
|
|
62
|
+
statSync = fs.statSync,
|
|
63
|
+
accessSync = fs.accessSync,
|
|
64
|
+
} = {}) {
|
|
65
|
+
const name = platformName(platform);
|
|
66
|
+
const inventory = runtimeExecutableInventory(packageRoot, { platform: name, existsSync });
|
|
67
|
+
if (!inventory.ok) return inventory;
|
|
68
|
+
for (const entry of inventory.paths) {
|
|
69
|
+
try {
|
|
70
|
+
if (!existsSync(entry.path)) return { ok: false, reason: "candidate-executable-missing", detail: entry.role };
|
|
71
|
+
if (!statSync(entry.path).isFile()) return { ok: false, reason: "candidate-executable-invalid", detail: entry.role };
|
|
72
|
+
if (name !== "win32") accessSync(entry.path, fs.constants.X_OK);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
reason: error?.code === "ENOENT" ? "candidate-executable-missing" : "candidate-not-executable",
|
|
77
|
+
detail: entry.role,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { ...inventory, ok: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
MAC_HELPER_EXECUTABLES,
|
|
86
|
+
runtimeExecutableInventory,
|
|
87
|
+
verifyRuntimeExecutables,
|
|
88
|
+
};
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const path = require("node:path");
|
|
4
|
+
const os = require("node:os");
|
|
4
5
|
const { spawnSync } = require("node:child_process");
|
|
5
6
|
|
|
7
|
+
const DAEMON_LABEL = "work.relay.companion";
|
|
8
|
+
const PILL_LABEL = "work.relay.companion.pill";
|
|
9
|
+
|
|
6
10
|
// A local checkout may run beside Relay without invalidating the installed
|
|
7
11
|
// runtime. Only installed relay-companion trees count as production services.
|
|
8
12
|
const SERVICE_TREE_RE = /node_modules[\\/]relay-companion[\\/]/i;
|
|
@@ -44,4 +48,148 @@ function exactRuntimeHealth(target, {
|
|
|
44
48
|
return { ok: daemon && pill && !oldDaemon && !oldPill, daemon, pill, oldDaemon, oldPill, packageRoot: target.packageRoot };
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
|
|
51
|
+
function installedServiceProcessRows(target, {
|
|
52
|
+
run = defaultRun,
|
|
53
|
+
includeTarget = false,
|
|
54
|
+
processId = process.pid,
|
|
55
|
+
} = {}) {
|
|
56
|
+
const result = run("/bin/ps", ["-axo", "pid=,command="]);
|
|
57
|
+
if (!commandOk(result)) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
rows: [],
|
|
61
|
+
reason: "service-process-query-failed",
|
|
62
|
+
detail: result?.error?.message || String(result?.stderr || result?.stdout || "").trim(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const targetNeedle = String(target?.packageRoot || "").replaceAll("\\", "/");
|
|
66
|
+
const rows = [];
|
|
67
|
+
for (const line of String(result.stdout || "").split("\n")) {
|
|
68
|
+
const match = line.match(/^\s*(\d+)\s+(.*)$/);
|
|
69
|
+
if (!match) continue;
|
|
70
|
+
const pid = Number(match[1]);
|
|
71
|
+
const command = match[2];
|
|
72
|
+
// This is the safety boundary: only an immutable/global production install
|
|
73
|
+
// has node_modules/relay-companion in its command. A checkout's daemon or
|
|
74
|
+
// Electron pill is deliberately invisible and must never be terminated.
|
|
75
|
+
if (!SERVICE_TREE_RE.test(command)) continue;
|
|
76
|
+
const isDaemon = /(?:^|[\\/])relay\.js(?:"|'|\s).*\bdaemon\b/i.test(command);
|
|
77
|
+
const isPill = /(?:^|[\\/])overlay[\\/]main\.cjs(?:"|'|\s|$)/i.test(command);
|
|
78
|
+
if (!isDaemon && !isPill) continue;
|
|
79
|
+
if (!includeTarget && targetNeedle && command.replaceAll("\\", "/").includes(targetNeedle)) continue;
|
|
80
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === processId) continue;
|
|
81
|
+
rows.push({ pid, command });
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, rows };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function terminateInstalledServiceProcesses(target, {
|
|
87
|
+
run = defaultRun,
|
|
88
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
89
|
+
graceMs = 2000,
|
|
90
|
+
includeTarget = false,
|
|
91
|
+
processId = process.pid,
|
|
92
|
+
} = {}) {
|
|
93
|
+
const first = installedServiceProcessRows(target, { run, includeTarget, processId });
|
|
94
|
+
if (!first.ok) return first;
|
|
95
|
+
if (!first.rows.length) return { ok: true, terminated: [] };
|
|
96
|
+
for (const row of first.rows) run("/bin/kill", ["-TERM", String(row.pid)]);
|
|
97
|
+
await sleep(graceMs);
|
|
98
|
+
const afterTerm = installedServiceProcessRows(target, { run, includeTarget, processId });
|
|
99
|
+
if (!afterTerm.ok) return afterTerm;
|
|
100
|
+
const originalPids = new Set(first.rows.map((row) => row.pid));
|
|
101
|
+
const survivors = afterTerm.rows.filter((row) => originalPids.has(row.pid));
|
|
102
|
+
for (const row of survivors) run("/bin/kill", ["-KILL", String(row.pid)]);
|
|
103
|
+
if (survivors.length) await sleep(500);
|
|
104
|
+
const afterKill = installedServiceProcessRows(target, { run, includeTarget, processId });
|
|
105
|
+
if (!afterKill.ok) return afterKill;
|
|
106
|
+
const remaining = afterKill.rows.filter((row) => originalPids.has(row.pid));
|
|
107
|
+
if (remaining.length) {
|
|
108
|
+
return { ok: false, reason: "service-process-stop-failed", detail: remaining.map((row) => row.pid).join(",") };
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, terminated: first.rows.map((row) => row.pid) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Restart Relay's two macOS jobs from the registrations already written by the
|
|
115
|
+
* caller, then prove that both processes belong to the requested package root.
|
|
116
|
+
* Registration remains owned by setup/repair; this helper owns only the narrow
|
|
117
|
+
* launchd handoff and never edits a plist, account file, or installed binary.
|
|
118
|
+
*/
|
|
119
|
+
async function activateMacRuntimeServices(target, {
|
|
120
|
+
homeDir = os.homedir(),
|
|
121
|
+
platform = process.platform,
|
|
122
|
+
run = defaultRun,
|
|
123
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
124
|
+
domainPollMs = 250,
|
|
125
|
+
bootstrapDelayMs = 500,
|
|
126
|
+
healthPollMs = 500,
|
|
127
|
+
activationDeadlineMs = 90_000,
|
|
128
|
+
now = Date.now,
|
|
129
|
+
processId = process.pid,
|
|
130
|
+
healthCheck = exactRuntimeHealth,
|
|
131
|
+
} = {}) {
|
|
132
|
+
if (platform !== "darwin") return { ok: false, reason: "activation-platform-unsupported" };
|
|
133
|
+
if (!target?.packageRoot || !target?.bin) return { ok: false, reason: "activation-target-invalid" };
|
|
134
|
+
const domain = `gui/${typeof process.getuid === "function" ? process.getuid() : 0}`;
|
|
135
|
+
const agents = path.posix.join(String(homeDir || ""), "Library", "LaunchAgents");
|
|
136
|
+
const labels = [PILL_LABEL, DAEMON_LABEL];
|
|
137
|
+
const deadline = now() + Math.max(1, activationDeadlineMs);
|
|
138
|
+
for (const label of labels) run("/bin/launchctl", ["bootout", `${domain}/${label}`]);
|
|
139
|
+
|
|
140
|
+
// launchctl bootout is asynchronous. Keep quiescence, bootstrap, and liveness
|
|
141
|
+
// under one deadline so a racing EIO cannot create an unbounded retry loop.
|
|
142
|
+
while (now() <= deadline) {
|
|
143
|
+
const labelsPresent = labels.filter((label) => commandOk(run("/bin/launchctl", ["print", `${domain}/${label}`])));
|
|
144
|
+
if (labelsPresent.length) {
|
|
145
|
+
for (const label of labelsPresent) run("/bin/launchctl", ["bootout", `${domain}/${label}`]);
|
|
146
|
+
await sleep(domainPollMs);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const stopped = await terminateInstalledServiceProcesses(target, {
|
|
151
|
+
run,
|
|
152
|
+
sleep,
|
|
153
|
+
includeTarget: true,
|
|
154
|
+
processId,
|
|
155
|
+
});
|
|
156
|
+
if (!stopped.ok) return stopped;
|
|
157
|
+
|
|
158
|
+
let bootstrapFailure = null;
|
|
159
|
+
for (const label of labels) {
|
|
160
|
+
const started = run("/bin/launchctl", ["bootstrap", domain, path.posix.join(agents, `${label}.plist`)]);
|
|
161
|
+
if (!commandOk(started)) {
|
|
162
|
+
bootstrapFailure = { label, result: started };
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (bootstrapFailure) {
|
|
167
|
+
for (const label of labels) run("/bin/launchctl", ["bootout", `${domain}/${label}`]);
|
|
168
|
+
const detail = String(bootstrapFailure.result?.stderr || bootstrapFailure.result?.stdout || "");
|
|
169
|
+
if (!/(?:Bootstrap failed:\s*5|Input\/output error|I\/O error)/i.test(detail)) {
|
|
170
|
+
return { ok: false, reason: "service-bootstrap-failed", detail: `${bootstrapFailure.label}: ${detail}` };
|
|
171
|
+
}
|
|
172
|
+
await sleep(bootstrapDelayMs);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
while (now() <= deadline) {
|
|
177
|
+
const health = await healthCheck(target, { platform, run });
|
|
178
|
+
if (health?.ok) return { ok: true, health };
|
|
179
|
+
if (health?.oldDaemon || health?.oldPill) {
|
|
180
|
+
for (const label of labels) run("/bin/launchctl", ["bootout", `${domain}/${label}`]);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
await sleep(healthPollMs);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { ok: false, reason: "activation-deadline-exceeded", detail: target.packageRoot };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = {
|
|
190
|
+
activateMacRuntimeServices,
|
|
191
|
+
exactRuntimeHealth,
|
|
192
|
+
installedServiceProcessRows,
|
|
193
|
+
runtimeProcessCommands,
|
|
194
|
+
terminateInstalledServiceProcesses,
|
|
195
|
+
};
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "relay-companion",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.409",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "relay-companion",
|
|
9
|
-
"version": "0.1.
|
|
9
|
+
"version": "0.1.409",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/claude-agent-sdk": "0.3.195",
|