cofluxd 2.15.0 → 2.16.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.
- package/README.md +27 -2
- package/account-client.mjs +69 -45
- package/coflux.mjs +226 -150
- package/cofluxd.mjs +885 -427
- package/output.mjs +93 -0
- package/package.json +3 -2
- package/release-trust.mjs +44 -36
- package/service-unit.mjs +52 -13
- package/skills/coflux/SKILL.md +6 -4
package/cofluxd.mjs
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// cofluxd
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// ptyd
|
|
2
|
+
// cofluxd: installs, starts, stops and updates the Coflux service on a device. It runs for one
|
|
3
|
+
// command and never stays resident.
|
|
4
|
+
//
|
|
5
|
+
// The service is three Rust processes: ptyd owns every PTY and lives long; the launcher owns the
|
|
6
|
+
// runtime version pointer and rollback and rarely changes; the runtime is updated by the server.
|
|
7
|
+
// Three service backends start them:
|
|
8
|
+
// - launchd (macOS) and systemd user units (Linux with systemd booted): ptyd and the launcher each
|
|
9
|
+
// get their own unit, so restarting the launcher leaves the terminals in ptyd.
|
|
10
|
+
// - self-managed (Linux without systemd: containers, WSL, OpenRC): `coflux-launcher watch` starts
|
|
11
|
+
// and supervises both. `cofluxd up` runs it in the background; `cofluxd run` in the foreground.
|
|
6
12
|
import { parseArgs } from "node:util";
|
|
7
13
|
import { homedir, hostname, platform, arch } from "node:os";
|
|
8
|
-
import { join, dirname } from "node:path";
|
|
14
|
+
import { join, dirname, basename } from "node:path";
|
|
9
15
|
import { createInterface } from "node:readline/promises";
|
|
10
16
|
import fs from "node:fs";
|
|
11
|
-
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
12
18
|
import dns from "node:dns/promises";
|
|
13
19
|
import net from "node:net";
|
|
14
20
|
import tls from "node:tls";
|
|
@@ -22,9 +28,10 @@ import {
|
|
|
22
28
|
parseReleaseManifestEntry,
|
|
23
29
|
verifyReleaseArtifact,
|
|
24
30
|
} from "./release-trust.mjs";
|
|
25
|
-
import { executorRuntime, plistXml, ptydPlistXml, ptydSystemdUnit, systemdUnit } from "./service-unit.mjs";
|
|
31
|
+
import { executorRuntime, plistXml, ptydPlistXml, ptydSystemdUnit, systemdUnit, watcherEnv } from "./service-unit.mjs";
|
|
32
|
+
import { bold, duration, error as printError, fail, info, step, success, table, warn } from "./output.mjs";
|
|
26
33
|
|
|
27
|
-
//
|
|
34
|
+
// The public server; self-hosted servers pass --server.
|
|
28
35
|
const DEFAULT_SERVER = "wss://api.coflux.dev/daemon";
|
|
29
36
|
|
|
30
37
|
const REPO = "myWsq/coflux";
|
|
@@ -41,44 +48,77 @@ const RELEASE_ARCHIVE_BASE = (
|
|
|
41
48
|
const MAX_RELEASE_METADATA_BYTES = 1024 * 1024;
|
|
42
49
|
const HOME = process.env.COFLUX_HOME || join(homedir(), ".coflux");
|
|
43
50
|
const BIN_DIR = join(HOME, "bin");
|
|
44
|
-
const SETTINGS = join(HOME, "settings.json"); //
|
|
51
|
+
const SETTINGS = join(HOME, "settings.json"); // serverUrl / deviceName / shell, read by the service; 0600
|
|
45
52
|
const LOG_FILE = join(HOME, "daemon.log");
|
|
46
53
|
const CRED = join(HOME, "credentials.json");
|
|
47
|
-
const PENDING_AUTH = join(HOME, "pending-auth.json"); //
|
|
48
|
-
// One-time join key (plan 20260924-device-join-keys): `up --key` writes it, the
|
|
54
|
+
const PENDING_AUTH = join(HOME, "pending-auth.json"); // the authorization link the runtime writes
|
|
55
|
+
// One-time join key (plan 20260924-device-join-keys): `up --key` writes it, the runtime presents it once
|
|
49
56
|
// and deletes it on the server's answer, recording what happened in the outcome file (0600 each).
|
|
50
57
|
const JOIN_KEY = join(HOME, "join-key.json");
|
|
51
58
|
const JOIN_OUTCOME = join(HOME, "join-outcome.json");
|
|
52
|
-
const CONN_STATE = join(HOME, "conn-state.json"); //
|
|
53
|
-
const LOCAL_GATEWAY_STORE = join(HOME, "local-gateway.json"); //
|
|
54
|
-
const FDA_STATUS = join(HOME, "fda-status"); //
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
// PTY 托管进程(plan 20260918-ptyd-terminal-custody):与 supervisor 平级的独立服务,supervisor 找不到它就拒绝启动。
|
|
59
|
+
const CONN_STATE = join(HOME, "conn-state.json"); // connection snapshot (crates/runtime/src/conn_state.rs)
|
|
60
|
+
const LOCAL_GATEWAY_STORE = join(HOME, "local-gateway.json"); // doctor reads its shape and counts only, never secrets
|
|
61
|
+
const FDA_STATUS = join(HOME, "fda-status"); // macOS only, written by the runtime (crates/runtime/src/fda.rs)
|
|
62
|
+
const LAUNCHER_BIN = join(BIN_DIR, "coflux-launcher");
|
|
63
|
+
const RUNTIME_BIN = join(BIN_DIR, "coflux-runtime");
|
|
58
64
|
const PTYD_BIN = join(BIN_DIR, "coflux-ptyd");
|
|
65
|
+
const CLI_BIN = join(BIN_DIR, "coflux");
|
|
59
66
|
const CLI_RELEASE_FLOOR = join(HOME, "cofluxd.release-floor");
|
|
60
|
-
const
|
|
67
|
+
const RUNTIME_RELEASE_FLOOR = join(HOME, "runtime.release-floor");
|
|
61
68
|
const IS_MAC = platform() === "darwin";
|
|
62
69
|
const IS_LINUX = platform() === "linux";
|
|
63
70
|
const PLIST = join(homedir(), "Library", "LaunchAgents", "com.coflux.daemon.plist");
|
|
64
71
|
const UNIT = join(homedir(), ".config", "systemd", "user", "coflux-daemon.service");
|
|
65
|
-
// ptyd
|
|
66
|
-
// KillMode=control-group
|
|
67
|
-
//
|
|
72
|
+
// ptyd runs as its own service (plan 20260918-ptyd-terminal-custody): one launchd plist runs one program,
|
|
73
|
+
// and systemd's default KillMode=control-group would take the whole cgroup down on `systemctl restart`.
|
|
74
|
+
// ptyd must sit outside the launcher's process group / cgroup for a restart to leave the terminals.
|
|
68
75
|
const PTYD_PLIST = join(homedir(), "Library", "LaunchAgents", "com.coflux.ptyd.plist");
|
|
69
76
|
const PTYD_UNIT = join(homedir(), ".config", "systemd", "user", "coflux-ptyd.service");
|
|
70
|
-
const DEFAULT_LOCAL_GATEWAY_PORT = 8788; //
|
|
77
|
+
const DEFAULT_LOCAL_GATEWAY_PORT = 8788; // same as LOCAL_GATEWAY_PORT in the protocol packages
|
|
78
|
+
|
|
79
|
+
// Self-managed mode (`coflux-launcher watch`, crates/launcher/src/watch.rs).
|
|
80
|
+
const SERVICE_MODE_FILE = join(HOME, "service-mode"); // "self-managed" once `up` chose it on this device
|
|
81
|
+
const WATCH_SOCK = join(HOME, "watch.sock");
|
|
82
|
+
const WATCH_STATE = join(HOME, "watch.json");
|
|
83
|
+
/** Found inside a launcher binary that has `watch`; an older launcher would ignore the argument. */
|
|
84
|
+
const WATCH_PROTOCOL = "coflux-watch/1";
|
|
85
|
+
/** The watcher's exit code when another service already owns this home. */
|
|
86
|
+
const WATCH_EXIT_REFUSED = 3;
|
|
71
87
|
|
|
72
|
-
const
|
|
88
|
+
const SYSTEMD_NEXT = "Check that systemd user services work in this session (systemctl --user status), then try again.";
|
|
89
|
+
const LAUNCHD_NEXT = "See cofluxd logs, then try again.";
|
|
90
|
+
|
|
91
|
+
const die = (what, next = "Run cofluxd --help for usage.") => fail(what, next);
|
|
73
92
|
const run = (cmd, args, opts = {}) => spawnSync(cmd, args, { encoding: "utf8", ...opts });
|
|
93
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
94
|
+
const messageOf = (error) => (error instanceof Error ? error.message : String(error));
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A launchctl / systemctl call whose failure must stop the command: `up` never reports success over a
|
|
98
|
+
* service manager that refused. Calls that only clear whatever may be loaded use `run` instead.
|
|
99
|
+
*/
|
|
100
|
+
function runChecked(cmd, args, next) {
|
|
101
|
+
const result = run(cmd, args);
|
|
102
|
+
if (result.status === 0) return result;
|
|
103
|
+
const detail = (result.stderr || result.stdout || "").trim().split("\n")[0] || (result.error ? result.error.message : `exit status ${result.status}`);
|
|
104
|
+
die(`${cmd} ${args.join(" ")} failed: ${detail}`, next);
|
|
105
|
+
}
|
|
74
106
|
|
|
75
107
|
function rustTarget() {
|
|
76
108
|
const p = platform(), a = arch();
|
|
77
109
|
if (p === "darwin") return a === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin";
|
|
78
110
|
if (p === "linux") return a === "arm64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl";
|
|
79
|
-
die(
|
|
111
|
+
die(`This platform is not supported: ${p}/${a}.`, "Coflux runs on macOS and Linux.");
|
|
80
112
|
}
|
|
81
113
|
|
|
114
|
+
/** `linux-x64`, `macos-arm64`: the platform as people name it. */
|
|
115
|
+
function platformLabel() {
|
|
116
|
+
return `${IS_MAC ? "macos" : platform()}-${arch()}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `2.15.0` for the tag `v2.15.0`. */
|
|
120
|
+
const displayVersion = (tag) => String(tag).replace(/^v/, "");
|
|
121
|
+
|
|
82
122
|
function readSettings() {
|
|
83
123
|
try { return JSON.parse(fs.readFileSync(SETTINGS, "utf8")); } catch { return {}; }
|
|
84
124
|
}
|
|
@@ -102,6 +142,11 @@ function readConnState() {
|
|
|
102
142
|
try { return JSON.parse(fs.readFileSync(CONN_STATE, "utf8")); } catch { return null; }
|
|
103
143
|
}
|
|
104
144
|
|
|
145
|
+
/** The host people recognise in a server URL: `api.coflux.dev`. */
|
|
146
|
+
function serverHost(serverUrl) {
|
|
147
|
+
try { return new URL(serverUrl).host || serverUrl; } catch { return serverUrl || "unknown server"; }
|
|
148
|
+
}
|
|
149
|
+
|
|
105
150
|
function readLocalGatewaySummary() {
|
|
106
151
|
let store;
|
|
107
152
|
try { store = JSON.parse(fs.readFileSync(LOCAL_GATEWAY_STORE, "utf8")); }
|
|
@@ -109,26 +154,24 @@ function readLocalGatewaySummary() {
|
|
|
109
154
|
return {
|
|
110
155
|
ok: false,
|
|
111
156
|
ready: false,
|
|
112
|
-
// JSON.parse
|
|
113
|
-
|
|
157
|
+
// JSON.parse errors can quote the file on some Node versions; the store holds private keys and
|
|
158
|
+
// diagnostics must never echo it.
|
|
159
|
+
error: fs.existsSync(LOCAL_GATEWAY_STORE) ? "pairing data cannot be read" : "no pairing data yet",
|
|
114
160
|
};
|
|
115
161
|
}
|
|
116
162
|
if (store?.version !== 1 || !Array.isArray(store.origins) || !Array.isArray(store.grants)) {
|
|
117
|
-
return { ok: false, ready: false, error: "
|
|
163
|
+
return { ok: false, ready: false, error: "pairing data is not valid" };
|
|
118
164
|
}
|
|
119
165
|
const origins = store.origins.filter((origin) => typeof origin === "string" && origin.length > 0);
|
|
120
166
|
const grants = store.grants.filter((grant) => grant && typeof grant.origin === "string" && grant.origin.length > 0);
|
|
121
167
|
const origin = grants.find((grant) => origins.includes(grant.origin))?.origin || origins[0];
|
|
122
168
|
const ready = grants.length > 0 && !!origin;
|
|
123
|
-
const summary = ready
|
|
124
|
-
? `${grants.length} 个持久 grant / ${origins.length} 个 Origin`
|
|
125
|
-
: `${grants.length} 个 grant / ${origins.length} 个 Origin(尚无可用浏览器配对)`;
|
|
126
169
|
return {
|
|
127
170
|
ok: ready,
|
|
128
171
|
ready,
|
|
129
172
|
origin,
|
|
130
|
-
detail: ready ?
|
|
131
|
-
error: ready ? undefined :
|
|
173
|
+
detail: ready ? `${grants.length} paired browser${grants.length === 1 ? "" : "s"}` : undefined,
|
|
174
|
+
error: ready ? undefined : "no paired browser yet",
|
|
132
175
|
};
|
|
133
176
|
}
|
|
134
177
|
|
|
@@ -137,44 +180,30 @@ function localGatewayPort() {
|
|
|
137
180
|
if (raw === undefined || raw === "") return { ok: true, port: DEFAULT_LOCAL_GATEWAY_PORT };
|
|
138
181
|
const port = Number(raw);
|
|
139
182
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
140
|
-
return { ok: false, error: `COFLUX_LOCAL_GATEWAY_PORT=${raw}
|
|
183
|
+
return { ok: false, error: `COFLUX_LOCAL_GATEWAY_PORT=${raw} is not a valid port` };
|
|
141
184
|
}
|
|
142
185
|
return { ok: true, port };
|
|
143
186
|
}
|
|
144
|
-
const CONN_STATE_LABEL = { connecting: "连接中", connected: "已连接", reconnecting: "重连中" };
|
|
145
|
-
function formatDuration(ms) {
|
|
146
|
-
const s = Math.max(0, Math.round(ms / 1000));
|
|
147
|
-
if (s < 60) return `${s}秒`;
|
|
148
|
-
const m = Math.floor(s / 60);
|
|
149
|
-
if (m < 60) return `${m}分钟`;
|
|
150
|
-
const h = Math.floor(m / 60);
|
|
151
|
-
return `${h}小时${m % 60}分钟`;
|
|
152
|
-
}
|
|
153
187
|
|
|
154
|
-
|
|
188
|
+
/** "granted" | "denied" | "unknown" | null (no file: the service has not written it yet). */
|
|
155
189
|
function readFdaStatus() {
|
|
156
190
|
try { return fs.readFileSync(FDA_STATUS, "utf8").trim(); } catch { return null; }
|
|
157
191
|
}
|
|
158
|
-
function fdaLabel(status) {
|
|
159
|
-
return status === "granted" ? "已授予" : status === "denied" ? "未授予" : "未知";
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
163
192
|
|
|
164
193
|
function fileSha256(path) {
|
|
165
194
|
try { return crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex"); } catch { return null; }
|
|
166
195
|
}
|
|
167
196
|
|
|
168
|
-
// macOS
|
|
169
|
-
// OS_REASON_CODESIGNING
|
|
170
|
-
// v0.13.0
|
|
171
|
-
//
|
|
197
|
+
// macOS: a freshly written binary carries com.apple.provenance, and launchd's top-level spawn gets it
|
|
198
|
+
// killed by AMFI with OS_REASON_CODESIGNING, even when it is Developer ID signed and notarized
|
|
199
|
+
// (observed with v0.13.0 on 2026-07-25). An ad-hoc local re-sign makes it a local product. Remote
|
|
200
|
+
// artifacts must pass release statement verification before they get here; never re-sign anything else.
|
|
172
201
|
function resignMacBinaries(paths) {
|
|
173
202
|
if (!IS_MAC) return;
|
|
174
203
|
for (const p of paths) {
|
|
175
204
|
const r = run("codesign", ["--force", "-s", "-", p]);
|
|
176
205
|
if (r.status !== 0) {
|
|
177
|
-
throw new Error(`
|
|
206
|
+
throw new Error(`could not re-sign ${basename(p)} for macOS`);
|
|
178
207
|
}
|
|
179
208
|
}
|
|
180
209
|
}
|
|
@@ -184,12 +213,12 @@ async function fetchBounded(url, maxBytes, label) {
|
|
|
184
213
|
redirect: "follow",
|
|
185
214
|
signal: AbortSignal.timeout(30_000),
|
|
186
215
|
});
|
|
187
|
-
if (!res.ok) throw new Error(`${label}
|
|
216
|
+
if (!res.ok) throw new Error(`${label}: HTTP ${res.status}`);
|
|
188
217
|
const declaredLength = Number(res.headers.get("content-length"));
|
|
189
218
|
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
190
|
-
throw new Error(`${label}
|
|
219
|
+
throw new Error(`${label} is larger than allowed`);
|
|
191
220
|
}
|
|
192
|
-
if (!res.body) throw new Error(`${label}
|
|
221
|
+
if (!res.body) throw new Error(`${label} came back empty`);
|
|
193
222
|
const chunks = [];
|
|
194
223
|
let total = 0;
|
|
195
224
|
for await (const chunk of res.body) {
|
|
@@ -197,7 +226,7 @@ async function fetchBounded(url, maxBytes, label) {
|
|
|
197
226
|
total += bytes.byteLength;
|
|
198
227
|
if (total > maxBytes) {
|
|
199
228
|
await res.body.cancel().catch(() => {});
|
|
200
|
-
throw new Error(`${label}
|
|
229
|
+
throw new Error(`${label} is larger than allowed`);
|
|
201
230
|
}
|
|
202
231
|
chunks.push(bytes);
|
|
203
232
|
}
|
|
@@ -210,34 +239,36 @@ function loadReleasePublicKey() {
|
|
|
210
239
|
return createReleasePublicKey(publicKeyHex);
|
|
211
240
|
}
|
|
212
241
|
|
|
242
|
+
const FLOOR_NEXT = "Reinstall Coflux on this device: cofluxd uninstall --purge, then cofluxd up.";
|
|
243
|
+
|
|
213
244
|
function readReleaseFloor(path, label) {
|
|
214
245
|
let metadata;
|
|
215
246
|
try { metadata = fs.lstatSync(path); }
|
|
216
247
|
catch (error) {
|
|
217
248
|
if (error?.code === "ENOENT") return undefined;
|
|
218
|
-
throw new Error(
|
|
249
|
+
throw new Error(`The local version record ${label} cannot be read.`);
|
|
219
250
|
}
|
|
220
251
|
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size <= 0 || metadata.size > 128) {
|
|
221
|
-
throw new Error(
|
|
252
|
+
throw new Error(`The local version record ${label} is not a small regular file.`);
|
|
222
253
|
}
|
|
223
254
|
let value;
|
|
224
255
|
try { value = fs.readFileSync(path, "utf8").trim(); }
|
|
225
|
-
catch { throw new Error(
|
|
256
|
+
catch { throw new Error(`The local version record ${label} cannot be read.`); }
|
|
226
257
|
try { assertReleaseVersion(value); }
|
|
227
|
-
catch { throw new Error(
|
|
258
|
+
catch { throw new Error(`The local version record ${label} is corrupted.`); }
|
|
228
259
|
return value;
|
|
229
260
|
}
|
|
230
261
|
|
|
231
262
|
function currentReleaseFloor() {
|
|
232
263
|
const floors = [
|
|
233
264
|
readReleaseFloor(CLI_RELEASE_FLOOR, "cofluxd.release-floor"),
|
|
234
|
-
readReleaseFloor(
|
|
265
|
+
readReleaseFloor(RUNTIME_RELEASE_FLOOR, "runtime.release-floor"),
|
|
235
266
|
].filter(Boolean);
|
|
236
267
|
return floors.reduce((highest, candidate) => {
|
|
237
268
|
if (!highest) return candidate;
|
|
238
269
|
const order = compareReleaseVersions(candidate, highest);
|
|
239
270
|
if (order === 0 && candidate !== highest) {
|
|
240
|
-
throw new Error(
|
|
271
|
+
throw new Error(`The local version records disagree: ${highest} and ${candidate}.`);
|
|
241
272
|
}
|
|
242
273
|
return order > 0 ? candidate : highest;
|
|
243
274
|
}, undefined);
|
|
@@ -270,7 +301,7 @@ async function readMirrorLatestTag() {
|
|
|
270
301
|
const body = await fetchBounded(
|
|
271
302
|
`${RELEASE_MIRROR_BASE}/latest.json`,
|
|
272
303
|
MAX_RELEASE_METADATA_BYTES,
|
|
273
|
-
"
|
|
304
|
+
"latest.json",
|
|
274
305
|
);
|
|
275
306
|
const tag = JSON.parse(body.toString("utf8"))?.version;
|
|
276
307
|
if (typeof tag !== "string") return null;
|
|
@@ -281,18 +312,23 @@ async function readMirrorLatestTag() {
|
|
|
281
312
|
}
|
|
282
313
|
}
|
|
283
314
|
|
|
284
|
-
|
|
285
|
-
|
|
315
|
+
/**
|
|
316
|
+
* Put the binaries in place. Returns the version installed: a release tag, or "local build".
|
|
317
|
+
*
|
|
318
|
+
* skipIfPresent: `up` and `run` are idempotent. When the binaries exist and no --version was given they
|
|
319
|
+
* are not downloaded again, so running `up` twice never upgrades by surprise (plan 035). `update`,
|
|
320
|
+
* --bin-dir and an explicit --version are not affected.
|
|
321
|
+
*/
|
|
286
322
|
async function ensureBinaries({ version, binDir, skipIfPresent }) {
|
|
287
323
|
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
288
324
|
if (binDir) {
|
|
289
|
-
const localArtifacts = ["coflux-
|
|
325
|
+
const localArtifacts = ["coflux-launcher", "coflux-runtime", "coflux-ptyd", ...(fs.existsSync(join(binDir, "coflux")) ? ["coflux"] : []), ...(fs.existsSync(join(binDir, "coflux-transport")) ? ["coflux-transport"] : [])].map((name) => ({
|
|
290
326
|
name,
|
|
291
327
|
path: join(binDir, name),
|
|
292
328
|
}));
|
|
293
329
|
for (const artifact of localArtifacts) {
|
|
294
330
|
if (!fs.existsSync(artifact.path)) {
|
|
295
|
-
die(
|
|
331
|
+
die(`Missing local build: ${artifact.path}`, "Build it first, for example with cargo build --release.");
|
|
296
332
|
}
|
|
297
333
|
}
|
|
298
334
|
const stageDir = fs.mkdtempSync(join(BIN_DIR, ".coflux-local-install-"));
|
|
@@ -319,14 +355,14 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
|
|
|
319
355
|
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
320
356
|
}
|
|
321
357
|
if (localFailure) {
|
|
322
|
-
die(
|
|
358
|
+
die(`Could not install the local build: ${messageOf(localFailure)}`, "Check the files in --bin-dir, then try again.");
|
|
323
359
|
}
|
|
324
|
-
|
|
325
|
-
return;
|
|
360
|
+
success(`Using the local build in ${binDir}`);
|
|
361
|
+
return "local build";
|
|
326
362
|
}
|
|
327
|
-
if (skipIfPresent && !version && fs.existsSync(
|
|
328
|
-
|
|
329
|
-
return;
|
|
363
|
+
if (skipIfPresent && !version && fs.existsSync(LAUNCHER_BIN) && fs.existsSync(RUNTIME_BIN) && fs.existsSync(PTYD_BIN)) {
|
|
364
|
+
success("Using the installed Coflux. To upgrade, run cofluxd update.");
|
|
365
|
+
return null;
|
|
330
366
|
}
|
|
331
367
|
const target = rustTarget();
|
|
332
368
|
let releaseVersion;
|
|
@@ -335,13 +371,12 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
|
|
|
335
371
|
const tag = await readMirrorLatestTag();
|
|
336
372
|
if (!tag) {
|
|
337
373
|
die(
|
|
338
|
-
|
|
339
|
-
"
|
|
374
|
+
`Cannot read the latest version from ${RELEASE_MIRROR_BASE}/latest.json.`,
|
|
375
|
+
"Pass a version to install it from GitHub Releases: --version vX.Y.Z",
|
|
340
376
|
);
|
|
341
377
|
}
|
|
342
378
|
releaseVersion = tag;
|
|
343
379
|
releaseBase = RELEASE_MIRROR_BASE;
|
|
344
|
-
console.log(`最新版本: ${releaseVersion}`);
|
|
345
380
|
} else {
|
|
346
381
|
releaseVersion = version;
|
|
347
382
|
// Only the tag the mirror's pointer names lives on the mirror; any other version (older, newer or a
|
|
@@ -351,7 +386,7 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
|
|
|
351
386
|
try {
|
|
352
387
|
assertReleaseVersion(releaseVersion);
|
|
353
388
|
} catch (error) {
|
|
354
|
-
die(error
|
|
389
|
+
die(messageOf(error), "Pass a release tag such as --version v2.15.0, or --version latest.");
|
|
355
390
|
}
|
|
356
391
|
let releaseFloor;
|
|
357
392
|
try {
|
|
@@ -360,15 +395,18 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
|
|
|
360
395
|
? compareReleaseVersions(releaseVersion, releaseFloor)
|
|
361
396
|
: 1;
|
|
362
397
|
if (floorOrder < 0 || (floorOrder === 0 && releaseVersion !== releaseFloor)) {
|
|
363
|
-
throw
|
|
398
|
+
throw Object.assign(
|
|
399
|
+
new Error(`Release ${releaseVersion} is not newer than the version this device already trusts (${releaseFloor}); refusing a downgrade or replay.`),
|
|
400
|
+
{ next: "Install a newer release, or run cofluxd update for the latest one." },
|
|
401
|
+
);
|
|
364
402
|
}
|
|
365
403
|
} catch (error) {
|
|
366
|
-
die(error
|
|
404
|
+
die(messageOf(error), error?.next || FLOOR_NEXT);
|
|
367
405
|
}
|
|
368
406
|
|
|
369
407
|
const base = `${releaseBase}/${releaseVersion}`;
|
|
370
|
-
console.log(`下载源: ${base}`);
|
|
371
408
|
const stageDir = fs.mkdtempSync(join(BIN_DIR, ".coflux-release-install-"));
|
|
409
|
+
const download = step(`Downloading Coflux ${displayVersion(releaseVersion)} (${platformLabel()})`);
|
|
372
410
|
let failure;
|
|
373
411
|
try {
|
|
374
412
|
const manifestBytes = await fetchBounded(
|
|
@@ -380,14 +418,13 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
|
|
|
380
418
|
try {
|
|
381
419
|
manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
382
420
|
} catch {
|
|
383
|
-
throw new Error("release manifest
|
|
421
|
+
throw new Error("the release manifest is not valid JSON");
|
|
384
422
|
}
|
|
385
423
|
const publicKey = loadReleasePublicKey();
|
|
386
424
|
const staged = [];
|
|
387
|
-
for (const component of ["
|
|
425
|
+
for (const component of ["launcher", "runtime", ...(manifest.cli ? ["cli"] : []), ...(manifest.transport ? ["transport"] : []), ...(manifest.ptyd ? ["ptyd"] : [])]) {
|
|
388
426
|
const entry = parseReleaseManifestEntry(manifest, component, releaseVersion, target);
|
|
389
427
|
const artifactName = `coflux-${component}-${target}`;
|
|
390
|
-
process.stdout.write(`下载并验签 ${artifactName} … `);
|
|
391
428
|
const data = await fetchBounded(
|
|
392
429
|
`${base}/${artifactName}`,
|
|
393
430
|
Math.min(entry.size, MAX_RELEASE_ARTIFACT_BYTES),
|
|
@@ -399,11 +436,10 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
|
|
|
399
436
|
fs.chmodSync(source, 0o755);
|
|
400
437
|
staged.push({
|
|
401
438
|
source,
|
|
402
|
-
destination: component === "cli" ?
|
|
439
|
+
destination: component === "cli" ? CLI_BIN : component === "launcher" ? LAUNCHER_BIN : component === "transport" ? join(BIN_DIR, "coflux-transport") : component === "ptyd" ? PTYD_BIN : RUNTIME_BIN,
|
|
403
440
|
});
|
|
404
|
-
console.log("✓");
|
|
405
441
|
}
|
|
406
|
-
//
|
|
442
|
+
// Every remote artifact verified against the same release root before any local transform / replacement.
|
|
407
443
|
resignMacBinaries(staged.map(({ source }) => source));
|
|
408
444
|
if (manifest.transport) {
|
|
409
445
|
const source = join(stageDir, "TRANSPORT-NOTICES.txt");
|
|
@@ -412,8 +448,8 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
|
|
|
412
448
|
fs.writeFileSync(source, notices, { mode: 0o644 });
|
|
413
449
|
staged.push({ source, destination: join(BIN_DIR, "TRANSPORT-NOTICES.txt") });
|
|
414
450
|
}
|
|
415
|
-
// floor
|
|
416
|
-
//
|
|
451
|
+
// The floor is committed before the binaries: a crash in between leaves at worst old binaries with
|
|
452
|
+
// a higher floor. Re-running the same version is still allowed; no older valid release can slip in.
|
|
417
453
|
if (!releaseFloor || compareReleaseVersions(releaseVersion, releaseFloor) > 0) {
|
|
418
454
|
persistCliReleaseFloor(releaseVersion);
|
|
419
455
|
}
|
|
@@ -424,11 +460,18 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
|
|
|
424
460
|
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
425
461
|
}
|
|
426
462
|
if (failure) {
|
|
427
|
-
|
|
463
|
+
download.failed();
|
|
464
|
+
die(
|
|
465
|
+
`Could not install Coflux ${displayVersion(releaseVersion)} from ${base}: ${messageOf(failure)}`,
|
|
466
|
+
"Nothing on this device was changed. Check your network, then try again.",
|
|
467
|
+
);
|
|
428
468
|
}
|
|
469
|
+
download.done();
|
|
470
|
+
success("Signature verified");
|
|
471
|
+
return releaseVersion;
|
|
429
472
|
}
|
|
430
473
|
|
|
431
|
-
//
|
|
474
|
+
// settings.json, read by the service directly.
|
|
432
475
|
function applyConfig({ serverUrl, deviceName, shell }) {
|
|
433
476
|
fs.mkdirSync(HOME, { recursive: true });
|
|
434
477
|
fs.chmodSync(HOME, 0o700);
|
|
@@ -439,320 +482,704 @@ function applyConfig({ serverUrl, deviceName, shell }) {
|
|
|
439
482
|
}
|
|
440
483
|
|
|
441
484
|
/**
|
|
442
|
-
* The executor runtime recorded
|
|
485
|
+
* The executor runtime recorded for the service, worked out once per command. See
|
|
443
486
|
* `service-unit.mjs`: null simply means this installation does not host an executor.
|
|
444
487
|
*/
|
|
445
488
|
function executorUnitRuntime() {
|
|
446
489
|
const runtime = executorRuntime();
|
|
447
|
-
if (!runtime)
|
|
490
|
+
if (!runtime) warn("This installation cannot run the executor.", "Reinstall cofluxd to add it, or use Coflux.app on this machine.");
|
|
448
491
|
return runtime;
|
|
449
492
|
}
|
|
450
493
|
|
|
451
|
-
/**
|
|
494
|
+
/** The four service files: paths and text (`cofluxd service-files` prints them as they are). */
|
|
452
495
|
function serviceFiles(executor = executorRuntime()) {
|
|
453
496
|
return {
|
|
454
497
|
launchd: {
|
|
455
|
-
|
|
498
|
+
launcher: { path: PLIST, text: plistXml({ launcherBin: LAUNCHER_BIN, home: HOME, logFile: LOG_FILE, executor }) },
|
|
456
499
|
ptyd: { path: PTYD_PLIST, text: ptydPlistXml({ ptydBin: PTYD_BIN, home: HOME, logFile: LOG_FILE }) },
|
|
457
500
|
},
|
|
458
501
|
systemd: {
|
|
459
|
-
|
|
502
|
+
launcher: { path: UNIT, text: systemdUnit({ launcherBin: LAUNCHER_BIN, home: HOME, executor }) },
|
|
460
503
|
ptyd: { path: PTYD_UNIT, text: ptydSystemdUnit({ ptydBin: PTYD_BIN, home: HOME }) },
|
|
461
504
|
},
|
|
462
505
|
};
|
|
463
506
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
507
|
+
|
|
508
|
+
/* ------------------------------ service modes ------------------------------ */
|
|
509
|
+
|
|
510
|
+
/** sd_booted(): systemd is PID 1. A `systemctl` binary alone proves nothing in a container. */
|
|
511
|
+
function systemdBooted() {
|
|
512
|
+
return fs.existsSync("/run/systemd/system");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function readServiceMode() {
|
|
516
|
+
try { return fs.readFileSync(SERVICE_MODE_FILE, "utf8").trim(); } catch { return ""; }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function writeServiceMode() {
|
|
520
|
+
fs.writeFileSync(SERVICE_MODE_FILE, "self-managed\n", { mode: 0o600 });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function clearServiceMode() {
|
|
524
|
+
try { fs.rmSync(SERVICE_MODE_FILE, { force: true }); } catch { /* */ }
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The backend this device runs under: recorded once `up` chose self-managed, never re-detected. */
|
|
528
|
+
function serviceMode() {
|
|
529
|
+
if (IS_MAC) return "launchd";
|
|
530
|
+
if (!IS_LINUX) die("Coflux runs only on macOS and Linux.", "Use a macOS or Linux machine.");
|
|
531
|
+
if (readServiceMode() === "self-managed") return "self-managed";
|
|
532
|
+
return systemdBooted() ? "systemd" : "self-managed";
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** One request to the running watcher; null when no watcher answers. */
|
|
536
|
+
function watchRequest(op, timeoutMs = 5000) {
|
|
537
|
+
return new Promise((resolve) => {
|
|
538
|
+
const socket = net.createConnection(WATCH_SOCK);
|
|
539
|
+
let text = "";
|
|
540
|
+
let settled = false;
|
|
541
|
+
const finish = (value) => {
|
|
542
|
+
if (settled) return;
|
|
543
|
+
settled = true;
|
|
544
|
+
socket.destroy();
|
|
545
|
+
resolve(value);
|
|
546
|
+
};
|
|
547
|
+
socket.setEncoding("utf8");
|
|
548
|
+
socket.setTimeout(timeoutMs, () => finish(null));
|
|
549
|
+
socket.on("error", () => finish(null));
|
|
550
|
+
socket.on("connect", () => socket.write(JSON.stringify({ op }) + "\n"));
|
|
551
|
+
socket.on("data", (chunk) => {
|
|
552
|
+
text += chunk;
|
|
553
|
+
const end = text.indexOf("\n");
|
|
554
|
+
if (end < 0) return;
|
|
555
|
+
try { finish(JSON.parse(text.slice(0, end))); } catch { finish(null); }
|
|
556
|
+
});
|
|
557
|
+
socket.on("end", () => finish(null));
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** A launcher from before `watch` ignores its arguments and would start next to nothing. */
|
|
562
|
+
function launcherSupportsWatch() {
|
|
563
|
+
try { return fs.readFileSync(LAUNCHER_BIN).includes(WATCH_PROTOCOL); } catch { return false; }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function requireWatchSupport() {
|
|
567
|
+
if (!launcherSupportsWatch()) {
|
|
568
|
+
die("The installed Coflux is too old to run without systemd.", "Run cofluxd update, then try again.");
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function pidAlive(pid) {
|
|
573
|
+
if (!Number.isInteger(pid) || pid <= 1) return false;
|
|
574
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error?.code === "EPERM"; }
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** The executable name of a process, to make sure a recorded pid still is that process. */
|
|
578
|
+
function processName(pid) {
|
|
579
|
+
try { return basename(fs.readlinkSync(`/proc/${pid}/exe`)).replace(/ \(deleted\)$/, ""); } catch { /* not Linux */ }
|
|
580
|
+
const result = run("ps", ["-p", String(pid), "-o", "comm="]);
|
|
581
|
+
return result.status === 0 ? basename(result.stdout.trim()) : "";
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function waitGone(pid, ms) {
|
|
585
|
+
const deadline = Date.now() + ms;
|
|
586
|
+
while (pidAlive(pid)) {
|
|
587
|
+
if (Date.now() >= deadline) return false;
|
|
588
|
+
await sleep(100);
|
|
589
|
+
}
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* No watcher answers, but a killed one may have left its children running (they live in their own
|
|
595
|
+
* sessions). Stop them in the watcher's order: the launcher first, then ptyd alone.
|
|
596
|
+
*/
|
|
597
|
+
async function stopLeftovers() {
|
|
598
|
+
let state;
|
|
599
|
+
try { state = JSON.parse(fs.readFileSync(WATCH_STATE, "utf8")); } catch { return; }
|
|
600
|
+
const launcher = Number(state?.launcherPid);
|
|
601
|
+
if (pidAlive(launcher) && processName(launcher).startsWith("coflux-launcher")) {
|
|
602
|
+
try { process.kill(launcher, "SIGTERM"); } catch { /* already gone */ }
|
|
603
|
+
if (!(await waitGone(launcher, 10_000))) {
|
|
604
|
+
try { process.kill(-launcher, "SIGKILL"); } catch { /* */ }
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const ptyd = Number(state?.ptydPid);
|
|
608
|
+
if (pidAlive(ptyd) && processName(ptyd).startsWith("coflux-ptyd")) {
|
|
609
|
+
try { process.kill(ptyd, "SIGTERM"); } catch { /* already gone */ }
|
|
610
|
+
if (!(await waitGone(ptyd, 15_000))) {
|
|
611
|
+
try { process.kill(ptyd, "SIGKILL"); } catch { /* */ }
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
try { fs.rmSync(WATCH_STATE, { force: true }); } catch { /* */ }
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Start the self-managed service in the background and wait until it runs. */
|
|
618
|
+
async function startSelfManaged(executor) {
|
|
619
|
+
requireWatchSupport();
|
|
620
|
+
fs.mkdirSync(HOME, { recursive: true });
|
|
621
|
+
const logFd = fs.openSync(LOG_FILE, "a", 0o600);
|
|
622
|
+
let exited = null;
|
|
623
|
+
try {
|
|
624
|
+
// A new session, no stdin, output to daemon.log: logging out of SSH must not take it down.
|
|
625
|
+
const child = spawn(LAUNCHER_BIN, ["watch"], {
|
|
626
|
+
detached: true,
|
|
627
|
+
stdio: ["ignore", logFd, logFd],
|
|
628
|
+
env: watcherEnv(process.env, { home: HOME, executor }),
|
|
629
|
+
});
|
|
630
|
+
child.once("error", (error) => { exited = { error }; });
|
|
631
|
+
child.once("exit", (code, signal) => { exited = { code, signal }; });
|
|
632
|
+
child.unref();
|
|
633
|
+
} finally {
|
|
634
|
+
fs.closeSync(logFd);
|
|
635
|
+
}
|
|
636
|
+
const deadline = Date.now() + 20_000;
|
|
637
|
+
while (Date.now() < deadline) {
|
|
638
|
+
if (exited) break;
|
|
639
|
+
const status = await watchRequest("status", 1000);
|
|
640
|
+
if (status?.ok && status.launcher?.running) return;
|
|
641
|
+
await sleep(200);
|
|
642
|
+
}
|
|
643
|
+
if (exited?.code === WATCH_EXIT_REFUSED) {
|
|
644
|
+
die("Coflux is already running on this device under another service.", "Stop it first with cofluxd down. If the Coflux app runs on this machine, quit it.");
|
|
645
|
+
}
|
|
646
|
+
die(exited ? "The service exited while starting." : "The service did not start in time.", "See what went wrong with cofluxd logs.");
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Stop the self-managed service: the watcher if one answers, else whatever a killed one left. */
|
|
650
|
+
async function stopSelfManaged() {
|
|
651
|
+
const reply = await watchRequest("stop", 60_000);
|
|
652
|
+
if (reply) {
|
|
653
|
+
const deadline = Date.now() + 10_000;
|
|
654
|
+
while (Date.now() < deadline && (await watchRequest("status", 500))) await sleep(200);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
await stopLeftovers();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function installService(mode, start, executor) {
|
|
661
|
+
const files = serviceFiles(executor);
|
|
662
|
+
if (mode === "launchd") {
|
|
467
663
|
fs.mkdirSync(dirname(PLIST), { recursive: true });
|
|
468
664
|
fs.writeFileSync(PTYD_PLIST, files.launchd.ptyd.text);
|
|
469
|
-
fs.writeFileSync(PLIST, files.launchd.
|
|
665
|
+
fs.writeFileSync(PLIST, files.launchd.launcher.text);
|
|
470
666
|
if (start) {
|
|
471
|
-
// ptyd
|
|
667
|
+
// ptyd first: a runtime that cannot reach it exits, and a KeepAlive retry is wasted work.
|
|
472
668
|
run("launchctl", ["unload", PLIST]);
|
|
473
669
|
run("launchctl", ["unload", PTYD_PLIST]);
|
|
474
|
-
|
|
475
|
-
|
|
670
|
+
runChecked("launchctl", ["load", PTYD_PLIST], LAUNCHD_NEXT);
|
|
671
|
+
runChecked("launchctl", ["load", PLIST], LAUNCHD_NEXT);
|
|
476
672
|
}
|
|
477
|
-
|
|
478
|
-
} else if (IS_LINUX) {
|
|
673
|
+
} else if (mode === "systemd") {
|
|
479
674
|
fs.mkdirSync(dirname(UNIT), { recursive: true });
|
|
480
675
|
fs.writeFileSync(PTYD_UNIT, files.systemd.ptyd.text);
|
|
481
|
-
fs.writeFileSync(UNIT, files.systemd.
|
|
676
|
+
fs.writeFileSync(UNIT, files.systemd.launcher.text);
|
|
482
677
|
if (start) {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
678
|
+
runChecked("systemctl", ["--user", "daemon-reload"], SYSTEMD_NEXT);
|
|
679
|
+
runChecked("systemctl", ["--user", "enable", "--now", "coflux-ptyd.service"], SYSTEMD_NEXT);
|
|
680
|
+
runChecked("systemctl", ["--user", "enable", "--now", "coflux-daemon.service"], SYSTEMD_NEXT);
|
|
486
681
|
}
|
|
487
|
-
|
|
488
|
-
} else die("仅支持 macOS / Linux");
|
|
682
|
+
}
|
|
489
683
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
684
|
+
|
|
685
|
+
/** Restart the launcher (and its runtime) only: the terminals stay in ptyd. Starts ptyd if it is not running. */
|
|
686
|
+
async function restartService(mode, executor) {
|
|
687
|
+
if (mode === "launchd") {
|
|
688
|
+
run("launchctl", ["load", PTYD_PLIST]); // already loaded is fine
|
|
494
689
|
run("launchctl", ["unload", PLIST]);
|
|
495
|
-
|
|
496
|
-
} else if (
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
690
|
+
runChecked("launchctl", ["load", PLIST], LAUNCHD_NEXT);
|
|
691
|
+
} else if (mode === "systemd") {
|
|
692
|
+
runChecked("systemctl", ["--user", "daemon-reload"], SYSTEMD_NEXT);
|
|
693
|
+
runChecked("systemctl", ["--user", "enable", "--now", "coflux-ptyd.service"], SYSTEMD_NEXT);
|
|
694
|
+
runChecked("systemctl", ["--user", "restart", "coflux-daemon.service"], SYSTEMD_NEXT);
|
|
695
|
+
} else {
|
|
696
|
+
const reply = await watchRequest("restart", 60_000);
|
|
697
|
+
if (reply?.ok) return;
|
|
698
|
+
if (reply) die(`The service could not restart: ${reply.error || "unknown reason"}`, "See what went wrong with cofluxd logs.");
|
|
699
|
+
await startSelfManaged(executor);
|
|
500
700
|
}
|
|
501
701
|
}
|
|
502
|
-
/** 重启两者(ptyd 自身更新):会结束本机全部终端。 */
|
|
503
|
-
function restartAllServices() {
|
|
504
|
-
stopService();
|
|
505
|
-
if (IS_MAC) { run("launchctl", ["load", PTYD_PLIST]); run("launchctl", ["load", PLIST]); }
|
|
506
|
-
else if (IS_LINUX) { run("systemctl", ["--user", "start", "coflux-ptyd.service"]); run("systemctl", ["--user", "start", "coflux-daemon.service"]); }
|
|
507
|
-
}
|
|
508
|
-
/** 停止本机 daemon = supervisor + ptyd 一起停(终端结束)。 */
|
|
509
|
-
function stopService() {
|
|
510
|
-
if (IS_MAC) { run("launchctl", ["unload", PLIST]); run("launchctl", ["unload", PTYD_PLIST]); }
|
|
511
|
-
else if (IS_LINUX) { run("systemctl", ["--user", "stop", "coflux-daemon.service"]); run("systemctl", ["--user", "stop", "coflux-ptyd.service"]); }
|
|
512
|
-
}
|
|
513
702
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
703
|
+
/** Restart both, ptyd included (a ptyd update): this ends every terminal on the device. */
|
|
704
|
+
async function restartAllServices(mode, executor) {
|
|
705
|
+
if (mode === "self-managed") {
|
|
706
|
+
const status = await watchRequest("status");
|
|
707
|
+
if (status?.mode === "foreground") {
|
|
708
|
+
// `cofluxd run` owns its watcher; it restarts everything in place.
|
|
709
|
+
const reply = await watchRequest("restart-all", 120_000);
|
|
710
|
+
if (!reply?.ok) die(`The service could not restart: ${reply?.error || "no answer"}`, "See what went wrong with cofluxd logs.");
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
await stopSelfManaged();
|
|
714
|
+
await startSelfManaged(executor);
|
|
715
|
+
return;
|
|
520
716
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
717
|
+
stopService(mode);
|
|
718
|
+
if (mode === "launchd") {
|
|
719
|
+
runChecked("launchctl", ["load", PTYD_PLIST], LAUNCHD_NEXT);
|
|
720
|
+
runChecked("launchctl", ["load", PLIST], LAUNCHD_NEXT);
|
|
721
|
+
} else if (mode === "systemd") {
|
|
722
|
+
runChecked("systemctl", ["--user", "start", "coflux-ptyd.service"], SYSTEMD_NEXT);
|
|
723
|
+
runChecked("systemctl", ["--user", "start", "coflux-daemon.service"], SYSTEMD_NEXT);
|
|
528
724
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Stop the launcher and ptyd together (terminals end). `strict` (down / uninstall) fails on a systemctl
|
|
729
|
+
* that could not stop the units; a unit that is not loaded (exit 5) is already stopped.
|
|
730
|
+
*/
|
|
731
|
+
function stopService(mode, { strict = false } = {}) {
|
|
732
|
+
if (mode === "launchd") {
|
|
733
|
+
run("launchctl", ["unload", PLIST]);
|
|
734
|
+
run("launchctl", ["unload", PTYD_PLIST]);
|
|
735
|
+
} else if (mode === "systemd") {
|
|
736
|
+
for (const unit of ["coflux-daemon.service", "coflux-ptyd.service"]) {
|
|
737
|
+
const result = run("systemctl", ["--user", "stop", unit]);
|
|
738
|
+
if (strict && result.status !== 0 && result.status !== 5) {
|
|
739
|
+
const detail = (result.stderr || "").trim().split("\n")[0] || (result.error ? result.error.message : `exit status ${result.status}`);
|
|
740
|
+
die(`systemctl --user stop ${unit} failed: ${detail}`, SYSTEMD_NEXT);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/* --------------------------- authorization and join --------------------------- */
|
|
747
|
+
|
|
748
|
+
const ADD_DEVICE_NEXT = "Copy the full command from Add Device in Coflux.";
|
|
749
|
+
|
|
750
|
+
/** `✓ Device "name" connected to host`. */
|
|
751
|
+
function deviceConnected(deviceName, serverUrl) {
|
|
752
|
+
success(`Device "${deviceName}" connected to ${serverHost(serverUrl)}`);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Minutes until a pending link expires, or null. */
|
|
756
|
+
function minutesLeft(pending) {
|
|
757
|
+
return Number.isFinite(pending?.expiresAt) ? Math.max(1, Math.round((pending.expiresAt - Date.now()) / 60000)) : null;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function printAuthorizationLink(pending) {
|
|
761
|
+
const mins = minutesLeft(pending);
|
|
762
|
+
info("");
|
|
763
|
+
info("To add this device to your account, open:");
|
|
764
|
+
info("");
|
|
765
|
+
info(` ${bold(pending.url)}`);
|
|
766
|
+
info("");
|
|
767
|
+
info(`Waiting for authorization...${mins ? ` (expires in ${mins}m)` : ""}`);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Poll pending-auth.json (the link) and credentials.json (registered). The CLI never speaks the
|
|
771
|
+
// protocol: it only reads files; the service renews an expired link on its own.
|
|
772
|
+
async function waitForAuthorization({ deviceName, serverUrl }) {
|
|
773
|
+
const maxWaitMs = 11 * 60 * 1000; // only how long we wait in the foreground; the service keeps going
|
|
774
|
+
const start = Date.now();
|
|
775
|
+
let printedUrl = null;
|
|
776
|
+
let warned = false;
|
|
777
|
+
while (Date.now() - start < maxWaitMs) {
|
|
778
|
+
if (fs.existsSync(CRED)) {
|
|
779
|
+
deviceConnected(deviceName, serverUrl);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const pending = readPendingAuth();
|
|
783
|
+
if (pending?.url && pending.url !== printedUrl) {
|
|
784
|
+
printedUrl = pending.url;
|
|
785
|
+
printAuthorizationLink(pending);
|
|
786
|
+
}
|
|
787
|
+
if (!printedUrl && !warned && Date.now() - start > 20_000) {
|
|
788
|
+
warned = true;
|
|
789
|
+
warn(`The device has not reached ${serverHost(serverUrl)} yet.`, "It keeps trying. Check it with cofluxd status, or see cofluxd logs.");
|
|
790
|
+
}
|
|
791
|
+
await sleep(1000);
|
|
532
792
|
}
|
|
793
|
+
// Links expire and are renewed: never point at "the link above", it may be dead by now.
|
|
794
|
+
info("");
|
|
795
|
+
info("Still waiting. The service keeps running and renews the link; run cofluxd status to see the current one.");
|
|
533
796
|
}
|
|
534
797
|
|
|
535
798
|
/**
|
|
536
799
|
* `cofluxd up --key` (plan 20260924-device-join-keys): join the account with a one-time key minted in
|
|
537
|
-
* Coflux's
|
|
800
|
+
* Coflux's Add Device dialog. Never prints a link and never enters the 11-minute link wait.
|
|
538
801
|
*
|
|
539
|
-
* Order matters: stop the service before the key file exists, so a
|
|
540
|
-
* era can never pick it up in a renewal and then be killed mid-answer; starting
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
* outcome file the worker writes.
|
|
802
|
+
* Order matters: stop the service before the key file exists, so a runtime still running from the
|
|
803
|
+
* link era can never pick it up in a renewal and then be killed mid-answer; starting it again is the
|
|
804
|
+
* restart that makes the new runtime read it. The CLI learns the result only from
|
|
805
|
+
* `credentials.json` and the outcome file the runtime writes.
|
|
544
806
|
*/
|
|
545
|
-
async function joinWithKey({ serverUrl, deviceName,
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
807
|
+
async function joinWithKey({ mode, serverUrl, deviceName, key, executor }) {
|
|
808
|
+
if (mode === "self-managed") {
|
|
809
|
+
requireWatchSupport();
|
|
810
|
+
await stopSelfManaged();
|
|
811
|
+
} else {
|
|
812
|
+
stopService(mode);
|
|
813
|
+
}
|
|
814
|
+
// The runtime clears pending-auth.json only at connection end, not on SIGTERM: a stale one would
|
|
815
|
+
// read as "too old for keys" below.
|
|
552
816
|
for (const file of [JOIN_OUTCOME, PENDING_AUTH]) { try { fs.rmSync(file, { force: true }); } catch { /* */ } }
|
|
553
817
|
fs.writeFileSync(JOIN_KEY, JSON.stringify({ key }) + "\n", { mode: 0o600 });
|
|
554
818
|
fs.chmodSync(JOIN_KEY, 0o600);
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
console.log("\n 正在用接入密钥接入账号 …\n");
|
|
819
|
+
await startService(mode, executor);
|
|
820
|
+
const joining = step("Joining your account");
|
|
558
821
|
const maxWaitMs = 2 * 60 * 1000;
|
|
559
822
|
const start = Date.now();
|
|
560
823
|
while (Date.now() - start < maxWaitMs) {
|
|
561
824
|
const outcome = readJoinOutcome();
|
|
562
825
|
if (fs.existsSync(CRED) || outcome?.status === "joined") {
|
|
563
|
-
|
|
564
|
-
|
|
826
|
+
joining.done();
|
|
827
|
+
deviceConnected(deviceName, serverUrl);
|
|
565
828
|
return;
|
|
566
829
|
}
|
|
567
830
|
if (outcome?.status === "rejected") {
|
|
568
|
-
|
|
831
|
+
joining.failed();
|
|
832
|
+
die(`The join key was rejected: ${outcome.reason || "it is invalid, expired or already used"}`, "Copy a new command from Add Device in Coflux and run it.");
|
|
569
833
|
}
|
|
570
834
|
if (outcome?.status === "unsupported") {
|
|
571
|
-
|
|
835
|
+
joining.failed();
|
|
836
|
+
die("This server does not support join keys yet.", "Update the server, or run cofluxd up without --key to authorize in the browser.");
|
|
572
837
|
}
|
|
573
838
|
if (fs.existsSync(JOIN_KEY) && fs.existsSync(PENDING_AUTH)) {
|
|
574
|
-
// The
|
|
575
|
-
// binaries). Drop the key so a later
|
|
839
|
+
// The service enrolled without reading the key: its binary predates join keys (up keeps existing
|
|
840
|
+
// binaries). Drop the key so a later runtime never presents it behind the user's back.
|
|
576
841
|
try { fs.rmSync(JOIN_KEY, { force: true }); } catch { /* */ }
|
|
577
|
-
|
|
842
|
+
joining.failed();
|
|
843
|
+
die("The installed Coflux is too old for join keys.", "Run cofluxd update, then copy a new command from Add Device in Coflux.");
|
|
578
844
|
}
|
|
579
845
|
await sleep(500);
|
|
580
846
|
}
|
|
581
|
-
|
|
847
|
+
joining.failed();
|
|
848
|
+
die("Timed out waiting to join your account.", "The service keeps trying. Check it with cofluxd status, and see cofluxd logs for the reason.");
|
|
582
849
|
}
|
|
583
850
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
851
|
+
/* ------------------------------ commands ------------------------------ */
|
|
852
|
+
|
|
853
|
+
/** The backend `up` installs: launchd, systemd when it is booted, otherwise self-managed. */
|
|
854
|
+
async function upMode() {
|
|
855
|
+
if (IS_MAC) {
|
|
856
|
+
return (await watchRequest("status")) ? "self-managed" : "launchd";
|
|
857
|
+
}
|
|
858
|
+
if (!IS_LINUX) die("Coflux runs only on macOS and Linux.", "Use a macOS or Linux machine.");
|
|
859
|
+
if (await watchRequest("status")) return "self-managed";
|
|
860
|
+
if (systemdBooted()) {
|
|
861
|
+
clearServiceMode();
|
|
862
|
+
return "systemd";
|
|
863
|
+
}
|
|
864
|
+
return "self-managed";
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Start (or, for a running self-managed service, restart) the service. */
|
|
868
|
+
async function startService(mode, executor) {
|
|
869
|
+
if (mode === "self-managed") {
|
|
870
|
+
writeServiceMode();
|
|
871
|
+
if (await watchRequest("status")) {
|
|
872
|
+
await restartService(mode, executor);
|
|
873
|
+
} else {
|
|
874
|
+
await startSelfManaged(executor);
|
|
603
875
|
}
|
|
604
|
-
|
|
876
|
+
} else {
|
|
877
|
+
installService(mode, true, executor);
|
|
878
|
+
}
|
|
879
|
+
success("Service started");
|
|
880
|
+
if (mode === "self-managed") {
|
|
881
|
+
warn("This service does not start on boot.", "In a container, use cofluxd run as the entry point instead.");
|
|
605
882
|
}
|
|
606
|
-
// 链接会过期换新(daemon 自动续期),别引导用户用"上面的链接"——过期后那是死链
|
|
607
|
-
console.log(" 仍未完成授权;daemon 已在后台运行并会自动更换过期的授权链接,用 `cofluxd status` 查看最新链接。\n");
|
|
608
883
|
}
|
|
609
884
|
|
|
610
|
-
|
|
885
|
+
function validateKey(raw) {
|
|
886
|
+
const key = raw.trim();
|
|
887
|
+
if (!/^cf_join_[A-Za-z0-9_-]+$/.test(key)) die("Invalid join key.", ADD_DEVICE_NEXT);
|
|
888
|
+
return key;
|
|
889
|
+
}
|
|
611
890
|
|
|
612
|
-
|
|
891
|
+
/** Settings for up / run: flags first, then the saved settings, then defaults. */
|
|
892
|
+
function resolveSettings(v) {
|
|
613
893
|
const s = readSettings();
|
|
894
|
+
return {
|
|
895
|
+
serverUrl: v.server || s.serverUrl || DEFAULT_SERVER,
|
|
896
|
+
deviceName: v.name || s.deviceName || hostname(),
|
|
897
|
+
shell: v.shell || s.shell,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function noticeServer(serverUrl) {
|
|
902
|
+
// A saved non-default server stays in effect; say so, so a leftover staging URL cannot connect silently.
|
|
903
|
+
if (serverUrl !== DEFAULT_SERVER) warn(`Using the server ${serverUrl}`);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async function cmdUp(v) {
|
|
907
|
+
const settings = resolveSettings(v);
|
|
908
|
+
let key;
|
|
614
909
|
if (v.key !== undefined) {
|
|
615
|
-
|
|
616
|
-
if (
|
|
617
|
-
if (v["no-start"]) die("--key 不能与 --no-start 同用:密钥只在启动时使用一次");
|
|
910
|
+
key = validateKey(v.key);
|
|
911
|
+
if (v["no-start"]) die("--key cannot be used with --no-start.", "The key is used once, when the service starts. Drop --no-start.");
|
|
618
912
|
if (fs.existsSync(CRED)) {
|
|
619
913
|
// Already registered: a key would never be presented, so change nothing at all.
|
|
620
914
|
const c = readCredentialsSummary();
|
|
621
|
-
|
|
915
|
+
success(`This device is already in your account on ${serverHost(c.serverUrl || settings.serverUrl)}. Nothing was changed.`);
|
|
622
916
|
return;
|
|
623
917
|
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
918
|
+
}
|
|
919
|
+
await ensureBinaries({ version: v.version, binDir: v["bin-dir"], skipIfPresent: true });
|
|
920
|
+
applyConfig(settings);
|
|
921
|
+
noticeServer(settings.serverUrl);
|
|
922
|
+
const mode = await upMode();
|
|
923
|
+
const executor = executorUnitRuntime();
|
|
924
|
+
if (key) {
|
|
925
|
+
await joinWithKey({ mode, ...settings, key, executor });
|
|
630
926
|
return;
|
|
631
927
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
928
|
+
if (v["no-start"]) {
|
|
929
|
+
if (mode === "self-managed") writeServiceMode();
|
|
930
|
+
else installService(mode, false, executor);
|
|
931
|
+
success("Installed without starting. Start it with cofluxd up.");
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
await startService(mode, executor);
|
|
935
|
+
if (!fs.existsSync(CRED)) {
|
|
936
|
+
await waitForAuthorization(settings);
|
|
937
|
+
} else {
|
|
938
|
+
info("");
|
|
939
|
+
await cmdStatus();
|
|
940
|
+
}
|
|
941
|
+
if (IS_MAC && readFdaStatus() !== "granted") {
|
|
942
|
+
warn("Full Disk Access is not granted. Terminals can hang on macOS permission prompts.", "Run cofluxd fda to grant it.");
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* `cofluxd run`: the self-managed service in the foreground, for containers and other supervisors.
|
|
948
|
+
* Node only forwards signals and mirrors the exit code; `coflux-launcher watch --foreground` does
|
|
949
|
+
* the supervising. Logs go to stdout/stderr. A rejected join key is reported and the service keeps
|
|
950
|
+
* running, so a restart policy cannot loop on a spent key.
|
|
951
|
+
*/
|
|
952
|
+
async function cmdRun(v) {
|
|
953
|
+
if (!IS_LINUX && !IS_MAC) die("Coflux runs only on macOS and Linux.", "Use a macOS or Linux machine.");
|
|
954
|
+
if (v["no-start"]) die("--no-start does not apply to cofluxd run.", "Use cofluxd up --no-start to install without starting.");
|
|
955
|
+
const settings = resolveSettings(v);
|
|
956
|
+
const key = v.key !== undefined ? validateKey(v.key) : undefined;
|
|
957
|
+
// Two launchers for one home would fight over launcher.sock: refuse while anything else owns it.
|
|
958
|
+
const otherOwner = (await watchRequest("status"))
|
|
959
|
+
|| (IS_MAC && serviceRunningInfo("launchd").running)
|
|
960
|
+
|| (IS_LINUX && systemdBooted() && serviceRunningInfo("systemd").running);
|
|
961
|
+
if (otherOwner) die("Coflux is already running on this device.", "Stop it first with cofluxd down, then run cofluxd run again.");
|
|
962
|
+
await ensureBinaries({ version: v.version, binDir: v["bin-dir"], skipIfPresent: true });
|
|
963
|
+
requireWatchSupport();
|
|
964
|
+
applyConfig(settings);
|
|
965
|
+
noticeServer(settings.serverUrl);
|
|
966
|
+
if (key) {
|
|
967
|
+
if (fs.existsSync(CRED)) {
|
|
968
|
+
success("This device is already in your account. The join key was not used.");
|
|
969
|
+
} else {
|
|
970
|
+
for (const file of [JOIN_OUTCOME, PENDING_AUTH]) { try { fs.rmSync(file, { force: true }); } catch { /* */ } }
|
|
971
|
+
fs.writeFileSync(JOIN_KEY, JSON.stringify({ key }) + "\n", { mode: 0o600 });
|
|
972
|
+
fs.chmodSync(JOIN_KEY, 0o600);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const executor = executorRuntime();
|
|
976
|
+
const child = spawn(LAUNCHER_BIN, ["watch", "--foreground"], {
|
|
977
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
978
|
+
env: watcherEnv(process.env, { home: HOME, executor }),
|
|
979
|
+
});
|
|
980
|
+
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
|
|
981
|
+
process.on(signal, () => { try { child.kill(signal); } catch { /* */ } });
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Report the authorization link, the join outcome and the first connection in the same output.
|
|
985
|
+
let printedUrl = null;
|
|
986
|
+
let reportedJoin = false;
|
|
987
|
+
let connected = fs.existsSync(CRED);
|
|
988
|
+
const monitor = setInterval(() => {
|
|
989
|
+
if (!connected && fs.existsSync(CRED)) {
|
|
990
|
+
connected = true;
|
|
991
|
+
deviceConnected(settings.deviceName, settings.serverUrl);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
if (connected) return;
|
|
995
|
+
const outcome = readJoinOutcome();
|
|
996
|
+
if (!reportedJoin && outcome?.status === "rejected") {
|
|
997
|
+
reportedJoin = true;
|
|
998
|
+
printError(`The join key was rejected: ${outcome.reason || "it is invalid, expired or already used"}`, "Copy a new command from Add Device in Coflux. The service keeps running.");
|
|
999
|
+
} else if (!reportedJoin && outcome?.status === "unsupported") {
|
|
1000
|
+
reportedJoin = true;
|
|
1001
|
+
printError("This server does not support join keys yet.", "Update the server, or authorize this device with the link printed below.");
|
|
1002
|
+
}
|
|
1003
|
+
const pending = readPendingAuth();
|
|
1004
|
+
if (pending?.url && pending.url !== printedUrl) {
|
|
1005
|
+
printedUrl = pending.url;
|
|
1006
|
+
printAuthorizationLink(pending);
|
|
1007
|
+
}
|
|
1008
|
+
}, 1000);
|
|
1009
|
+
|
|
1010
|
+
child.once("error", (error) => {
|
|
1011
|
+
clearInterval(monitor);
|
|
1012
|
+
die(`Could not start the service: ${error.message}`, "Run cofluxd update, then try again.");
|
|
1013
|
+
});
|
|
1014
|
+
child.once("exit", (code, signal) => {
|
|
1015
|
+
clearInterval(monitor);
|
|
1016
|
+
if (code === WATCH_EXIT_REFUSED) {
|
|
1017
|
+
printError("Coflux is already running on this device under another service.", "Stop it first with cofluxd down. If the Coflux app runs on this machine, quit it.");
|
|
1018
|
+
} else if (code !== 0) {
|
|
1019
|
+
printError("The service stopped.", "See the output above for the reason. Terminals that were open are closed.");
|
|
1020
|
+
}
|
|
1021
|
+
process.exit(code ?? (signal ? 1 : 0));
|
|
637
1022
|
});
|
|
638
1023
|
}
|
|
639
1024
|
|
|
640
|
-
function cmdDown() {
|
|
1025
|
+
async function cmdDown() {
|
|
1026
|
+
const mode = (await watchRequest("status")) ? "self-managed" : serviceMode();
|
|
1027
|
+
const stopping = step("Stopping the service");
|
|
1028
|
+
if (mode === "self-managed") await stopSelfManaged();
|
|
1029
|
+
else stopService(mode, { strict: true });
|
|
1030
|
+
stopping.done();
|
|
1031
|
+
}
|
|
641
1032
|
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
1033
|
+
// Only the launcher, ptyd and the coflux command need this (plan 20261002-runtime-launcher-merge): the
|
|
1034
|
+
// server updates the runtime itself. The runtime refreshed here is the fallback the launcher runs
|
|
1035
|
+
// when its store holds nothing better.
|
|
645
1036
|
//
|
|
646
|
-
// update
|
|
647
|
-
//
|
|
648
|
-
//
|
|
1037
|
+
// update never restarts (decided 2026-07-25): replacing files on disk does not affect running
|
|
1038
|
+
// processes. Interruptions happen only on an explicit `cofluxd restart` (terminals stay) or
|
|
1039
|
+
// `cofluxd restart --ptyd` (terminals end).
|
|
649
1040
|
async function cmdUpdate(v) {
|
|
650
|
-
if (!fs.existsSync(SETTINGS)) die("
|
|
1041
|
+
if (!fs.existsSync(SETTINGS)) die("Coflux is not installed on this device.", "Run cofluxd up first.");
|
|
651
1042
|
const version = v.version || "latest";
|
|
652
|
-
const before =
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1043
|
+
const before = {
|
|
1044
|
+
launcher: fileSha256(LAUNCHER_BIN),
|
|
1045
|
+
ptyd: fileSha256(PTYD_BIN),
|
|
1046
|
+
runtime: fileSha256(RUNTIME_BIN),
|
|
1047
|
+
cli: fileSha256(CLI_BIN),
|
|
1048
|
+
};
|
|
1049
|
+
const installed = await ensureBinaries({ version, binDir: v["bin-dir"] });
|
|
1050
|
+
const label = installed === "local build" ? "the local build" : displayVersion(installed);
|
|
1051
|
+
const launcherChanged = before.launcher !== fileSha256(LAUNCHER_BIN);
|
|
1052
|
+
const ptydChanged = before.ptyd !== fileSha256(PTYD_BIN);
|
|
1053
|
+
const runtimeChanged = before.runtime !== fileSha256(RUNTIME_BIN);
|
|
1054
|
+
const cliChanged = before.cli !== fileSha256(CLI_BIN);
|
|
1055
|
+
if (!launcherChanged && !ptydChanged && !runtimeChanged && !cliChanged) {
|
|
1056
|
+
success(`Already up to date (${label})`);
|
|
660
1057
|
return;
|
|
661
1058
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
console.log(" 运行 `cofluxd restart` 应用——终端留在 ptyd 里,屏幕与回滚原样保留,只有几秒停顿。");
|
|
665
|
-
}
|
|
1059
|
+
success(`Updated to ${label}`);
|
|
1060
|
+
if (launcherChanged) info("Run cofluxd restart to apply it. Terminals stay open.");
|
|
666
1061
|
if (ptydChanged) {
|
|
667
|
-
|
|
668
|
-
console.log(" ⚠ 运行 `cofluxd restart --ptyd` 应用——ptyd 自身更新无法保留终端,会结束本机所有活会话。");
|
|
1062
|
+
warn("Part of this update takes effect only after a full restart, which closes all terminals.", "Run cofluxd restart --ptyd when that is convenient.");
|
|
669
1063
|
}
|
|
1064
|
+
if (!launcherChanged && !ptydChanged) info("No restart needed.");
|
|
670
1065
|
}
|
|
671
1066
|
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
function cmdRestart(v) {
|
|
675
|
-
if (!fs.existsSync(SETTINGS)) die("
|
|
676
|
-
|
|
677
|
-
|
|
1067
|
+
// Apply a new launcher (or just restart the service): the terminals stay in ptyd. `--ptyd` restarts
|
|
1068
|
+
// ptyd as well (only a ptyd update needs it), and that ends every terminal on the device.
|
|
1069
|
+
async function cmdRestart(v) {
|
|
1070
|
+
if (!fs.existsSync(SETTINGS)) die("Coflux is not installed on this device.", "Run cofluxd up first.");
|
|
1071
|
+
const mode = (await watchRequest("status")) ? "self-managed" : serviceMode();
|
|
1072
|
+
const executor = executorRuntime();
|
|
1073
|
+
// Keep the unit files in step with the templates (the first restart after an upgrade may still find
|
|
1074
|
+
// units from an older layout); written only, started below.
|
|
1075
|
+
if (mode !== "self-managed") installService(mode, false, executor);
|
|
678
1076
|
if (v.ptyd) {
|
|
679
|
-
|
|
680
|
-
|
|
1077
|
+
warn("This closes all terminals on this device.");
|
|
1078
|
+
const restarting = step("Restarting the service and its terminals");
|
|
1079
|
+
await restartAllServices(mode, executor);
|
|
1080
|
+
restarting.done();
|
|
681
1081
|
} else {
|
|
682
|
-
|
|
683
|
-
restartService();
|
|
1082
|
+
const restarting = step("Restarting the service");
|
|
1083
|
+
await restartService(mode, executor);
|
|
1084
|
+
restarting.done();
|
|
1085
|
+
info("Terminals stayed open.");
|
|
684
1086
|
}
|
|
685
|
-
console.log("✓ 已重启(cofluxd status 查看状态)");
|
|
686
1087
|
}
|
|
687
1088
|
|
|
688
|
-
/**
|
|
1089
|
+
/** Hidden: the four service files (launchd x 2, systemd x 2) as JSON on stdout. */
|
|
689
1090
|
function cmdServiceFiles() {
|
|
690
1091
|
console.log(JSON.stringify(serviceFiles(), null, 2));
|
|
691
1092
|
}
|
|
692
1093
|
|
|
693
|
-
// launchd
|
|
694
|
-
//
|
|
695
|
-
// OS_REASON_CODESIGNING
|
|
696
|
-
function serviceRunningInfo() {
|
|
697
|
-
if (
|
|
1094
|
+
// launchd / systemd service state, shared by status and doctor. On macOS only the PID in
|
|
1095
|
+
// `launchctl list` counts: a registered label is not a live process (a launcher killed with
|
|
1096
|
+
// OS_REASON_CODESIGNING keeps its label; observed 2026-07-25).
|
|
1097
|
+
function serviceRunningInfo(mode) {
|
|
1098
|
+
if (mode === "launchd") {
|
|
698
1099
|
const out = run("launchctl", ["list", "com.coflux.daemon"]);
|
|
699
1100
|
const registered = out.status === 0;
|
|
700
1101
|
const running = registered && /"PID"\s*=\s*\d+/.test(out.stdout || "");
|
|
701
|
-
return { running,
|
|
1102
|
+
return { running, label: running ? "running" : registered ? "not running (see cofluxd logs)" : "stopped" };
|
|
702
1103
|
}
|
|
703
|
-
if (
|
|
704
|
-
const active = (run("systemctl", ["--user", "is-active", "coflux-daemon.service"]).stdout || "").trim()
|
|
705
|
-
|
|
1104
|
+
if (mode === "systemd") {
|
|
1105
|
+
const active = (run("systemctl", ["--user", "is-active", "coflux-daemon.service"]).stdout || "").trim();
|
|
1106
|
+
const label = active === "active" ? "running"
|
|
1107
|
+
: active === "activating" || active === "reloading" ? "starting"
|
|
1108
|
+
: active === "failed" ? "failed (see cofluxd logs)"
|
|
1109
|
+
: "stopped";
|
|
1110
|
+
return { running: active === "active", label };
|
|
1111
|
+
}
|
|
1112
|
+
return { running: false, label: "stopped" };
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** The service state under whichever backend runs it, plus the terminal count when known. */
|
|
1116
|
+
async function serviceState() {
|
|
1117
|
+
const watcher = await watchRequest("status");
|
|
1118
|
+
if (watcher?.ok) {
|
|
1119
|
+
const launcherUp = !!watcher.launcher?.running;
|
|
1120
|
+
const label = !launcherUp ? "starting"
|
|
1121
|
+
: watcher.mode === "foreground" ? "running (cofluxd run, no autostart)" : "running (self-managed, no autostart)";
|
|
1122
|
+
return { running: launcherUp, label, terminals: Number.isInteger(watcher.terminals) ? watcher.terminals : undefined };
|
|
706
1123
|
}
|
|
707
|
-
|
|
1124
|
+
const mode = serviceMode();
|
|
1125
|
+
if (mode === "self-managed") return { running: false, label: "stopped" };
|
|
1126
|
+
return serviceRunningInfo(mode);
|
|
708
1127
|
}
|
|
709
1128
|
|
|
710
|
-
|
|
1129
|
+
const CONNECTION_LABEL = { connecting: "connecting", connected: "connected", reconnecting: "reconnecting" };
|
|
1130
|
+
|
|
1131
|
+
async function cmdStatus() {
|
|
711
1132
|
const s = readSettings();
|
|
712
|
-
|
|
713
|
-
console.log(`设备名: ${s.deviceName || "(默认)"}`);
|
|
1133
|
+
const { running, label, terminals } = await serviceState();
|
|
714
1134
|
const registered = fs.existsSync(CRED);
|
|
715
|
-
|
|
1135
|
+
let connection;
|
|
716
1136
|
if (registered) {
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
1137
|
+
// conn-state.json is a snapshot the runtime wrote while alive: with the service down it is stale and
|
|
1138
|
+
// must not read as online.
|
|
1139
|
+
if (running) {
|
|
1140
|
+
const conn = readConnState();
|
|
1141
|
+
const state = CONNECTION_LABEL[conn?.state];
|
|
1142
|
+
if (state) {
|
|
1143
|
+
connection = state === "connected" && Number.isFinite(conn.since) ? `connected for ${duration(Date.now() - conn.since)}` : state;
|
|
1144
|
+
} else {
|
|
1145
|
+
connection = "unknown";
|
|
1146
|
+
}
|
|
1147
|
+
} else {
|
|
1148
|
+
connection = "offline";
|
|
1149
|
+
}
|
|
722
1150
|
} else {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
}
|
|
730
|
-
console.log(`服务: ${active}${pid}`);
|
|
731
|
-
// conn-state.json 是 worker 生前写的快照:进程不在时是 stale 数据,忽略——不能让"进程活着"
|
|
732
|
-
// 误当成"在线",也不能让 stale 文件在进程死后继续显示假的连接态。
|
|
733
|
-
if (running) {
|
|
734
|
-
const conn = readConnState();
|
|
735
|
-
if (conn?.state && CONN_STATE_LABEL[conn.state]) {
|
|
736
|
-
const dur = Number.isFinite(conn.since) ? formatDuration(Date.now() - conn.since) : null;
|
|
737
|
-
console.log(`连接: ${CONN_STATE_LABEL[conn.state]}${dur ? `(已 ${dur})` : ""}`);
|
|
1151
|
+
const pending = readPendingAuth();
|
|
1152
|
+
if (pending?.url) {
|
|
1153
|
+
const mins = Number.isFinite(pending.expiresAt) ? Math.max(0, Math.round((pending.expiresAt - Date.now()) / 60000)) : null;
|
|
1154
|
+
connection = `waiting for authorization${mins !== null ? ` (expires in ${mins}m)` : ""}\n${pending.url}`;
|
|
1155
|
+
} else {
|
|
1156
|
+
connection = "not added to an account yet";
|
|
738
1157
|
}
|
|
739
1158
|
}
|
|
1159
|
+
const rows = [
|
|
1160
|
+
["Device", s.deviceName || hostname()],
|
|
1161
|
+
["Server", s.serverUrl ? serverHost(s.serverUrl) : "not configured"],
|
|
1162
|
+
["Service", label],
|
|
1163
|
+
["Connection", connection],
|
|
1164
|
+
["Terminals", terminals === undefined ? undefined : `${terminals} open`],
|
|
1165
|
+
];
|
|
740
1166
|
if (IS_MAC) {
|
|
741
1167
|
const fda = readFdaStatus();
|
|
742
|
-
|
|
743
|
-
console.log(`FDA: ${fdaLabel(fda)}${hint}`);
|
|
1168
|
+
rows.push(["Disk access", fda === "granted" ? "granted" : fda === "denied" ? "not granted; run cofluxd fda" : "unknown; run cofluxd fda"]);
|
|
744
1169
|
}
|
|
1170
|
+
table(rows);
|
|
745
1171
|
}
|
|
746
1172
|
|
|
747
|
-
/* ------------------------------ doctor
|
|
748
|
-
//
|
|
749
|
-
//
|
|
1173
|
+
/* ------------------------------ doctor ------------------------------ */
|
|
1174
|
+
// Transport-level probes only: no coflux protocol message is parsed, so the CLI stays protocol-free.
|
|
1175
|
+
// The server WebSocket is closed right after the upgrade; the loopback probe only checks that a stored
|
|
1176
|
+
// Origin gets a 101 and never sends a browser key or grant.
|
|
750
1177
|
const DOCTOR_TIMEOUT_MS = 5000;
|
|
751
1178
|
|
|
752
1179
|
function parseServerUrl(serverUrl) {
|
|
753
1180
|
const u = new URL(serverUrl);
|
|
754
1181
|
const useTls = u.protocol === "wss:";
|
|
755
|
-
if (!useTls && u.protocol !== "ws:") throw new Error(
|
|
1182
|
+
if (!useTls && u.protocol !== "ws:") throw new Error(`unsupported scheme ${u.protocol}; use ws:// or wss://`);
|
|
756
1183
|
const port = u.port ? Number(u.port) : (useTls ? 443 : 80);
|
|
757
1184
|
const path = u.pathname || "/";
|
|
758
1185
|
return { host: u.hostname, port, path, useTls };
|
|
@@ -777,7 +1204,7 @@ function probeTcp(host, port) {
|
|
|
777
1204
|
const socket = net.connect({ host, port, timeout: DOCTOR_TIMEOUT_MS });
|
|
778
1205
|
const done = (ok, extra) => { socket.destroy(); resolve({ ok, ms: Date.now() - t0, ...extra }); };
|
|
779
1206
|
socket.once("connect", () => done(true, {}));
|
|
780
|
-
socket.once("timeout", () => done(false, { error:
|
|
1207
|
+
socket.once("timeout", () => done(false, { error: `timed out after ${DOCTOR_TIMEOUT_MS}ms` }));
|
|
781
1208
|
socket.once("error", (e) => done(false, { error: e.message }));
|
|
782
1209
|
});
|
|
783
1210
|
}
|
|
@@ -786,16 +1213,16 @@ function probeTls(host, port) {
|
|
|
786
1213
|
return new Promise((resolve) => {
|
|
787
1214
|
const t0 = Date.now();
|
|
788
1215
|
const socket = tls.connect({ host, port, servername: host, timeout: DOCTOR_TIMEOUT_MS }, () => {
|
|
789
|
-
const detail = socket.authorized ? socket.getProtocol() : `${socket.getProtocol()}
|
|
1216
|
+
const detail = socket.authorized ? socket.getProtocol() : `${socket.getProtocol()}, certificate not verified: ${socket.authorizationError}`;
|
|
790
1217
|
socket.destroy();
|
|
791
1218
|
resolve({ ok: true, ms: Date.now() - t0, detail });
|
|
792
1219
|
});
|
|
793
|
-
socket.once("timeout", () => { socket.destroy(); resolve({ ok: false, ms: Date.now() - t0, error:
|
|
1220
|
+
socket.once("timeout", () => { socket.destroy(); resolve({ ok: false, ms: Date.now() - t0, error: `handshake timed out after ${DOCTOR_TIMEOUT_MS}ms` }); });
|
|
794
1221
|
socket.once("error", (e) => resolve({ ok: false, ms: Date.now() - t0, error: e.message }));
|
|
795
1222
|
});
|
|
796
1223
|
}
|
|
797
1224
|
|
|
798
|
-
//
|
|
1225
|
+
// One minimal HTTP/1.1 Upgrade request; only whether a 101 comes back matters. No WebSocket frames.
|
|
799
1226
|
function probeWsUpgrade({ host, port, path, useTls, origin }) {
|
|
800
1227
|
return new Promise((resolve) => {
|
|
801
1228
|
const t0 = Date.now();
|
|
@@ -811,7 +1238,7 @@ function probeWsUpgrade({ host, port, path, useTls, origin }) {
|
|
|
811
1238
|
};
|
|
812
1239
|
const onOpen = (socket) => {
|
|
813
1240
|
let buf = "";
|
|
814
|
-
const timer = setTimeout(() => { socket.destroy(); finish({ ok: false, error:
|
|
1241
|
+
const timer = setTimeout(() => { socket.destroy(); finish({ ok: false, error: `no upgrade response after ${DOCTOR_TIMEOUT_MS}ms` }); }, DOCTOR_TIMEOUT_MS);
|
|
815
1242
|
socket.on("data", (chunk) => {
|
|
816
1243
|
buf += chunk.toString("latin1");
|
|
817
1244
|
if (!buf.includes("\r\n\r\n")) return;
|
|
@@ -819,7 +1246,7 @@ function probeWsUpgrade({ host, port, path, useTls, origin }) {
|
|
|
819
1246
|
const statusLine = buf.split("\r\n")[0];
|
|
820
1247
|
socket.destroy();
|
|
821
1248
|
const ok = /^HTTP\/1\.\d 101\b/.test(statusLine);
|
|
822
|
-
finish(ok ? { ok, detail: statusLine } : { ok, error:
|
|
1249
|
+
finish(ok ? { ok, detail: statusLine } : { ok, error: `expected 101 Switching Protocols, got: ${statusLine}` });
|
|
823
1250
|
});
|
|
824
1251
|
socket.once("error", (e) => { clearTimeout(timer); finish({ ok: false, error: e.message }); });
|
|
825
1252
|
socket.write(req);
|
|
@@ -828,83 +1255,41 @@ function probeWsUpgrade({ host, port, path, useTls, origin }) {
|
|
|
828
1255
|
? tls.connect({ host, port, servername: host, timeout: DOCTOR_TIMEOUT_MS })
|
|
829
1256
|
: net.connect({ host, port, timeout: DOCTOR_TIMEOUT_MS });
|
|
830
1257
|
socket.once(useTls ? "secureConnect" : "connect", () => onOpen(socket));
|
|
831
|
-
socket.once("timeout", () => { socket.destroy(); finish({ ok: false, error:
|
|
1258
|
+
socket.once("timeout", () => { socket.destroy(); finish({ ok: false, error: `timed out after ${DOCTOR_TIMEOUT_MS}ms` }); });
|
|
832
1259
|
socket.once("error", (e) => finish({ ok: false, error: e.message }));
|
|
833
1260
|
});
|
|
834
1261
|
}
|
|
835
1262
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
const
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
function printLocalLayer(name, r) {
|
|
843
|
-
const mark = r.ok ? "✓" : "⚠";
|
|
844
|
-
const msg = r.ok ? (r.detail ? `→ ${r.detail}` : "") : (r.error || "");
|
|
845
|
-
const elapsed = Number.isFinite(r.ms) ? ` (${r.ms}ms)` : "";
|
|
846
|
-
console.log(` ${mark} ${name}${elapsed}${msg ? ` ${msg}` : ""}`);
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
// 本地事实汇总:服务进程存活、conn-state.json 连接态、凭证有无、FDA。返回连接态三态:
|
|
850
|
-
// "connected" | "not-connected"(有快照但非 connected,如 connecting/reconnecting)
|
|
851
|
-
// | "unknown"(服务未运行,或无快照——daemon 版本较旧还没写、或刚启动)。
|
|
852
|
-
// 三态区分是因为"无快照"不等于"未连接":旧版 worker 不写 conn-state.json,把它当"未连接"
|
|
853
|
-
// 会把"连接态未知"误报成"认证/授权层有问题"(2026-07-23 实操验收发现)。
|
|
854
|
-
function printLocalFacts() {
|
|
855
|
-
console.log("\n Daemon 状态\n ───────────");
|
|
856
|
-
const registered = fs.existsSync(CRED);
|
|
857
|
-
console.log(` 凭证: ${registered ? "已登记" : "未登记"}`);
|
|
858
|
-
const { running, active } = serviceRunningInfo();
|
|
859
|
-
console.log(` 服务: ${active}`);
|
|
860
|
-
let connState = "unknown";
|
|
861
|
-
if (running) {
|
|
862
|
-
const conn = readConnState();
|
|
863
|
-
if (conn?.state && CONN_STATE_LABEL[conn.state]) {
|
|
864
|
-
console.log(` 中心: ${CONN_STATE_LABEL[conn.state]}`);
|
|
865
|
-
connState = conn.state === "connected" ? "connected" : "not-connected";
|
|
866
|
-
} else {
|
|
867
|
-
console.log(" 中心: (无连接态快照)");
|
|
868
|
-
}
|
|
869
|
-
} else {
|
|
870
|
-
console.log(" 中心: 未探测(服务未运行)");
|
|
871
|
-
}
|
|
872
|
-
if (IS_MAC) console.log(` FDA: ${fdaLabel(readFdaStatus())}`);
|
|
873
|
-
console.log("");
|
|
874
|
-
return { registered, running, connState };
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
function printConclusion(level, msg) {
|
|
878
|
-
const mark = level === "ok" ? "✓" : level === "warning" ? "⚠" : "✗";
|
|
879
|
-
console.log(` ${mark} ${msg}\n`);
|
|
1263
|
+
/** One probe row: ` ✓ Name 12ms detail`. `soft` failures are warnings, not errors. */
|
|
1264
|
+
function printProbe(name, r, { soft = false } = {}) {
|
|
1265
|
+
const mark = r.ok ? "✓" : soft ? "!" : "✗";
|
|
1266
|
+
const elapsed = Number.isFinite(r.ms) ? `${r.ms}ms` : "";
|
|
1267
|
+
const msg = r.ok ? (r.detail || "") : (r.error || "");
|
|
1268
|
+
info(` ${mark} ${name.padEnd(28)} ${elapsed.padStart(6)} ${msg}`.trimEnd());
|
|
880
1269
|
}
|
|
881
1270
|
|
|
882
|
-
async function
|
|
1271
|
+
async function probeServer(target) {
|
|
883
1272
|
const { host, port, useTls } = target;
|
|
884
1273
|
const dnsR = await probeDns(host);
|
|
885
|
-
|
|
886
|
-
if (!dnsR.ok) {
|
|
887
|
-
return { ok: false, reason: "DNS 解析失败——检查网络连接/DNS 配置,或该域名是否可达。" };
|
|
888
|
-
}
|
|
1274
|
+
printProbe("DNS lookup", dnsR);
|
|
1275
|
+
if (!dnsR.ok) return { ok: false, reason: "DNS lookup failed.", next: "Check your network and DNS settings." };
|
|
889
1276
|
|
|
890
1277
|
const tcpR = await probeTcp(host, port);
|
|
891
|
-
|
|
892
|
-
if (!tcpR.ok) {
|
|
893
|
-
return { ok: false, reason: "DNS 可解析但 TCP 连不上——防火墙/代理拦截,或目标端口未开放。" };
|
|
894
|
-
}
|
|
1278
|
+
printProbe(`TCP connection (${host}:${port})`, tcpR);
|
|
1279
|
+
if (!tcpR.ok) return { ok: false, reason: "DNS works, but the TCP connection failed.", next: "A firewall or proxy may block it, or the port is closed." };
|
|
895
1280
|
|
|
896
1281
|
if (useTls) {
|
|
897
1282
|
const tlsR = await probeTls(host, port);
|
|
898
|
-
|
|
1283
|
+
printProbe("TLS handshake", tlsR);
|
|
899
1284
|
if (!tlsR.ok) {
|
|
900
|
-
return { ok: false, reason: "TCP
|
|
1285
|
+
return { ok: false, reason: "TCP works, but the TLS handshake failed.", next: "A corporate proxy, a wrong system clock or the server's certificate may be the cause." };
|
|
901
1286
|
}
|
|
902
1287
|
}
|
|
903
1288
|
|
|
904
1289
|
const wsR = await probeWsUpgrade(target);
|
|
905
|
-
|
|
1290
|
+
printProbe("WebSocket upgrade", wsR);
|
|
906
1291
|
if (!wsR.ok) {
|
|
907
|
-
return { ok: false, reason: "
|
|
1292
|
+
return { ok: false, reason: "The network works, but the WebSocket upgrade was refused.", next: "A reverse proxy may not forward the Upgrade header, or the path is wrong." };
|
|
908
1293
|
}
|
|
909
1294
|
return { ok: true };
|
|
910
1295
|
}
|
|
@@ -912,140 +1297,213 @@ async function probeCenter(target) {
|
|
|
912
1297
|
async function probeLocalDirect() {
|
|
913
1298
|
const portResult = localGatewayPort();
|
|
914
1299
|
if (!portResult.ok) {
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
printLocalLayer("本地 grant", grant);
|
|
1300
|
+
printProbe("Local port", { ok: false, error: portResult.error }, { soft: true });
|
|
1301
|
+
printProbe("Local connection", { ok: false, error: "local port unknown; not checked" }, { soft: true });
|
|
1302
|
+
printProbe("Browser pairing", readLocalGatewaySummary(), { soft: true });
|
|
919
1303
|
return { ready: false };
|
|
920
1304
|
}
|
|
921
1305
|
|
|
922
1306
|
const bind = await probeTcp("127.0.0.1", portResult.port);
|
|
923
|
-
|
|
1307
|
+
printProbe(`Local port (127.0.0.1:${portResult.port})`, bind, { soft: true });
|
|
924
1308
|
const grant = readLocalGatewaySummary();
|
|
925
|
-
|
|
1309
|
+
printProbe("Browser pairing", grant, { soft: true });
|
|
926
1310
|
|
|
927
1311
|
let loopback;
|
|
928
1312
|
if (!bind.ok) {
|
|
929
|
-
loopback = { ok: false, error: "
|
|
1313
|
+
loopback = { ok: false, error: "local port not listening; skipped" };
|
|
930
1314
|
} else {
|
|
931
1315
|
loopback = await probeWsUpgrade({
|
|
932
1316
|
host: "127.0.0.1",
|
|
933
1317
|
port: portResult.port,
|
|
934
1318
|
path: "/device",
|
|
935
1319
|
useTls: false,
|
|
936
|
-
//
|
|
1320
|
+
// Without a stored Origin still send a valid one: a 403 tells "reachable but not paired" apart.
|
|
937
1321
|
origin: grant.origin || "http://127.0.0.1",
|
|
938
1322
|
});
|
|
939
1323
|
}
|
|
940
|
-
|
|
1324
|
+
printProbe("Local connection", loopback, { soft: true });
|
|
941
1325
|
return { ready: bind.ok && grant.ready && loopback.ok };
|
|
942
1326
|
}
|
|
943
1327
|
|
|
944
1328
|
async function cmdDoctor() {
|
|
945
1329
|
const s = readSettings();
|
|
946
1330
|
const serverUrl = s.serverUrl || DEFAULT_SERVER;
|
|
947
|
-
|
|
948
|
-
let
|
|
1331
|
+
info(`Server ${serverUrl}`);
|
|
1332
|
+
let server;
|
|
949
1333
|
try {
|
|
950
|
-
|
|
1334
|
+
server = await probeServer(parseServerUrl(serverUrl));
|
|
951
1335
|
} catch (error) {
|
|
952
|
-
|
|
953
|
-
|
|
1336
|
+
printProbe("Server URL", { ok: false, ms: 0, error: error.message });
|
|
1337
|
+
server = { ok: false, reason: "The server URL is not valid.", next: "Fix it with cofluxd up --server <url>." };
|
|
954
1338
|
}
|
|
955
1339
|
|
|
956
|
-
|
|
1340
|
+
info("");
|
|
1341
|
+
info("Local access");
|
|
957
1342
|
const direct = await probeLocalDirect();
|
|
958
|
-
const facts = printLocalFacts();
|
|
959
|
-
const relayReady = center.ok && facts.running && facts.connState === "connected";
|
|
960
1343
|
|
|
1344
|
+
info("");
|
|
1345
|
+
const { running, label } = await serviceState();
|
|
1346
|
+
const registered = fs.existsSync(CRED);
|
|
1347
|
+
// Three states, because "no snapshot" is not "not connected": an older runtime does not write one.
|
|
1348
|
+
let connState = "unknown";
|
|
1349
|
+
let connection = running ? "unknown" : "not checked; the service is not running";
|
|
1350
|
+
if (running) {
|
|
1351
|
+
const conn = readConnState();
|
|
1352
|
+
if (CONNECTION_LABEL[conn?.state]) {
|
|
1353
|
+
connection = CONNECTION_LABEL[conn.state];
|
|
1354
|
+
connState = conn.state === "connected" ? "connected" : "not-connected";
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
table([
|
|
1358
|
+
["Service", label],
|
|
1359
|
+
["Account", registered ? "added" : "not added yet"],
|
|
1360
|
+
["Connection", connection],
|
|
1361
|
+
...(IS_MAC ? [["Disk access", readFdaStatus() === "granted" ? "granted" : "not granted; run cofluxd fda"]] : []),
|
|
1362
|
+
]);
|
|
1363
|
+
info("");
|
|
1364
|
+
|
|
1365
|
+
const relayReady = server.ok && running && connState === "connected";
|
|
961
1366
|
if (direct.ready && relayReady) {
|
|
962
|
-
|
|
1367
|
+
success("The server connection and direct local access both work.");
|
|
963
1368
|
} else if (!direct.ready && relayReady) {
|
|
964
|
-
|
|
965
|
-
} else if (direct.ready && !
|
|
966
|
-
|
|
1369
|
+
warn("Direct local access is degraded, but the server connection works: the device is online.", "Check the local access items above.");
|
|
1370
|
+
} else if (direct.ready && !server.ok) {
|
|
1371
|
+
warn("The server is unreachable, but direct local access works.", "Pages already open keep working; reloading them may not.");
|
|
967
1372
|
} else if (direct.ready) {
|
|
968
|
-
|
|
969
|
-
} else if (
|
|
970
|
-
|
|
1373
|
+
warn("Direct local access works. The server is reachable, but the device has not confirmed its connection.", "Check again in a moment with cofluxd status, or see cofluxd logs.");
|
|
1374
|
+
} else if (server.ok) {
|
|
1375
|
+
warn("Direct local access is degraded, and the device has not confirmed its server connection.", "This does not mean the device is offline. See cofluxd logs.");
|
|
971
1376
|
} else {
|
|
972
|
-
|
|
1377
|
+
printError(`${server.reason} Direct local access is degraded too.`, server.next);
|
|
973
1378
|
}
|
|
974
1379
|
}
|
|
975
1380
|
|
|
976
|
-
function cmdLogs(v) {
|
|
977
|
-
|
|
978
|
-
|
|
1381
|
+
async function cmdLogs(v) {
|
|
1382
|
+
const watcher = await watchRequest("status");
|
|
1383
|
+
if (IS_MAC || watcher || serviceMode() === "self-managed") {
|
|
1384
|
+
if (!fs.existsSync(LOG_FILE)) die("There is no log yet.", "Start the service with cofluxd up.");
|
|
979
1385
|
run("tail", v.follow ? ["-f", LOG_FILE] : ["-n", "100", LOG_FILE], { stdio: "inherit" });
|
|
980
1386
|
} else if (IS_LINUX) {
|
|
981
1387
|
run("journalctl", ["--user", "-u", "coflux-daemon.service", ...(v.follow ? ["-f"] : ["-n", "100"])], { stdio: "inherit" });
|
|
982
1388
|
}
|
|
983
1389
|
}
|
|
984
1390
|
|
|
985
|
-
//
|
|
986
|
-
//
|
|
987
|
-
//
|
|
1391
|
+
// Full Disk Access (FDA): macOS never lets a program open the FDA prompt itself, by design. The best
|
|
1392
|
+
// this can do is detect, open System Settings and guide. The grant goes to the launcher binary: the
|
|
1393
|
+
// runtime and agents are its children, and TCC attributes them to the launchd job's responsible process.
|
|
988
1394
|
async function cmdFda() {
|
|
989
|
-
if (!IS_MAC) die("
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1395
|
+
if (!IS_MAC) die("cofluxd fda is only for macOS.", "Linux needs no extra permission.");
|
|
1396
|
+
info("Grant Full Disk Access to Coflux");
|
|
1397
|
+
info("");
|
|
1398
|
+
info("macOS asks you to add the program yourself. In System Settings, open");
|
|
1399
|
+
info("Privacy & Security > Full Disk Access, then drag in this file and turn it on:");
|
|
1400
|
+
info("");
|
|
1401
|
+
info(` ${bold(LAUNCHER_BIN)}`);
|
|
1402
|
+
info("");
|
|
1403
|
+
info("Opening System Settings and showing the file in Finder...");
|
|
995
1404
|
run("open", ["x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"]);
|
|
996
|
-
run("open", ["-R",
|
|
1405
|
+
run("open", ["-R", LAUNCHER_BIN]);
|
|
997
1406
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
998
1407
|
try {
|
|
999
|
-
await rl.question("
|
|
1408
|
+
await rl.question("Press Enter once it is added and turned on... ");
|
|
1000
1409
|
} finally {
|
|
1001
1410
|
rl.close();
|
|
1002
1411
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1412
|
+
// The grant only applies to processes started after it.
|
|
1413
|
+
await restartService("launchd", executorRuntime());
|
|
1414
|
+
success("Service restarted. Check the result with cofluxd status in a moment.");
|
|
1005
1415
|
}
|
|
1006
1416
|
|
|
1007
|
-
function cmdUninstall(v) {
|
|
1008
|
-
|
|
1417
|
+
async function cmdUninstall(v) {
|
|
1418
|
+
const watcher = await watchRequest("status");
|
|
1419
|
+
const mode = watcher ? "self-managed" : serviceMode();
|
|
1420
|
+
if (mode === "self-managed") await stopSelfManaged();
|
|
1421
|
+
else stopService(mode, { strict: true });
|
|
1009
1422
|
for (const file of IS_MAC ? [PLIST, PTYD_PLIST] : [UNIT, PTYD_UNIT]) {
|
|
1010
1423
|
try { fs.rmSync(file); } catch { /* */ }
|
|
1011
1424
|
}
|
|
1012
|
-
if (IS_LINUX) run("systemctl", ["--user", "daemon-reload"]);
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
cofluxd
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1425
|
+
if (IS_LINUX && systemdBooted()) run("systemctl", ["--user", "daemon-reload"]);
|
|
1426
|
+
clearServiceMode();
|
|
1427
|
+
if (v.purge) {
|
|
1428
|
+
try { fs.rmSync(HOME, { recursive: true, force: true }); } catch { /* */ }
|
|
1429
|
+
success(`Removed Coflux and everything in ${HOME}`);
|
|
1430
|
+
} else {
|
|
1431
|
+
success("Service removed");
|
|
1432
|
+
info(`Binaries, settings and credentials stay in ${HOME}. Run cofluxd uninstall --purge to remove them too.`);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const HELP = `Run this machine as a Coflux device: install, start, stop and update its service.
|
|
1437
|
+
|
|
1438
|
+
Usage:
|
|
1439
|
+
cofluxd <command> [flags]
|
|
1440
|
+
|
|
1441
|
+
Commands:
|
|
1442
|
+
up Install and start the service, then add this device to your account
|
|
1443
|
+
run Run the service in the foreground, as a container's entry point
|
|
1444
|
+
status Show the device, its service and its connection
|
|
1445
|
+
doctor Check the connection to the server and local access
|
|
1446
|
+
update Download the latest version; restart afterwards to apply it
|
|
1447
|
+
restart Restart the service; terminals stay open
|
|
1448
|
+
restart --ptyd Restart everything; closes all terminals on this device
|
|
1449
|
+
fda Grant Full Disk Access (macOS only)
|
|
1450
|
+
logs [-f] Show the service log
|
|
1451
|
+
down Stop the service; closes all terminals
|
|
1452
|
+
uninstall Remove the service
|
|
1453
|
+
|
|
1454
|
+
With no command, cofluxd runs up on a new device and status on a configured one.
|
|
1455
|
+
|
|
1456
|
+
Flags for up and run:
|
|
1457
|
+
--server <url> Server to connect to (default ${DEFAULT_SERVER})
|
|
1458
|
+
--name <name> Device name (default: the hostname)
|
|
1459
|
+
--shell <path> Shell for new terminals
|
|
1460
|
+
--key <key> One-time join key from Add Device in Coflux, valid for 1 hour
|
|
1461
|
+
--no-start Install without starting (up only)
|
|
1462
|
+
|
|
1463
|
+
Flags for up, run and update:
|
|
1464
|
+
--version <tag> Version to install, such as v2.15.0, or latest. up and run keep the
|
|
1465
|
+
installed version; update installs the latest.
|
|
1466
|
+
--bin-dir <dir> Install from a local build instead of downloading
|
|
1467
|
+
|
|
1468
|
+
Other flags:
|
|
1469
|
+
--purge With uninstall: also remove binaries, settings and credentials
|
|
1470
|
+
-f, --follow With logs: keep printing new lines
|
|
1471
|
+
-h, --help Show this help
|
|
1472
|
+
|
|
1473
|
+
Settings are kept in ~/.coflux/settings.json; run cofluxd up again after editing it.
|
|
1474
|
+
|
|
1475
|
+
On Linux without systemd, cofluxd runs the service itself. It does not start on boot; in a
|
|
1476
|
+
container, use cofluxd run as the entry point. For sign-in and terminals, use coflux.`;
|
|
1477
|
+
|
|
1478
|
+
/** parseArgs errors, first sentence only (the same wording as coflux). */
|
|
1479
|
+
function argumentError(error) {
|
|
1480
|
+
const first = String(error?.message ?? error).split(". ")[0];
|
|
1481
|
+
const missing = /^Option '(.+)' argument missing/.exec(first);
|
|
1482
|
+
if (missing) return `Option '${missing[1]}' needs a value`;
|
|
1483
|
+
const unexpected = /^Option '(.+)' does not take an argument/.exec(first);
|
|
1484
|
+
if (unexpected) return `Option '${unexpected[1]}' does not take a value`;
|
|
1485
|
+
return first;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
let parsedArgs;
|
|
1489
|
+
try {
|
|
1490
|
+
parsedArgs = parseArgs({
|
|
1491
|
+
allowPositionals: true,
|
|
1492
|
+
options: {
|
|
1493
|
+
server: { type: "string" }, name: { type: "string" }, shell: { type: "string" }, key: { type: "string" },
|
|
1494
|
+
version: { type: "string" }, "bin-dir": { type: "string" },
|
|
1495
|
+
"no-start": { type: "boolean", default: false }, purge: { type: "boolean", default: false },
|
|
1496
|
+
ptyd: { type: "boolean", default: false },
|
|
1497
|
+
follow: { type: "boolean", short: "f", default: false }, help: { type: "boolean", short: "h", default: false },
|
|
1498
|
+
},
|
|
1499
|
+
});
|
|
1500
|
+
} catch (error) {
|
|
1501
|
+
die(argumentError(error));
|
|
1502
|
+
}
|
|
1503
|
+
const { values, positionals } = parsedArgs;
|
|
1046
1504
|
const cmd = positionals[0] || (fs.existsSync(SETTINGS) ? "status" : "up");
|
|
1047
1505
|
if (values.help || cmd === "help") { console.log(HELP); process.exit(0); }
|
|
1048
|
-
const handlers = { up: cmdUp, update: cmdUpdate, restart: cmdRestart, down: cmdDown, status: cmdStatus, doctor: cmdDoctor, fda: cmdFda, logs: cmdLogs, uninstall: cmdUninstall, "service-files": cmdServiceFiles };
|
|
1506
|
+
const handlers = { up: cmdUp, run: cmdRun, update: cmdUpdate, restart: cmdRestart, down: cmdDown, status: cmdStatus, doctor: cmdDoctor, fda: cmdFda, logs: cmdLogs, uninstall: cmdUninstall, "service-files": cmdServiceFiles };
|
|
1049
1507
|
const handler = handlers[cmd];
|
|
1050
|
-
if (!handler) die(
|
|
1508
|
+
if (!handler) die(`Unknown command: ${cmd}`, "Run cofluxd --help to see the commands. For sign-in and terminals, use coflux.");
|
|
1051
1509
|
await handler(values);
|