relay-companion 0.1.511 → 0.1.513
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.
|
@@ -9,6 +9,23 @@ const defaultRun = (cmd, args, options = {}) => spawnSync(cmd, args, { encoding:
|
|
|
9
9
|
const snapshotPath = homeDir => path.join(homeDir, ".relay", "runtime", "mac-registrations.json");
|
|
10
10
|
const ok = r => Boolean(r && !r.error && r.status === 0);
|
|
11
11
|
|
|
12
|
+
// Windows antivirus and indexing hold a handle on a file for a moment after it
|
|
13
|
+
// is written, and rename fails with EPERM/EBUSY for exactly that moment. Retry
|
|
14
|
+
// briefly instead of failing an entire repair on a hold that clears by itself.
|
|
15
|
+
const RENAME_RETRY_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
16
|
+
function renameWithRetry(fsImpl, from, to, { totalMs = 3000, sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) } = {}) {
|
|
17
|
+
const started = Date.now();
|
|
18
|
+
let wait = 50;
|
|
19
|
+
for (;;) {
|
|
20
|
+
try { fsImpl.renameSync(from, to); return; }
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (!RENAME_RETRY_CODES.has(error?.code) || Date.now() - started + wait > totalMs) throw error;
|
|
23
|
+
sleep(wait);
|
|
24
|
+
wait = Math.min(500, wait * 2);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
function atomicFile(file, bytes, fsImpl = fs) {
|
|
13
30
|
fsImpl.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
14
31
|
const temporary = `${file}.${crypto.randomUUID()}.tmp`;
|
|
@@ -16,7 +33,7 @@ function atomicFile(file, bytes, fsImpl = fs) {
|
|
|
16
33
|
try {
|
|
17
34
|
fd = fsImpl.openSync(temporary, "wx", 0o600);
|
|
18
35
|
fsImpl.writeFileSync(fd, bytes); fsImpl.fsyncSync(fd); fsImpl.closeSync(fd); fd = undefined;
|
|
19
|
-
fsImpl
|
|
36
|
+
renameWithRetry(fsImpl, temporary, file);
|
|
20
37
|
// macOS can flush a directory after rename. Keep this conditional for test
|
|
21
38
|
// hosts (Windows cannot open directory handles through this API).
|
|
22
39
|
if (process.platform !== "win32") {
|
|
@@ -162,4 +179,4 @@ async function restoreSnapshot({ homeDir = os.homedir(), fsImpl = fs, run = defa
|
|
|
162
179
|
}
|
|
163
180
|
}
|
|
164
181
|
|
|
165
|
-
module.exports = { atomicFile, snapshotPath, readSnapshot, clearSnapshot, prepareSnapshot, restoreSnapshot };
|
|
182
|
+
module.exports = { atomicFile, renameWithRetry, snapshotPath, readSnapshot, clearSnapshot, prepareSnapshot, restoreSnapshot };
|
|
@@ -13,6 +13,32 @@ const ok = (r) => r?.ok === true || (!r?.error && r?.status === 0);
|
|
|
13
13
|
const xml = (s) => String(s).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
14
14
|
const unit = (s) => '"' + String(s).replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%") + '"';
|
|
15
15
|
function run(command, args) { return spawnSync(command, args, { encoding: "utf8", windowsHide: true, timeout: 30_000 }); }
|
|
16
|
+
// Why a command failed, in one line: the spawn error first, then whatever the
|
|
17
|
+
// child said. Callers embed this in their own error so update.log names the step.
|
|
18
|
+
function commandDetail(r) {
|
|
19
|
+
const text = r?.error?.message || String(r?.stderr || r?.out || r?.stdout || "").trim();
|
|
20
|
+
return `${r?.error ? "" : `exit ${r?.status ?? "unknown"}`}${text ? `${r?.error ? "" : ": "}${text}` : ""}`.slice(0, 600);
|
|
21
|
+
}
|
|
22
|
+
// Windows refuses to rename a directory or file while an antivirus scanner or
|
|
23
|
+
// indexer still holds a handle on something it just wrote. Those holds last
|
|
24
|
+
// milliseconds to a couple of seconds and then clear on their own, so a short
|
|
25
|
+
// bounded retry turns a spurious EPERM into the rename that was asked for. The
|
|
26
|
+
// same install step, run by hand a minute later, succeeded on the first try on
|
|
27
|
+
// Dino's machine (2026-09-13) after eight consecutive worker failures.
|
|
28
|
+
const RENAME_RETRY_CODES = new Set(["EPERM", "EBUSY", "EACCES", "ENOTEMPTY"]);
|
|
29
|
+
const RENAME_RETRY_TOTAL_MS = 3000;
|
|
30
|
+
function renameWithRetry(from, to, { fsImpl = fs, totalMs = RENAME_RETRY_TOTAL_MS, sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) } = {}) {
|
|
31
|
+
const started = Date.now();
|
|
32
|
+
let wait = 50;
|
|
33
|
+
for (;;) {
|
|
34
|
+
try { fsImpl.renameSync(from, to); return; }
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (!RENAME_RETRY_CODES.has(error?.code) || Date.now() - started + wait > totalMs) throw error;
|
|
37
|
+
sleep(wait);
|
|
38
|
+
wait = Math.min(500, wait * 2);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
16
42
|
|
|
17
43
|
// Task Scheduler reads this XML as UTF-16 (the caller adds the BOM). The settings
|
|
18
44
|
// that differ from schtasks' `/SC MINUTE` defaults are the load-bearing ones: both
|
|
@@ -84,16 +110,23 @@ function installRecovery({ packageRoot, node = process.execPath, homeDir = os.ho
|
|
|
84
110
|
};
|
|
85
111
|
// A live runner may still import these files. Never repair them in place.
|
|
86
112
|
if (fs.existsSync(bundle) && !matches(bundle)) bundle = path.join(root, "versions", crypto.createHash("sha256").update(digest + crypto.randomUUID()).digest("hex"));
|
|
87
|
-
|
|
113
|
+
let runtimeNode;
|
|
114
|
+
try { runtimeNode = preserveNode(node, { platform, runtimeRoot: root, isTemporary: () => true }); }
|
|
115
|
+
catch (error) { throw Error(`recovery-node-preservation-failed: ${error.message}`); }
|
|
88
116
|
if (!fs.existsSync(bundle)) {
|
|
89
117
|
const pending = path.join(root, "versions", '.pending-' + crypto.randomUUID());
|
|
90
118
|
fs.mkdirSync(path.join(pending, "bootstrap"), { recursive: true, mode: 0o700 });
|
|
119
|
+
// Each step names itself when it fails. The update worker records only the
|
|
120
|
+
// message, and "recovery-install-failed" alone cost a day of guessing.
|
|
91
121
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
122
|
+
try {
|
|
123
|
+
for (const name of names) atomicFile(path.join(pending, "bootstrap", name), fs.readFileSync(path.join(source, name)));
|
|
124
|
+
atomicFile(path.join(pending, "package.json"), fs.readFileSync(path.join(packageRoot, "package.json")));
|
|
125
|
+
} catch (error) { throw Error(`recovery-bundle-copy-failed: ${error.message}`); }
|
|
126
|
+
const pendingCheck = runCommand(runtimeNode, [path.join(pending, "bootstrap", "recovery-runner.cjs"), "--self-check"]);
|
|
127
|
+
if (!ok(pendingCheck)) throw Error(`recovery-bundle-verification-failed: ${commandDetail(pendingCheck)}`);
|
|
128
|
+
try { renameWithRetry(pending, bundle); }
|
|
129
|
+
catch (error) { if (!matches(bundle)) throw Error(`recovery-bundle-publish-failed: ${error.message}`); }
|
|
97
130
|
} finally { fs.rmSync(pending, { recursive: true, force: true }); }
|
|
98
131
|
}
|
|
99
132
|
const pointer = path.join(root, "current.json");
|
|
@@ -106,7 +139,7 @@ function installRecovery({ packageRoot, node = process.execPath, homeDir = os.ho
|
|
|
106
139
|
write(path.join(root, "known-good.json"), current);
|
|
107
140
|
}
|
|
108
141
|
const checked = runCommand(runtimeNode, [path.join(bundle, "bootstrap", "recovery-runner.cjs"), "--self-check"]);
|
|
109
|
-
if (!ok(checked)) throw Error(
|
|
142
|
+
if (!ok(checked)) throw Error(`recovery-bundle-verification-failed: ${commandDetail(checked)}`);
|
|
110
143
|
atomicFile(pointer, JSON.stringify({ schema: 1, version: incoming, node: runtimeNode, bundle }));
|
|
111
144
|
const launcher = path.join(root, "launch.cjs");
|
|
112
145
|
// Static launcher dispatches through a replaceable pointer. Never overwrite
|
|
@@ -201,4 +234,4 @@ function uninstallRecovery({ homeDir = os.homedir(), platform = process.platform
|
|
|
201
234
|
for (const file of files) fs.rmSync(file, { force: true });
|
|
202
235
|
return { ok: true };
|
|
203
236
|
}
|
|
204
|
-
module.exports = { installRecovery, uninstallRecovery, windowsRecoveryTaskXml, LABEL, TASK };
|
|
237
|
+
module.exports = { installRecovery, uninstallRecovery, windowsRecoveryTaskXml, renameWithRetry, commandDetail, LABEL, TASK };
|
|
@@ -1051,6 +1051,19 @@ async function activateRuntime(layout, runtime, version, {
|
|
|
1051
1051
|
return { candidate, cliLauncher };
|
|
1052
1052
|
}
|
|
1053
1053
|
|
|
1054
|
+
// The setup-intent marker, read by the pill (overlay/main.cjs readSetupIntent).
|
|
1055
|
+
// Plain setup opens the pill signed out for a person who will sign in there
|
|
1056
|
+
// (sendrelays.com's Get started); the marker is what lets the pill start that
|
|
1057
|
+
// sign-in without a click. The --code and --agent-protocol paths pair without
|
|
1058
|
+
// a pill sign-in, so they must not leave one behind. Only the fact, the time
|
|
1059
|
+
// and the version are recorded: never a credential.
|
|
1060
|
+
const SETUP_INTENT_FILE = "setup-intent.json";
|
|
1061
|
+
function writeSetupIntent(configDir, version, setupCompatibilityArgs = [], { now = new Date(), write = atomicWriteJson } = {}) {
|
|
1062
|
+
if (setupCompatibilityArgs.includes("--code") || setupCompatibilityArgs.includes("--agent-protocol")) return false;
|
|
1063
|
+
write(path.join(configDir, SETUP_INTENT_FILE), { agentInstalled: true, at: now.toISOString(), version });
|
|
1064
|
+
return true;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1054
1067
|
async function setup(argv = []) {
|
|
1055
1068
|
assertCompatibleNode();
|
|
1056
1069
|
const setupCompatibilityArgs = validateSetupCompatibilityArgs(argv);
|
|
@@ -1068,6 +1081,12 @@ async function setup(argv = []) {
|
|
|
1068
1081
|
if (setupCompatibilityArgs.includes("--agent-protocol") && process.env.RELAY_BACKGROUND_INSTALL_WORKER === "1") {
|
|
1069
1082
|
await require("./relay-background-install.cjs").waitForAgentAuthorization();
|
|
1070
1083
|
}
|
|
1084
|
+
// Before activation, so the pill it opens reads the marker on first paint.
|
|
1085
|
+
// A marker that cannot be written costs the person one click, not the
|
|
1086
|
+
// install, so it never fails setup.
|
|
1087
|
+
try {
|
|
1088
|
+
writeSetupIntent(process.env.RELAY_CONFIG_DIR || path.join(os.homedir(), ".relay"), version, setupCompatibilityArgs);
|
|
1089
|
+
} catch {}
|
|
1071
1090
|
const activated = await activateRuntime(layout, runtime, version, { setupCompatibilityArgs });
|
|
1072
1091
|
if (setupCompatibilityArgs.includes("--code")) {
|
|
1073
1092
|
console.log(`Relay ${version} is installed and paired. The Relay pill is open.`);
|
|
@@ -1203,6 +1222,7 @@ module.exports = {
|
|
|
1203
1222
|
validateArchiveEntries,
|
|
1204
1223
|
verifyExtractedRuntime,
|
|
1205
1224
|
waitForRuntimeHealth,
|
|
1225
|
+
writeSetupIntent,
|
|
1206
1226
|
};
|
|
1207
1227
|
|
|
1208
1228
|
if (require.main === module) {
|
package/package.json
CHANGED