viveworker 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -0
- package/launchd/io.viveworker.app.plist.example +39 -0
- package/ntfy/docker-compose.yml.example +10 -0
- package/ntfy/server.yml.example +28 -0
- package/package.json +24 -0
- package/scripts/lib/markdown-render.mjs +274 -0
- package/scripts/lib/pairing.mjs +83 -0
- package/scripts/viveworker-bridge.mjs +8892 -0
- package/scripts/viveworker.mjs +1353 -0
- package/viveworker.env.example +99 -0
- package/web/app.css +2303 -0
- package/web/app.js +3867 -0
- package/web/i18n.js +937 -0
- package/web/icons/apple-touch-icon.png +0 -0
- package/web/icons/viveworker-beacon-v.svg +19 -0
- package/web/icons/viveworker-icon-1024.png +0 -0
- package/web/icons/viveworker-icon-192.png +0 -0
- package/web/icons/viveworker-icon-512.png +0 -0
- package/web/icons/viveworker-v-check.svg +19 -0
- package/web/icons/viveworker-v-pulse.svg +24 -0
- package/web/index.html +17 -0
- package/web/manifest.webmanifest +22 -0
- package/web/sw.js +153 -0
|
@@ -0,0 +1,1353 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { promises as fs } from "node:fs";
|
|
6
|
+
import { createServer as createHttpServer } from "node:http";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import process from "node:process";
|
|
10
|
+
import { createInterface as createReadlineInterface } from "node:readline/promises";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { DEFAULT_LOCALE, normalizeLocale, t } from "../web/i18n.js";
|
|
13
|
+
import { generatePairingCredentials, shouldRotatePairing, upsertEnvText } from "./lib/pairing.mjs";
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = path.dirname(__filename);
|
|
17
|
+
const packageRoot = path.resolve(__dirname, "..");
|
|
18
|
+
const bridgeScript = path.join(packageRoot, "scripts", "viveworker-bridge.mjs");
|
|
19
|
+
const defaultConfigDir = path.join(os.homedir(), ".viveworker");
|
|
20
|
+
const defaultEnvFile = path.join(defaultConfigDir, "config.env");
|
|
21
|
+
const defaultStateFile = path.join(defaultConfigDir, "state.json");
|
|
22
|
+
const defaultLogDir = path.join(defaultConfigDir, "logs");
|
|
23
|
+
const defaultLogFile = path.join(defaultLogDir, "viveworker.log");
|
|
24
|
+
const defaultPidFile = path.join(defaultConfigDir, "viveworker.pid");
|
|
25
|
+
const defaultLaunchAgentPath = path.join(os.homedir(), "Library", "LaunchAgents", "io.viveworker.app.plist");
|
|
26
|
+
const defaultLabel = "io.viveworker.app";
|
|
27
|
+
const defaultTlsDir = path.join(defaultConfigDir, "tls");
|
|
28
|
+
const defaultServerPort = 8810;
|
|
29
|
+
|
|
30
|
+
const cli = parseArgs(process.argv.slice(2));
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
await main(cli);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (process.stdout.isTTY) {
|
|
36
|
+
process.stdout.write("\n");
|
|
37
|
+
}
|
|
38
|
+
console.error(error.message || String(error));
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function main(cliOptions) {
|
|
43
|
+
switch (cliOptions.command) {
|
|
44
|
+
case "setup":
|
|
45
|
+
await runSetup(cliOptions);
|
|
46
|
+
return;
|
|
47
|
+
case "start":
|
|
48
|
+
await runStart(cliOptions);
|
|
49
|
+
return;
|
|
50
|
+
case "stop":
|
|
51
|
+
await runStop(cliOptions);
|
|
52
|
+
return;
|
|
53
|
+
case "status":
|
|
54
|
+
await runStatus(cliOptions);
|
|
55
|
+
return;
|
|
56
|
+
case "doctor":
|
|
57
|
+
await runDoctor(cliOptions);
|
|
58
|
+
return;
|
|
59
|
+
case "help":
|
|
60
|
+
default:
|
|
61
|
+
printHelp();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function runSetup(cliOptions) {
|
|
66
|
+
const configDir = resolvePath(cliOptions.configDir || defaultConfigDir);
|
|
67
|
+
const envFile = resolvePath(cliOptions.envFile || path.join(configDir, "config.env"));
|
|
68
|
+
const stateFile = resolvePath(cliOptions.stateFile || path.join(configDir, "state.json"));
|
|
69
|
+
const logFile = resolvePath(cliOptions.logFile || path.join(configDir, "logs", "viveworker.log"));
|
|
70
|
+
const pidFile = resolvePath(cliOptions.pidFile || path.join(configDir, "viveworker.pid"));
|
|
71
|
+
const launchAgentPath = resolvePath(cliOptions.launchAgentPath || defaultLaunchAgentPath);
|
|
72
|
+
const existing = await maybeReadEnvFile(envFile);
|
|
73
|
+
const locale = await resolveSetupLocale(cliOptions, existing);
|
|
74
|
+
const progress = createCliProgressReporter(locale);
|
|
75
|
+
progress.update("cli.setup.progress.prepare");
|
|
76
|
+
const port = cliOptions.port || Number(existing.NATIVE_APPROVAL_SERVER_PORT) || defaultServerPort;
|
|
77
|
+
const hostname = cliOptions.hostname || existing.VIVEWORKER_HOSTNAME || os.hostname();
|
|
78
|
+
const localHostname = hostname.endsWith(".local") ? hostname : `${hostname}.local`;
|
|
79
|
+
const ips = await findLocalIpv4Addresses();
|
|
80
|
+
const chosenIp = ips[0] || "127.0.0.1";
|
|
81
|
+
const webPushEnabled = resolveSetupWebPushEnabled(cliOptions);
|
|
82
|
+
const allowInsecureHttpLan = Boolean(cliOptions.allowInsecureHttpLan && !webPushEnabled);
|
|
83
|
+
const tlsCertFile = resolvePath(
|
|
84
|
+
cliOptions.tlsCertFile || existing.TLS_CERT_FILE || path.join(configDir, "tls", "cert.pem")
|
|
85
|
+
);
|
|
86
|
+
const tlsKeyFile = resolvePath(
|
|
87
|
+
cliOptions.tlsKeyFile || existing.TLS_KEY_FILE || path.join(configDir, "tls", "key.pem")
|
|
88
|
+
);
|
|
89
|
+
const scheme = webPushEnabled ? "https" : "http";
|
|
90
|
+
const publicBaseUrl = webPushEnabled || allowInsecureHttpLan
|
|
91
|
+
? `${scheme}://${localHostname}:${port}`
|
|
92
|
+
: `http://127.0.0.1:${port}`;
|
|
93
|
+
const fallbackBaseUrl = webPushEnabled || allowInsecureHttpLan
|
|
94
|
+
? `${scheme}://${chosenIp}:${port}`
|
|
95
|
+
: publicBaseUrl;
|
|
96
|
+
const listenHost = webPushEnabled || allowInsecureHttpLan ? "0.0.0.0" : "127.0.0.1";
|
|
97
|
+
const shouldRotatePairingValue = shouldRotatePairing(
|
|
98
|
+
{
|
|
99
|
+
force: cliOptions.pair,
|
|
100
|
+
pairingCode: existing.PAIRING_CODE,
|
|
101
|
+
pairingToken: existing.PAIRING_TOKEN,
|
|
102
|
+
pairingExpiresAtMs: existing.PAIRING_EXPIRES_AT_MS,
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
const nextPairing = shouldRotatePairingValue ? generatePairingCredentials() : null;
|
|
106
|
+
const pairCode =
|
|
107
|
+
cliOptions.pairCode ||
|
|
108
|
+
(shouldRotatePairingValue ? nextPairing.pairingCode : existing.PAIRING_CODE) ||
|
|
109
|
+
generatePairingCredentials().pairingCode;
|
|
110
|
+
const pairToken =
|
|
111
|
+
cliOptions.pairToken ||
|
|
112
|
+
(shouldRotatePairingValue ? nextPairing.pairingToken : existing.PAIRING_TOKEN) ||
|
|
113
|
+
generatePairingCredentials().pairingToken;
|
|
114
|
+
const sessionSecret =
|
|
115
|
+
cliOptions.sessionSecret ||
|
|
116
|
+
existing.SESSION_SECRET ||
|
|
117
|
+
crypto.randomBytes(32).toString("hex");
|
|
118
|
+
const deviceTrustTtlMs = Number(existing.DEVICE_TRUST_TTL_MS) || 30 * 24 * 60 * 60 * 1000;
|
|
119
|
+
const pairingExpiresAtMs = shouldRotatePairingValue
|
|
120
|
+
? nextPairing.pairingExpiresAtMs
|
|
121
|
+
: Number(existing.PAIRING_EXPIRES_AT_MS) || Date.now() + 15 * 60 * 1000;
|
|
122
|
+
const enableNtfy = Boolean(cliOptions.enableNtfy);
|
|
123
|
+
const webPushSubject =
|
|
124
|
+
cliOptions.webPushSubject ||
|
|
125
|
+
existing.WEB_PUSH_SUBJECT ||
|
|
126
|
+
"mailto:viveworker@example.com";
|
|
127
|
+
const tlsAssets = webPushEnabled
|
|
128
|
+
? await ensureWebPushAssets({
|
|
129
|
+
cliOptions,
|
|
130
|
+
existing,
|
|
131
|
+
hostname,
|
|
132
|
+
localHostname,
|
|
133
|
+
locale,
|
|
134
|
+
progress,
|
|
135
|
+
chosenIp,
|
|
136
|
+
tlsCertFile,
|
|
137
|
+
tlsKeyFile,
|
|
138
|
+
})
|
|
139
|
+
: null;
|
|
140
|
+
|
|
141
|
+
progress.update("cli.setup.progress.writeConfig");
|
|
142
|
+
await fs.mkdir(path.dirname(envFile), { recursive: true });
|
|
143
|
+
await fs.mkdir(path.dirname(logFile), { recursive: true });
|
|
144
|
+
|
|
145
|
+
const envLines = [
|
|
146
|
+
`WEB_UI_ENABLED=1`,
|
|
147
|
+
`AUTH_REQUIRED=1`,
|
|
148
|
+
`VIVEWORKER_HOSTNAME=${hostname}`,
|
|
149
|
+
`CODEX_HOME=${existing.CODEX_HOME || process.env.CODEX_HOME || path.join(os.homedir(), ".codex")}`,
|
|
150
|
+
`STATE_FILE=${stateFile}`,
|
|
151
|
+
`NATIVE_APPROVAL_SERVER_HOST=${listenHost}`,
|
|
152
|
+
`NATIVE_APPROVAL_SERVER_PORT=${port}`,
|
|
153
|
+
`NATIVE_APPROVAL_SERVER_PUBLIC_BASE_URL=${publicBaseUrl}`,
|
|
154
|
+
`SESSION_SECRET=${sessionSecret}`,
|
|
155
|
+
`DEVICE_TRUST_TTL_MS=${deviceTrustTtlMs}`,
|
|
156
|
+
`DEFAULT_LOCALE=${locale}`,
|
|
157
|
+
`WEB_PUSH_ENABLED=${webPushEnabled ? 1 : 0}`,
|
|
158
|
+
`ALLOW_INSECURE_LAN_HTTP=${allowInsecureHttpLan ? 1 : 0}`,
|
|
159
|
+
webPushEnabled ? `TLS_CERT_FILE=${tlsAssets.certFile}` : null,
|
|
160
|
+
webPushEnabled ? `TLS_KEY_FILE=${tlsAssets.keyFile}` : null,
|
|
161
|
+
webPushEnabled ? `WEB_PUSH_VAPID_PUBLIC_KEY=${tlsAssets.vapidPublicKey}` : null,
|
|
162
|
+
webPushEnabled ? `WEB_PUSH_VAPID_PRIVATE_KEY=${tlsAssets.vapidPrivateKey}` : null,
|
|
163
|
+
webPushEnabled ? `WEB_PUSH_SUBJECT=${webPushSubject}` : null,
|
|
164
|
+
`PAIRING_CODE=${pairCode}`,
|
|
165
|
+
`PAIRING_TOKEN=${pairToken}`,
|
|
166
|
+
`PAIRING_EXPIRES_AT_MS=${pairingExpiresAtMs}`,
|
|
167
|
+
`CHOICE_PAGE_SIZE=5`,
|
|
168
|
+
`MAX_HISTORY_ITEMS=100`,
|
|
169
|
+
`NATIVE_APPROVALS=1`,
|
|
170
|
+
`NOTIFY_APPROVALS=${enableNtfy ? 1 : 0}`,
|
|
171
|
+
`NOTIFY_PLANS=${enableNtfy ? 1 : 0}`,
|
|
172
|
+
`NOTIFY_COMPLETIONS=${enableNtfy ? 1 : 0}`,
|
|
173
|
+
`ENABLE_NTFY=${enableNtfy ? 1 : 0}`,
|
|
174
|
+
enableNtfy && existing.NTFY_BASE_URL ? `NTFY_BASE_URL=${existing.NTFY_BASE_URL}` : null,
|
|
175
|
+
enableNtfy && existing.NTFY_PUBLISH_BASE_URL ? `NTFY_PUBLISH_BASE_URL=${existing.NTFY_PUBLISH_BASE_URL}` : null,
|
|
176
|
+
enableNtfy && existing.NTFY_TOPIC ? `NTFY_TOPIC=${existing.NTFY_TOPIC}` : null,
|
|
177
|
+
enableNtfy && existing.NTFY_ACCESS_TOKEN ? `NTFY_ACCESS_TOKEN=${existing.NTFY_ACCESS_TOKEN}` : null,
|
|
178
|
+
].filter(Boolean);
|
|
179
|
+
|
|
180
|
+
await fs.writeFile(envFile, `${envLines.join("\n")}\n`, "utf8");
|
|
181
|
+
|
|
182
|
+
if (!cliOptions.noLaunchd) {
|
|
183
|
+
progress.update("cli.setup.progress.launchd");
|
|
184
|
+
const plist = buildLaunchAgentPlist({
|
|
185
|
+
label: defaultLabel,
|
|
186
|
+
nodePath: process.execPath,
|
|
187
|
+
bridgeScript,
|
|
188
|
+
envFile,
|
|
189
|
+
logFile,
|
|
190
|
+
});
|
|
191
|
+
await fs.mkdir(path.dirname(launchAgentPath), { recursive: true });
|
|
192
|
+
await fs.writeFile(launchAgentPath, plist, "utf8");
|
|
193
|
+
await execCommand(["launchctl", "bootout", `gui/${process.getuid()}`, launchAgentPath], { ignoreError: true });
|
|
194
|
+
await execCommand(["launchctl", "bootstrap", `gui/${process.getuid()}`, launchAgentPath]);
|
|
195
|
+
await execCommand(["launchctl", "kickstart", "-k", `gui/${process.getuid()}/${defaultLabel}`]);
|
|
196
|
+
} else {
|
|
197
|
+
progress.update("cli.setup.progress.startBridge");
|
|
198
|
+
await startDetachedBridge({ envFile, logFile, pidFile });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
progress.update("cli.setup.progress.health");
|
|
202
|
+
const healthy = await waitForHealth(buildLoopbackHealthUrl(publicBaseUrl));
|
|
203
|
+
progress.done(healthy ? "cli.setup.complete" : "cli.setup.completePending");
|
|
204
|
+
|
|
205
|
+
const pairPath = `/app?pairToken=${encodeURIComponent(pairToken)}`;
|
|
206
|
+
const mkcertRootCaFile = resolvePath(
|
|
207
|
+
existing.MKCERT_ROOT_CA_FILE || process.env.MKCERT_ROOT_CA_FILE || "~/Library/Application Support/mkcert/rootCA.pem"
|
|
208
|
+
);
|
|
209
|
+
const canShowCaDownload = webPushEnabled && await fileExists(mkcertRootCaFile);
|
|
210
|
+
const caPath = "/ca/rootCA.pem";
|
|
211
|
+
let caDownloadLocalUrl = `${publicBaseUrl}${caPath}`;
|
|
212
|
+
let caDownloadIpUrl = `${fallbackBaseUrl}${caPath}`;
|
|
213
|
+
let temporaryCaServer = null;
|
|
214
|
+
|
|
215
|
+
if (cliOptions.installMkcert && canShowCaDownload) {
|
|
216
|
+
temporaryCaServer = await startTemporaryCaDownloadServer({
|
|
217
|
+
rootCaFile: mkcertRootCaFile,
|
|
218
|
+
preferredPort: port + 1,
|
|
219
|
+
localHostname,
|
|
220
|
+
fallbackIp: chosenIp,
|
|
221
|
+
pathName: caPath,
|
|
222
|
+
});
|
|
223
|
+
caDownloadLocalUrl = temporaryCaServer.localUrl;
|
|
224
|
+
caDownloadIpUrl = temporaryCaServer.ipUrl;
|
|
225
|
+
console.log("");
|
|
226
|
+
console.log(t(locale, webPushEnabled ? "cli.setup.webPushEnabled" : "cli.setup.webPushDisabled"));
|
|
227
|
+
console.log(t(locale, "cli.setup.caFlow.title"));
|
|
228
|
+
console.log(t(locale, "cli.setup.caDownloadLocal", { url: caDownloadLocalUrl }));
|
|
229
|
+
console.log(t(locale, "cli.setup.caDownloadIp", { url: caDownloadIpUrl }));
|
|
230
|
+
console.log("");
|
|
231
|
+
console.log(t(locale, "cli.setup.caFlow.step1"));
|
|
232
|
+
console.log(t(locale, "cli.setup.caFlow.step2"));
|
|
233
|
+
console.log(t(locale, "cli.setup.caFlow.step3"));
|
|
234
|
+
console.log("");
|
|
235
|
+
console.log(t(locale, "cli.setup.qrCaDownload"));
|
|
236
|
+
await printQrCode(caDownloadIpUrl);
|
|
237
|
+
try {
|
|
238
|
+
await waitForEnter(locale, "cli.setup.prompt.continueToApp");
|
|
239
|
+
} finally {
|
|
240
|
+
await temporaryCaServer.close();
|
|
241
|
+
temporaryCaServer = null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
console.log("");
|
|
246
|
+
if (cliOptions.pair) {
|
|
247
|
+
console.log(t(locale, "cli.setup.pairRefresh.title"));
|
|
248
|
+
console.log(t(locale, "cli.setup.pairRefresh.copy"));
|
|
249
|
+
console.log(t(locale, "cli.setup.pairRefresh.reminder"));
|
|
250
|
+
console.log("");
|
|
251
|
+
}
|
|
252
|
+
console.log(t(locale, "cli.setup.primaryUrl", { url: publicBaseUrl }));
|
|
253
|
+
console.log(t(locale, "cli.setup.fallbackUrl", { url: fallbackBaseUrl }));
|
|
254
|
+
console.log(t(locale, "cli.setup.pairingCode", { code: pairCode }));
|
|
255
|
+
console.log(t(locale, "cli.setup.pairingUrlLocal", { url: `${publicBaseUrl}${pairPath}` }));
|
|
256
|
+
console.log(t(locale, "cli.setup.pairingUrlIp", { url: `${fallbackBaseUrl}${pairPath}` }));
|
|
257
|
+
console.log(t(locale, webPushEnabled ? "cli.setup.webPushEnabled" : "cli.setup.webPushDisabled"));
|
|
258
|
+
if (allowInsecureHttpLan) {
|
|
259
|
+
console.log(t(locale, "cli.setup.warning.insecureHttpLan"));
|
|
260
|
+
}
|
|
261
|
+
if (canShowCaDownload && !cliOptions.installMkcert) {
|
|
262
|
+
console.log(t(locale, "cli.setup.caDownloadLocal", { url: caDownloadLocalUrl }));
|
|
263
|
+
console.log(t(locale, "cli.setup.caDownloadIp", { url: caDownloadIpUrl }));
|
|
264
|
+
}
|
|
265
|
+
console.log("");
|
|
266
|
+
if (webPushEnabled) {
|
|
267
|
+
console.log(t(locale, cliOptions.installMkcert ? "cli.setup.instructions.afterCa" : "cli.setup.instructions.https"));
|
|
268
|
+
} else if (allowInsecureHttpLan) {
|
|
269
|
+
console.log(t(locale, "cli.setup.instructions.insecureHttpLan"));
|
|
270
|
+
} else {
|
|
271
|
+
console.log(t(locale, "cli.setup.instructions.localOnlyHttp"));
|
|
272
|
+
}
|
|
273
|
+
console.log("");
|
|
274
|
+
console.log(t(locale, "cli.setup.qrPairing"));
|
|
275
|
+
await printQrCode(`${publicBaseUrl}${pairPath}`);
|
|
276
|
+
if (canShowCaDownload && !cliOptions.installMkcert) {
|
|
277
|
+
console.log("");
|
|
278
|
+
console.log(t(locale, "cli.setup.qrCaDownload"));
|
|
279
|
+
await printQrCode(caDownloadIpUrl);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function runStart(cliOptions) {
|
|
284
|
+
const configDir = resolvePath(cliOptions.configDir || defaultConfigDir);
|
|
285
|
+
const envFile = resolvePath(cliOptions.envFile || path.join(configDir, "config.env"));
|
|
286
|
+
const initialLocale = await resolveCliLocale(cliOptions);
|
|
287
|
+
const progress = createCliProgressReporter(initialLocale);
|
|
288
|
+
progress.update("cli.start.progress.prepare");
|
|
289
|
+
let config = await ensureDefaultLocalePersisted(envFile, cliOptions);
|
|
290
|
+
const locale = await resolveCliLocale(cliOptions, config);
|
|
291
|
+
progress.setLocale(locale);
|
|
292
|
+
const rotatedPairing = await maybeRotateStartupPairing(envFile, config);
|
|
293
|
+
if (rotatedPairing.rotated) {
|
|
294
|
+
progress.update("cli.start.progress.refreshPairing");
|
|
295
|
+
config = {
|
|
296
|
+
...config,
|
|
297
|
+
PAIRING_CODE: rotatedPairing.pairingCode,
|
|
298
|
+
PAIRING_TOKEN: rotatedPairing.pairingToken,
|
|
299
|
+
PAIRING_EXPIRES_AT_MS: String(rotatedPairing.pairingExpiresAtMs),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
const pidFile = resolvePath(cliOptions.pidFile || path.join(configDir, "viveworker.pid"));
|
|
303
|
+
const launchAgentPath = resolvePath(cliOptions.launchAgentPath || defaultLaunchAgentPath);
|
|
304
|
+
const healthUrl = buildLoopbackHealthUrl(config.NATIVE_APPROVAL_SERVER_PUBLIC_BASE_URL || "");
|
|
305
|
+
if (await fileExists(launchAgentPath)) {
|
|
306
|
+
progress.update("cli.start.progress.launchd");
|
|
307
|
+
await execCommand(["launchctl", "bootstrap", `gui/${process.getuid()}`, launchAgentPath], { ignoreError: true });
|
|
308
|
+
progress.update("cli.start.progress.kickstart");
|
|
309
|
+
await execCommand(["launchctl", "kickstart", "-k", `gui/${process.getuid()}/${defaultLabel}`]);
|
|
310
|
+
progress.update("cli.start.progress.health");
|
|
311
|
+
const healthy = await waitForHealth(healthUrl);
|
|
312
|
+
progress.done(healthy ? "cli.start.launchdStarted" : "cli.start.launchdStartedPending");
|
|
313
|
+
if (rotatedPairing.rotated) {
|
|
314
|
+
await printPairingInfo(locale, config);
|
|
315
|
+
}
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
progress.update("cli.start.progress.bridge");
|
|
320
|
+
await startDetachedBridge({
|
|
321
|
+
envFile,
|
|
322
|
+
logFile: resolvePath(cliOptions.logFile || defaultLogFile),
|
|
323
|
+
pidFile,
|
|
324
|
+
});
|
|
325
|
+
progress.update("cli.start.progress.health");
|
|
326
|
+
const healthy = await waitForHealth(healthUrl);
|
|
327
|
+
progress.done(healthy ? "cli.start.bridgeStarted" : "cli.start.bridgeStartedPending");
|
|
328
|
+
if (rotatedPairing.rotated) {
|
|
329
|
+
await printPairingInfo(locale, config);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function runStop(cliOptions) {
|
|
334
|
+
const configDir = resolvePath(cliOptions.configDir || defaultConfigDir);
|
|
335
|
+
const launchAgentPath = resolvePath(cliOptions.launchAgentPath || defaultLaunchAgentPath);
|
|
336
|
+
const pidFile = resolvePath(cliOptions.pidFile || path.join(configDir, "viveworker.pid"));
|
|
337
|
+
if (await fileExists(launchAgentPath)) {
|
|
338
|
+
await execCommand(["launchctl", "bootout", `gui/${process.getuid()}`, launchAgentPath], { ignoreError: true });
|
|
339
|
+
console.log(t(await resolveCliLocale(cliOptions), "cli.stop.launchdStopped"));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const pid = await maybeReadPid(pidFile);
|
|
344
|
+
if (!pid) {
|
|
345
|
+
console.log(t(await resolveCliLocale(cliOptions), "cli.stop.noProcess"));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
process.kill(pid, "SIGTERM");
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (error?.code !== "ESRCH") {
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
await fs.rm(pidFile, { force: true });
|
|
357
|
+
console.log(t(await resolveCliLocale(cliOptions), "cli.stop.stopped", { pid }));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function runStatus(cliOptions) {
|
|
361
|
+
const configDir = resolvePath(cliOptions.configDir || defaultConfigDir);
|
|
362
|
+
const envFile = resolvePath(cliOptions.envFile || path.join(configDir, "config.env"));
|
|
363
|
+
const config = await ensureDefaultLocalePersisted(envFile, cliOptions);
|
|
364
|
+
const baseUrl = config.NATIVE_APPROVAL_SERVER_PUBLIC_BASE_URL || "";
|
|
365
|
+
const healthUrl = baseUrl ? `${baseUrl}/health` : "";
|
|
366
|
+
const launchAgentPath = resolvePath(cliOptions.launchAgentPath || defaultLaunchAgentPath);
|
|
367
|
+
const pidFile = resolvePath(cliOptions.pidFile || path.join(configDir, "viveworker.pid"));
|
|
368
|
+
const webPushEnabled = truthyString(config.WEB_PUSH_ENABLED);
|
|
369
|
+
const httpsEnabled = isHttpsUrl(baseUrl);
|
|
370
|
+
const locale = await resolveCliLocale(cliOptions, config);
|
|
371
|
+
|
|
372
|
+
console.log(t(locale, "cli.status.envFile", { value: envFile }));
|
|
373
|
+
console.log(t(locale, "cli.status.baseUrl", { value: baseUrl || "(not configured)" }));
|
|
374
|
+
console.log(t(locale, "cli.status.webPush", { value: t(locale, webPushEnabled ? "cli.status.enabled" : "cli.status.disabled") }));
|
|
375
|
+
console.log(t(locale, "cli.status.https", { value: t(locale, httpsEnabled ? "cli.status.enabled" : "cli.status.disabled") }));
|
|
376
|
+
if (webPushEnabled) {
|
|
377
|
+
console.log(t(locale, "cli.status.tlsCert", { value: config.TLS_CERT_FILE || "(missing)" }));
|
|
378
|
+
console.log(t(locale, "cli.status.tlsKey", { value: config.TLS_KEY_FILE || "(missing)" }));
|
|
379
|
+
}
|
|
380
|
+
console.log(
|
|
381
|
+
t(locale, "cli.status.launchAgent", {
|
|
382
|
+
value: (await fileExists(launchAgentPath)) ? launchAgentPath : "(not installed)",
|
|
383
|
+
})
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
if (await fileExists(launchAgentPath)) {
|
|
387
|
+
const printed = await execCommand(
|
|
388
|
+
["launchctl", "print", `gui/${process.getuid()}/${defaultLabel}`],
|
|
389
|
+
{ ignoreError: true }
|
|
390
|
+
);
|
|
391
|
+
console.log(
|
|
392
|
+
t(locale, "cli.status.launchd", {
|
|
393
|
+
value: t(locale, printed.ok ? "cli.status.installed" : "cli.status.notRunning"),
|
|
394
|
+
})
|
|
395
|
+
);
|
|
396
|
+
} else {
|
|
397
|
+
const pid = await maybeReadPid(pidFile);
|
|
398
|
+
console.log(t(locale, "cli.status.pid", { value: pid || "(not running)" }));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (healthUrl) {
|
|
402
|
+
const health = await execCommand(buildHealthCheckArgs(healthUrl), { ignoreError: true });
|
|
403
|
+
console.log(t(locale, "cli.status.health", { value: t(locale, health.ok ? "cli.status.ok" : "cli.status.failed") }));
|
|
404
|
+
if (health.stdout) {
|
|
405
|
+
console.log(health.stdout.trim());
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function runDoctor(cliOptions) {
|
|
411
|
+
const configDir = resolvePath(cliOptions.configDir || defaultConfigDir);
|
|
412
|
+
const envFile = resolvePath(cliOptions.envFile || path.join(configDir, "config.env"));
|
|
413
|
+
const config = await ensureDefaultLocalePersisted(envFile, cliOptions);
|
|
414
|
+
const issues = [];
|
|
415
|
+
const baseUrl = config.NATIVE_APPROVAL_SERVER_PUBLIC_BASE_URL || "";
|
|
416
|
+
const healthUrl = baseUrl ? `${baseUrl}/health` : "";
|
|
417
|
+
const webPushEnabled = truthyString(config.WEB_PUSH_ENABLED);
|
|
418
|
+
const allowInsecureHttpLan = truthyString(config.ALLOW_INSECURE_LAN_HTTP);
|
|
419
|
+
const hostname = config.VIVEWORKER_HOSTNAME || os.hostname();
|
|
420
|
+
const localHostname = hostname.endsWith(".local") ? hostname : `${hostname}.local`;
|
|
421
|
+
const ips = await findLocalIpv4Addresses();
|
|
422
|
+
const chosenIp = ips[0] || "127.0.0.1";
|
|
423
|
+
const locale = await resolveCliLocale(cliOptions, config);
|
|
424
|
+
|
|
425
|
+
if (!(await fileExists(envFile))) {
|
|
426
|
+
issues.push(t(locale, "cli.doctor.issue.envMissing"));
|
|
427
|
+
}
|
|
428
|
+
if (!config.SESSION_SECRET) {
|
|
429
|
+
issues.push(t(locale, "cli.doctor.issue.sessionSecretMissing"));
|
|
430
|
+
}
|
|
431
|
+
if (!config.PAIRING_CODE || !config.PAIRING_TOKEN) {
|
|
432
|
+
issues.push(t(locale, "cli.doctor.issue.pairingMissing"));
|
|
433
|
+
}
|
|
434
|
+
if (!baseUrl) {
|
|
435
|
+
issues.push(t(locale, "cli.doctor.issue.baseUrlMissing"));
|
|
436
|
+
}
|
|
437
|
+
if (baseUrl && !isHttpsUrl(baseUrl) && !isLoopbackBaseUrl(baseUrl) && !allowInsecureHttpLan) {
|
|
438
|
+
issues.push(t(locale, "cli.doctor.issue.lanHttpRequiresOverride"));
|
|
439
|
+
}
|
|
440
|
+
if (webPushEnabled) {
|
|
441
|
+
if (!isHttpsUrl(baseUrl)) {
|
|
442
|
+
issues.push(t(locale, "cli.doctor.issue.webPushHttps"));
|
|
443
|
+
}
|
|
444
|
+
if (!config.TLS_CERT_FILE || !(await fileExists(resolvePath(config.TLS_CERT_FILE)))) {
|
|
445
|
+
issues.push(t(locale, "cli.doctor.issue.tlsCertMissing"));
|
|
446
|
+
}
|
|
447
|
+
if (!config.TLS_KEY_FILE || !(await fileExists(resolvePath(config.TLS_KEY_FILE)))) {
|
|
448
|
+
issues.push(t(locale, "cli.doctor.issue.tlsKeyMissing"));
|
|
449
|
+
}
|
|
450
|
+
if (!config.WEB_PUSH_VAPID_PUBLIC_KEY) {
|
|
451
|
+
issues.push(t(locale, "cli.doctor.issue.vapidPublicMissing"));
|
|
452
|
+
}
|
|
453
|
+
if (!config.WEB_PUSH_VAPID_PRIVATE_KEY) {
|
|
454
|
+
issues.push(t(locale, "cli.doctor.issue.vapidPrivateMissing"));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (config.TLS_CERT_FILE && await fileExists(resolvePath(config.TLS_CERT_FILE))) {
|
|
458
|
+
const certificateIssues = await checkCertificateHosts({
|
|
459
|
+
certFile: resolvePath(config.TLS_CERT_FILE),
|
|
460
|
+
expectedHosts: collectTlsHosts({
|
|
461
|
+
hostname,
|
|
462
|
+
localHostname,
|
|
463
|
+
chosenIp,
|
|
464
|
+
}),
|
|
465
|
+
});
|
|
466
|
+
issues.push(...certificateIssues);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (healthUrl) {
|
|
470
|
+
const health = await execCommand(buildHealthCheckArgs(healthUrl), { ignoreError: true });
|
|
471
|
+
if (!health.ok) {
|
|
472
|
+
issues.push(t(locale, webPushEnabled ? "cli.doctor.issue.healthHttps" : "cli.doctor.issue.health"));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (issues.length === 0) {
|
|
477
|
+
console.log(t(locale, "cli.doctor.ok"));
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
console.log(t(locale, "cli.doctor.foundIssues"));
|
|
482
|
+
for (const issue of issues) {
|
|
483
|
+
console.log(`- ${issue}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function parseArgs(argv) {
|
|
488
|
+
const parsed = {
|
|
489
|
+
command: "help",
|
|
490
|
+
enableNtfy: false,
|
|
491
|
+
enableWebPush: false,
|
|
492
|
+
disableWebPush: false,
|
|
493
|
+
allowInsecureHttpLan: false,
|
|
494
|
+
installMkcert: false,
|
|
495
|
+
noLaunchd: false,
|
|
496
|
+
pair: false,
|
|
497
|
+
port: null,
|
|
498
|
+
hostname: "",
|
|
499
|
+
envFile: "",
|
|
500
|
+
configDir: "",
|
|
501
|
+
stateFile: "",
|
|
502
|
+
logFile: "",
|
|
503
|
+
pidFile: "",
|
|
504
|
+
launchAgentPath: "",
|
|
505
|
+
pairCode: "",
|
|
506
|
+
pairToken: "",
|
|
507
|
+
sessionSecret: "",
|
|
508
|
+
tlsCertFile: "",
|
|
509
|
+
tlsKeyFile: "",
|
|
510
|
+
webPushSubject: "",
|
|
511
|
+
vapidPublicKey: "",
|
|
512
|
+
vapidPrivateKey: "",
|
|
513
|
+
locale: "",
|
|
514
|
+
mkcertTrustStores: "",
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
if (argv[0] && !argv[0].startsWith("-")) {
|
|
518
|
+
parsed.command = argv[0];
|
|
519
|
+
argv = argv.slice(1);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
523
|
+
const arg = argv[index];
|
|
524
|
+
const next = argv[index + 1] ?? "";
|
|
525
|
+
if (arg === "--enable-ntfy") {
|
|
526
|
+
parsed.enableNtfy = true;
|
|
527
|
+
} else if (arg === "--enable-web-push") {
|
|
528
|
+
parsed.enableWebPush = true;
|
|
529
|
+
} else if (arg === "--disable-web-push") {
|
|
530
|
+
parsed.disableWebPush = true;
|
|
531
|
+
} else if (arg === "--allow-insecure-http-lan") {
|
|
532
|
+
parsed.allowInsecureHttpLan = true;
|
|
533
|
+
} else if (arg === "--install-mkcert") {
|
|
534
|
+
parsed.installMkcert = true;
|
|
535
|
+
} else if (arg === "--mkcert-trust-stores") {
|
|
536
|
+
parsed.mkcertTrustStores = next;
|
|
537
|
+
index += 1;
|
|
538
|
+
} else if (arg === "--no-launchd") {
|
|
539
|
+
parsed.noLaunchd = true;
|
|
540
|
+
} else if (arg === "--port") {
|
|
541
|
+
parsed.port = Number(next) || null;
|
|
542
|
+
index += 1;
|
|
543
|
+
} else if (arg === "--hostname") {
|
|
544
|
+
parsed.hostname = next;
|
|
545
|
+
index += 1;
|
|
546
|
+
} else if (arg === "--env-file") {
|
|
547
|
+
parsed.envFile = next;
|
|
548
|
+
index += 1;
|
|
549
|
+
} else if (arg === "--config-dir") {
|
|
550
|
+
parsed.configDir = next;
|
|
551
|
+
index += 1;
|
|
552
|
+
} else if (arg === "--state-file") {
|
|
553
|
+
parsed.stateFile = next;
|
|
554
|
+
index += 1;
|
|
555
|
+
} else if (arg === "--log-file") {
|
|
556
|
+
parsed.logFile = next;
|
|
557
|
+
index += 1;
|
|
558
|
+
} else if (arg === "--pid-file") {
|
|
559
|
+
parsed.pidFile = next;
|
|
560
|
+
index += 1;
|
|
561
|
+
} else if (arg === "--launch-agent-path") {
|
|
562
|
+
parsed.launchAgentPath = next;
|
|
563
|
+
index += 1;
|
|
564
|
+
} else if (arg === "--pair-code") {
|
|
565
|
+
parsed.pairCode = next;
|
|
566
|
+
index += 1;
|
|
567
|
+
} else if (arg === "--pair-token") {
|
|
568
|
+
parsed.pairToken = next;
|
|
569
|
+
index += 1;
|
|
570
|
+
} else if (arg === "--session-secret") {
|
|
571
|
+
parsed.sessionSecret = next;
|
|
572
|
+
index += 1;
|
|
573
|
+
} else if (arg === "--tls-cert-file") {
|
|
574
|
+
parsed.tlsCertFile = next;
|
|
575
|
+
index += 1;
|
|
576
|
+
} else if (arg === "--tls-key-file") {
|
|
577
|
+
parsed.tlsKeyFile = next;
|
|
578
|
+
index += 1;
|
|
579
|
+
} else if (arg === "--web-push-subject") {
|
|
580
|
+
parsed.webPushSubject = next;
|
|
581
|
+
index += 1;
|
|
582
|
+
} else if (arg === "--locale") {
|
|
583
|
+
parsed.locale = next;
|
|
584
|
+
index += 1;
|
|
585
|
+
} else if (arg === "--vapid-public-key") {
|
|
586
|
+
parsed.vapidPublicKey = next;
|
|
587
|
+
index += 1;
|
|
588
|
+
} else if (arg === "--vapid-private-key") {
|
|
589
|
+
parsed.vapidPrivateKey = next;
|
|
590
|
+
index += 1;
|
|
591
|
+
} else if (arg === "--pair") {
|
|
592
|
+
parsed.pair = true;
|
|
593
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
594
|
+
parsed.command = "help";
|
|
595
|
+
} else {
|
|
596
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (parsed.enableWebPush && parsed.disableWebPush) {
|
|
601
|
+
throw new Error("Use either --enable-web-push or --disable-web-push, not both.");
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return parsed;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function printHelp() {
|
|
608
|
+
const locale = normalizeLocale(process.env.DEFAULT_LOCALE || process.env.LANG || "") || DEFAULT_LOCALE;
|
|
609
|
+
console.log(`${t(locale, "cli.help.usage")}
|
|
610
|
+
|
|
611
|
+
${t(locale, "cli.help.commands")}
|
|
612
|
+
${t(locale, "cli.help.setup")}
|
|
613
|
+
${t(locale, "cli.help.start")}
|
|
614
|
+
${t(locale, "cli.help.stop")}
|
|
615
|
+
${t(locale, "cli.help.status")}
|
|
616
|
+
${t(locale, "cli.help.doctor")}
|
|
617
|
+
|
|
618
|
+
${t(locale, "cli.help.commonOptions")}
|
|
619
|
+
--port <n>
|
|
620
|
+
--hostname <name>
|
|
621
|
+
--env-file <path>
|
|
622
|
+
--config-dir <path>
|
|
623
|
+
--disable-web-push
|
|
624
|
+
--enable-web-push
|
|
625
|
+
--allow-insecure-http-lan
|
|
626
|
+
--install-mkcert
|
|
627
|
+
--mkcert-trust-stores <system[,java][,nss]>
|
|
628
|
+
--tls-cert-file <path>
|
|
629
|
+
--tls-key-file <path>
|
|
630
|
+
--web-push-subject <mailto:...>
|
|
631
|
+
--locale <en|ja>
|
|
632
|
+
--vapid-public-key <key>
|
|
633
|
+
--vapid-private-key <key>
|
|
634
|
+
--enable-ntfy
|
|
635
|
+
--no-launchd
|
|
636
|
+
--pair
|
|
637
|
+
`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async function resolveCliLocale(cliOptions, existingConfig = null) {
|
|
641
|
+
const explicit = normalizeLocale(cliOptions?.locale || "");
|
|
642
|
+
if (explicit) {
|
|
643
|
+
return explicit;
|
|
644
|
+
}
|
|
645
|
+
const persisted = normalizeLocale(existingConfig?.DEFAULT_LOCALE || "");
|
|
646
|
+
if (persisted) {
|
|
647
|
+
return persisted;
|
|
648
|
+
}
|
|
649
|
+
const detected = await detectSystemLocale();
|
|
650
|
+
return detected || DEFAULT_LOCALE;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function resolveSetupLocale(cliOptions, existingConfig = null) {
|
|
654
|
+
const explicit = normalizeLocale(cliOptions?.locale || "");
|
|
655
|
+
if (explicit) {
|
|
656
|
+
return explicit;
|
|
657
|
+
}
|
|
658
|
+
const persisted = normalizeLocale(existingConfig?.DEFAULT_LOCALE || "");
|
|
659
|
+
if (persisted) {
|
|
660
|
+
return persisted;
|
|
661
|
+
}
|
|
662
|
+
return (await detectSystemLocale()) || DEFAULT_LOCALE;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function detectSystemLocale() {
|
|
666
|
+
const detected = await detectMacSystemLocale();
|
|
667
|
+
if (detected) {
|
|
668
|
+
return detected;
|
|
669
|
+
}
|
|
670
|
+
return normalizeLocale(Intl.DateTimeFormat().resolvedOptions().locale || process.env.LANG || "");
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function detectMacSystemLocale() {
|
|
674
|
+
if (process.platform !== "darwin") {
|
|
675
|
+
return "";
|
|
676
|
+
}
|
|
677
|
+
const result = await execCommand(["defaults", "read", "-g", "AppleLanguages"], { ignoreError: true });
|
|
678
|
+
if (!result.ok) {
|
|
679
|
+
return "";
|
|
680
|
+
}
|
|
681
|
+
const normalized = normalizeLocale(extractFirstLocale(result.stdout));
|
|
682
|
+
return normalized || "";
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function extractFirstLocale(rawValue) {
|
|
686
|
+
const text = String(rawValue || "");
|
|
687
|
+
const quoted = text.match(/"([A-Za-z_-]+)"/u);
|
|
688
|
+
if (quoted?.[1]) {
|
|
689
|
+
return quoted[1];
|
|
690
|
+
}
|
|
691
|
+
const bare = text.match(/\b([A-Za-z]{2}(?:[-_][A-Za-z]{2})?)\b/u);
|
|
692
|
+
return bare?.[1] || "";
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function buildLaunchAgentPlist({ label, nodePath, bridgeScript, envFile, logFile }) {
|
|
696
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
697
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
698
|
+
<plist version="1.0">
|
|
699
|
+
<dict>
|
|
700
|
+
<key>Label</key>
|
|
701
|
+
<string>${escapeXml(label)}</string>
|
|
702
|
+
<key>ProgramArguments</key>
|
|
703
|
+
<array>
|
|
704
|
+
<string>${escapeXml(nodePath)}</string>
|
|
705
|
+
<string>${escapeXml(bridgeScript)}</string>
|
|
706
|
+
<string>--env-file</string>
|
|
707
|
+
<string>${escapeXml(envFile)}</string>
|
|
708
|
+
</array>
|
|
709
|
+
<key>RunAtLoad</key>
|
|
710
|
+
<true/>
|
|
711
|
+
<key>KeepAlive</key>
|
|
712
|
+
<true/>
|
|
713
|
+
<key>StandardOutPath</key>
|
|
714
|
+
<string>${escapeXml(logFile)}</string>
|
|
715
|
+
<key>StandardErrorPath</key>
|
|
716
|
+
<string>${escapeXml(logFile)}</string>
|
|
717
|
+
</dict>
|
|
718
|
+
</plist>
|
|
719
|
+
`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function startDetachedBridge({ envFile, logFile, pidFile }) {
|
|
723
|
+
await fs.mkdir(path.dirname(logFile), { recursive: true });
|
|
724
|
+
const logHandle = await fs.open(logFile, "a");
|
|
725
|
+
const child = spawn(process.execPath, [bridgeScript, "--env-file", envFile], {
|
|
726
|
+
detached: true,
|
|
727
|
+
stdio: ["ignore", logHandle.fd, logHandle.fd],
|
|
728
|
+
});
|
|
729
|
+
child.unref();
|
|
730
|
+
await logHandle.close();
|
|
731
|
+
await fs.writeFile(pidFile, `${child.pid}\n`, "utf8");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function maybeReadPid(pidFile) {
|
|
735
|
+
try {
|
|
736
|
+
const raw = await fs.readFile(pidFile, "utf8");
|
|
737
|
+
const pid = Number(raw.trim());
|
|
738
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
739
|
+
} catch {
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function maybeReadEnvFile(filePath) {
|
|
745
|
+
const output = {};
|
|
746
|
+
try {
|
|
747
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
748
|
+
for (const rawLine of raw.split(/\r?\n/u)) {
|
|
749
|
+
const line = rawLine.trim();
|
|
750
|
+
if (!line || line.startsWith("#")) {
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
const separator = line.indexOf("=");
|
|
754
|
+
if (separator === -1) {
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
output[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
|
|
758
|
+
}
|
|
759
|
+
} catch {
|
|
760
|
+
return output;
|
|
761
|
+
}
|
|
762
|
+
return output;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function ensureDefaultLocalePersisted(envFile, cliOptions = {}, existingConfig = null) {
|
|
766
|
+
const config = existingConfig || (await maybeReadEnvFile(envFile));
|
|
767
|
+
if (normalizeLocale(config.DEFAULT_LOCALE)) {
|
|
768
|
+
return config;
|
|
769
|
+
}
|
|
770
|
+
if (!(await fileExists(envFile))) {
|
|
771
|
+
return config;
|
|
772
|
+
}
|
|
773
|
+
const locale = await resolveCliLocale(cliOptions, config);
|
|
774
|
+
await fs.appendFile(envFile, `DEFAULT_LOCALE=${locale}\n`, "utf8");
|
|
775
|
+
return {
|
|
776
|
+
...config,
|
|
777
|
+
DEFAULT_LOCALE: locale,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function maybeRotateStartupPairing(envFile, config = {}) {
|
|
782
|
+
const now = Date.now();
|
|
783
|
+
const rotated = shouldRotatePairing({
|
|
784
|
+
pairingCode: config.PAIRING_CODE,
|
|
785
|
+
pairingToken: config.PAIRING_TOKEN,
|
|
786
|
+
pairingExpiresAtMs: config.PAIRING_EXPIRES_AT_MS,
|
|
787
|
+
}, now);
|
|
788
|
+
|
|
789
|
+
if (!rotated) {
|
|
790
|
+
return { rotated: false };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const nextPairing = generatePairingCredentials(now);
|
|
794
|
+
const currentText = (await fileExists(envFile)) ? await fs.readFile(envFile, "utf8") : "";
|
|
795
|
+
const nextText = upsertEnvText(currentText, {
|
|
796
|
+
PAIRING_CODE: nextPairing.pairingCode,
|
|
797
|
+
PAIRING_TOKEN: nextPairing.pairingToken,
|
|
798
|
+
PAIRING_EXPIRES_AT_MS: String(nextPairing.pairingExpiresAtMs),
|
|
799
|
+
});
|
|
800
|
+
await fs.mkdir(path.dirname(envFile), { recursive: true });
|
|
801
|
+
await fs.writeFile(envFile, nextText, "utf8");
|
|
802
|
+
|
|
803
|
+
return {
|
|
804
|
+
rotated: true,
|
|
805
|
+
...nextPairing,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function findLocalIpv4Addresses() {
|
|
810
|
+
const interfaces = os.networkInterfaces();
|
|
811
|
+
const result = [];
|
|
812
|
+
for (const entries of Object.values(interfaces)) {
|
|
813
|
+
for (const entry of entries || []) {
|
|
814
|
+
if (entry?.family === "IPv4" && !entry.internal && entry.address) {
|
|
815
|
+
result.push(entry.address);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return Array.from(new Set(result));
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function execCommand(args, { ignoreError = false, env = null, streamOutput = false, beforeStreamOutput = null } = {}) {
|
|
823
|
+
return new Promise((resolve, reject) => {
|
|
824
|
+
let beforeStreamOutputCalled = false;
|
|
825
|
+
const maybeBeforeStreamOutput = () => {
|
|
826
|
+
if (!streamOutput || beforeStreamOutputCalled) {
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
beforeStreamOutputCalled = true;
|
|
830
|
+
beforeStreamOutput?.();
|
|
831
|
+
};
|
|
832
|
+
const child = spawn(args[0], args.slice(1), {
|
|
833
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
834
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
835
|
+
});
|
|
836
|
+
let stdout = "";
|
|
837
|
+
let stderr = "";
|
|
838
|
+
child.stdout.on("data", (chunk) => {
|
|
839
|
+
stdout += chunk.toString("utf8");
|
|
840
|
+
if (streamOutput) {
|
|
841
|
+
maybeBeforeStreamOutput();
|
|
842
|
+
process.stdout.write(chunk);
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
child.stderr.on("data", (chunk) => {
|
|
846
|
+
stderr += chunk.toString("utf8");
|
|
847
|
+
if (streamOutput) {
|
|
848
|
+
maybeBeforeStreamOutput();
|
|
849
|
+
process.stderr.write(chunk);
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
child.on("error", (error) => {
|
|
853
|
+
if (ignoreError) {
|
|
854
|
+
resolve({ ok: false, stdout, stderr: `${stderr}${error.message}` });
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
reject(error);
|
|
858
|
+
});
|
|
859
|
+
child.on("close", (code) => {
|
|
860
|
+
if (code === 0 || ignoreError) {
|
|
861
|
+
resolve({ ok: code === 0, stdout, stderr });
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
reject(new Error(stderr.trim() || `${args[0]} exited with code ${code}`));
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function resolveMkcertTrustStores(cliOptions = {}) {
|
|
870
|
+
return String(cliOptions.mkcertTrustStores || process.env.MKCERT_TRUST_STORES || "system").trim() || "system";
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function resolveSetupWebPushEnabled(cliOptions = {}) {
|
|
874
|
+
if (cliOptions.disableWebPush) {
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function logSetupProgress(locale, key, vars = {}) {
|
|
881
|
+
console.log(`• ${t(locale, key, vars)}`);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function createCliProgressReporter(initialLocale) {
|
|
885
|
+
let locale = initialLocale;
|
|
886
|
+
let lastWidth = 0;
|
|
887
|
+
let active = false;
|
|
888
|
+
let currentText = "";
|
|
889
|
+
let spinnerIndex = 0;
|
|
890
|
+
let spinnerTimer = null;
|
|
891
|
+
const interactive = Boolean(process.stdout.isTTY);
|
|
892
|
+
const spinnerFrames = ["|", "/", "-", "\\"];
|
|
893
|
+
|
|
894
|
+
const stopSpinner = () => {
|
|
895
|
+
if (spinnerTimer) {
|
|
896
|
+
clearInterval(spinnerTimer);
|
|
897
|
+
spinnerTimer = null;
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
const render = (prefix, text, newline = false) => {
|
|
902
|
+
const padded = `${prefix} ${text}`.padEnd(lastWidth);
|
|
903
|
+
process.stdout.write(`\r${padded}${newline ? "\n" : ""}`);
|
|
904
|
+
lastWidth = newline ? 0 : Math.max(lastWidth, `${prefix} ${text}`.length);
|
|
905
|
+
active = !newline;
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
const ensureSpinner = () => {
|
|
909
|
+
if (!interactive || spinnerTimer || !currentText) {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
spinnerTimer = setInterval(() => {
|
|
913
|
+
spinnerIndex = (spinnerIndex + 1) % spinnerFrames.length;
|
|
914
|
+
render(spinnerFrames[spinnerIndex], currentText, false);
|
|
915
|
+
}, 120);
|
|
916
|
+
spinnerTimer.unref?.();
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
const writeLine = (prefix, key, vars = {}, newline = false) => {
|
|
920
|
+
const text = `${prefix} ${t(locale, key, vars)}`;
|
|
921
|
+
if (!interactive) {
|
|
922
|
+
console.log(text);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
stopSpinner();
|
|
926
|
+
currentText = t(locale, key, vars);
|
|
927
|
+
render(prefix, currentText, newline);
|
|
928
|
+
if (!newline && prefix !== "✓") {
|
|
929
|
+
ensureSpinner();
|
|
930
|
+
} else if (newline) {
|
|
931
|
+
currentText = "";
|
|
932
|
+
spinnerIndex = 0;
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
return {
|
|
937
|
+
setLocale(nextLocale) {
|
|
938
|
+
locale = nextLocale || locale;
|
|
939
|
+
},
|
|
940
|
+
update(key, vars = {}) {
|
|
941
|
+
writeLine("•", key, vars, false);
|
|
942
|
+
},
|
|
943
|
+
done(key, vars = {}) {
|
|
944
|
+
writeLine("✓", key, vars, true);
|
|
945
|
+
},
|
|
946
|
+
clear() {
|
|
947
|
+
stopSpinner();
|
|
948
|
+
if (!interactive || !active || lastWidth === 0) {
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
process.stdout.write(`\r${" ".repeat(lastWidth)}\r`);
|
|
952
|
+
lastWidth = 0;
|
|
953
|
+
active = false;
|
|
954
|
+
currentText = "";
|
|
955
|
+
spinnerIndex = 0;
|
|
956
|
+
},
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function buildHealthCheckArgs(url) {
|
|
961
|
+
const args = ["curl", "-sS", "--fail-with-body", "--connect-timeout", "3", "--max-time", "5"];
|
|
962
|
+
if (isHttpsUrl(url)) {
|
|
963
|
+
args.push("-k");
|
|
964
|
+
}
|
|
965
|
+
args.push(url);
|
|
966
|
+
return args;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function buildLoopbackHealthUrl(baseUrl) {
|
|
970
|
+
if (!baseUrl) {
|
|
971
|
+
return "";
|
|
972
|
+
}
|
|
973
|
+
try {
|
|
974
|
+
const url = new URL(baseUrl);
|
|
975
|
+
url.hostname = "127.0.0.1";
|
|
976
|
+
url.pathname = "/health";
|
|
977
|
+
url.search = "";
|
|
978
|
+
url.hash = "";
|
|
979
|
+
return url.toString();
|
|
980
|
+
} catch {
|
|
981
|
+
return "";
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function sleep(ms) {
|
|
986
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async function printQrCode(url) {
|
|
990
|
+
try {
|
|
991
|
+
const module = await import("qrcode-terminal");
|
|
992
|
+
const qrcode = module.default || module;
|
|
993
|
+
console.log("");
|
|
994
|
+
qrcode.generate(url, { small: true });
|
|
995
|
+
} catch {
|
|
996
|
+
console.log("");
|
|
997
|
+
console.log("QR generation requires the optional qrcode-terminal dependency.");
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
async function waitForEnter(locale, key) {
|
|
1002
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const rl = createReadlineInterface({
|
|
1006
|
+
input: process.stdin,
|
|
1007
|
+
output: process.stdout,
|
|
1008
|
+
});
|
|
1009
|
+
try {
|
|
1010
|
+
await rl.question(`\n${t(locale, key)} `);
|
|
1011
|
+
} finally {
|
|
1012
|
+
rl.close();
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
async function startTemporaryCaDownloadServer({
|
|
1017
|
+
rootCaFile,
|
|
1018
|
+
preferredPort,
|
|
1019
|
+
localHostname,
|
|
1020
|
+
fallbackIp,
|
|
1021
|
+
pathName = "/ca/rootCA.pem",
|
|
1022
|
+
}) {
|
|
1023
|
+
const server = createHttpServer(async (req, res) => {
|
|
1024
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
1025
|
+
if (url.pathname === pathName || url.pathname === "/downloads/rootCA.pem") {
|
|
1026
|
+
try {
|
|
1027
|
+
const body = await fs.readFile(rootCaFile, "utf8");
|
|
1028
|
+
res.statusCode = 200;
|
|
1029
|
+
res.setHeader("Content-Type", "application/x-pem-file");
|
|
1030
|
+
res.setHeader("Content-Disposition", 'attachment; filename="rootCA.pem"');
|
|
1031
|
+
res.end(body);
|
|
1032
|
+
return;
|
|
1033
|
+
} catch {
|
|
1034
|
+
res.statusCode = 404;
|
|
1035
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
1036
|
+
res.end("rootCA.pem not found");
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
if (url.pathname === "/health") {
|
|
1041
|
+
res.statusCode = 200;
|
|
1042
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
1043
|
+
res.end('{"ok":true}');
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
res.statusCode = 404;
|
|
1047
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
1048
|
+
res.end("Not found");
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
const actualPort = await listenTemporaryServer(server, preferredPort, "0.0.0.0");
|
|
1052
|
+
const localUrl = `http://${localHostname}:${actualPort}${pathName}`;
|
|
1053
|
+
const ipUrl = `http://${fallbackIp}:${actualPort}${pathName}`;
|
|
1054
|
+
return {
|
|
1055
|
+
port: actualPort,
|
|
1056
|
+
localUrl,
|
|
1057
|
+
ipUrl,
|
|
1058
|
+
async close() {
|
|
1059
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
1060
|
+
},
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async function listenTemporaryServer(server, preferredPort, host) {
|
|
1065
|
+
try {
|
|
1066
|
+
return await listenServerOnce(server, preferredPort, host);
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
if (error?.code === "EADDRINUSE" && preferredPort !== 0) {
|
|
1069
|
+
return await listenServerOnce(server, 0, host);
|
|
1070
|
+
}
|
|
1071
|
+
throw error;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function listenServerOnce(server, port, host) {
|
|
1076
|
+
return await new Promise((resolve, reject) => {
|
|
1077
|
+
const onError = (error) => {
|
|
1078
|
+
server.off("listening", onListening);
|
|
1079
|
+
reject(error);
|
|
1080
|
+
};
|
|
1081
|
+
const onListening = () => {
|
|
1082
|
+
server.off("error", onError);
|
|
1083
|
+
const address = server.address();
|
|
1084
|
+
resolve(Number(address?.port) || port);
|
|
1085
|
+
};
|
|
1086
|
+
server.once("error", onError);
|
|
1087
|
+
server.once("listening", onListening);
|
|
1088
|
+
server.listen(port, host);
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
async function waitForHealth(url, { attempts = 8, intervalMs = 500 } = {}) {
|
|
1093
|
+
if (!url) {
|
|
1094
|
+
return false;
|
|
1095
|
+
}
|
|
1096
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1097
|
+
const result = await execCommand(buildHealthCheckArgs(url), { ignoreError: true });
|
|
1098
|
+
if (result.ok) {
|
|
1099
|
+
return true;
|
|
1100
|
+
}
|
|
1101
|
+
if (attempt < attempts - 1) {
|
|
1102
|
+
await sleep(intervalMs);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function truthyString(value) {
|
|
1109
|
+
return /^(1|true|yes|on)$/iu.test(String(value || "").trim());
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function isHttpsUrl(value) {
|
|
1113
|
+
return String(value || "").trim().toLowerCase().startsWith("https://");
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function isLoopbackBaseUrl(value) {
|
|
1117
|
+
try {
|
|
1118
|
+
const url = new URL(String(value || "").trim());
|
|
1119
|
+
const hostname = url.hostname.toLowerCase().replace(/^\[/u, "").replace(/\]$/u, "");
|
|
1120
|
+
return hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost";
|
|
1121
|
+
} catch {
|
|
1122
|
+
return false;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function collectTlsHosts({ hostname, localHostname, chosenIp }) {
|
|
1127
|
+
return Array.from(
|
|
1128
|
+
new Set(
|
|
1129
|
+
["localhost", "127.0.0.1", hostname, localHostname, chosenIp]
|
|
1130
|
+
.map((value) => String(value || "").trim())
|
|
1131
|
+
.filter(Boolean)
|
|
1132
|
+
)
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
async function ensureWebPushAssets({
|
|
1137
|
+
cliOptions,
|
|
1138
|
+
existing,
|
|
1139
|
+
hostname,
|
|
1140
|
+
localHostname,
|
|
1141
|
+
locale,
|
|
1142
|
+
progress,
|
|
1143
|
+
chosenIp,
|
|
1144
|
+
tlsCertFile,
|
|
1145
|
+
tlsKeyFile,
|
|
1146
|
+
}) {
|
|
1147
|
+
const mkcertTrustStores = resolveMkcertTrustStores(cliOptions);
|
|
1148
|
+
const manualCertOverride = Boolean(cliOptions.tlsCertFile || cliOptions.tlsKeyFile);
|
|
1149
|
+
const certExists = await fileExists(tlsCertFile);
|
|
1150
|
+
const keyExists = await fileExists(tlsKeyFile);
|
|
1151
|
+
if (certExists !== keyExists) {
|
|
1152
|
+
throw new Error("TLS_CERT_FILE and TLS_KEY_FILE must both exist.");
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
if (!certExists) {
|
|
1156
|
+
if (manualCertOverride) {
|
|
1157
|
+
throw new Error("The provided TLS certificate or key file does not exist.");
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
let mkcertPath = await findExecutable("mkcert");
|
|
1161
|
+
if (!mkcertPath && cliOptions.installMkcert) {
|
|
1162
|
+
mkcertPath = await installMkcertForMac(progress, locale);
|
|
1163
|
+
}
|
|
1164
|
+
if (!mkcertPath) {
|
|
1165
|
+
throw new Error(
|
|
1166
|
+
[
|
|
1167
|
+
"Web Push requires HTTPS, but mkcert is not installed.",
|
|
1168
|
+
"Install mkcert and trust its local CA, or provide --tls-cert-file and --tls-key-file.",
|
|
1169
|
+
"You can also run: npx viveworker setup --install-mkcert",
|
|
1170
|
+
"Example: brew install mkcert && mkcert -install",
|
|
1171
|
+
].join("\n")
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
progress?.update("cli.setup.progress.installCa", { stores: mkcertTrustStores });
|
|
1176
|
+
await execCommand([mkcertPath, "-install"], {
|
|
1177
|
+
env: {
|
|
1178
|
+
TRUST_STORES: mkcertTrustStores,
|
|
1179
|
+
},
|
|
1180
|
+
streamOutput: true,
|
|
1181
|
+
beforeStreamOutput: () => progress?.clear(),
|
|
1182
|
+
});
|
|
1183
|
+
progress?.update("cli.setup.progress.generateCert");
|
|
1184
|
+
await fs.mkdir(path.dirname(tlsCertFile), { recursive: true });
|
|
1185
|
+
await execCommand([
|
|
1186
|
+
mkcertPath,
|
|
1187
|
+
"-cert-file",
|
|
1188
|
+
tlsCertFile,
|
|
1189
|
+
"-key-file",
|
|
1190
|
+
tlsKeyFile,
|
|
1191
|
+
...collectTlsHosts({ hostname, localHostname, chosenIp }),
|
|
1192
|
+
], {
|
|
1193
|
+
streamOutput: true,
|
|
1194
|
+
beforeStreamOutput: () => progress?.clear(),
|
|
1195
|
+
});
|
|
1196
|
+
} else {
|
|
1197
|
+
const mkcertPath = await findExecutable("mkcert");
|
|
1198
|
+
if (mkcertPath && cliOptions.installMkcert) {
|
|
1199
|
+
progress?.update("cli.setup.progress.installCa", { stores: mkcertTrustStores });
|
|
1200
|
+
await execCommand([mkcertPath, "-install"], {
|
|
1201
|
+
env: {
|
|
1202
|
+
TRUST_STORES: mkcertTrustStores,
|
|
1203
|
+
},
|
|
1204
|
+
streamOutput: true,
|
|
1205
|
+
beforeStreamOutput: () => progress?.clear(),
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const vapidPublicKey =
|
|
1211
|
+
cliOptions.vapidPublicKey ||
|
|
1212
|
+
existing.WEB_PUSH_VAPID_PUBLIC_KEY ||
|
|
1213
|
+
"";
|
|
1214
|
+
const vapidPrivateKey =
|
|
1215
|
+
cliOptions.vapidPrivateKey ||
|
|
1216
|
+
existing.WEB_PUSH_VAPID_PRIVATE_KEY ||
|
|
1217
|
+
"";
|
|
1218
|
+
if (vapidPublicKey && vapidPrivateKey) {
|
|
1219
|
+
return {
|
|
1220
|
+
certFile: tlsCertFile,
|
|
1221
|
+
keyFile: tlsKeyFile,
|
|
1222
|
+
vapidPublicKey,
|
|
1223
|
+
vapidPrivateKey,
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
progress?.update("cli.setup.progress.generateVapid");
|
|
1228
|
+
const generated = await generateVapidKeys();
|
|
1229
|
+
return {
|
|
1230
|
+
certFile: tlsCertFile,
|
|
1231
|
+
keyFile: tlsKeyFile,
|
|
1232
|
+
vapidPublicKey: generated.publicKey,
|
|
1233
|
+
vapidPrivateKey: generated.privateKey,
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
async function generateVapidKeys() {
|
|
1238
|
+
const module = await import("web-push");
|
|
1239
|
+
const webPush = module.default || module;
|
|
1240
|
+
return webPush.generateVAPIDKeys();
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
async function findExecutable(name) {
|
|
1244
|
+
const result = await execCommand(["which", name], { ignoreError: true });
|
|
1245
|
+
if (!result.ok) {
|
|
1246
|
+
return "";
|
|
1247
|
+
}
|
|
1248
|
+
return result.stdout.trim();
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
async function installMkcertForMac(progress, locale) {
|
|
1252
|
+
const brewPath = await findExecutable("brew");
|
|
1253
|
+
if (!brewPath) {
|
|
1254
|
+
throw new Error(
|
|
1255
|
+
[
|
|
1256
|
+
"mkcert is not installed and Homebrew was not found.",
|
|
1257
|
+
"Install Homebrew first, or install mkcert manually, then rerun setup.",
|
|
1258
|
+
].join("\n")
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
progress?.update("cli.setup.progress.installMkcert");
|
|
1263
|
+
await execCommand([brewPath, "install", "mkcert"], {
|
|
1264
|
+
streamOutput: true,
|
|
1265
|
+
beforeStreamOutput: () => progress?.clear(),
|
|
1266
|
+
});
|
|
1267
|
+
const mkcertPath = await findExecutable("mkcert");
|
|
1268
|
+
if (!mkcertPath) {
|
|
1269
|
+
throw new Error("mkcert installation finished, but the mkcert executable is still not available.");
|
|
1270
|
+
}
|
|
1271
|
+
return mkcertPath;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
async function checkCertificateHosts({ certFile, expectedHosts }) {
|
|
1275
|
+
try {
|
|
1276
|
+
const raw = await fs.readFile(certFile, "utf8");
|
|
1277
|
+
const certificate = new crypto.X509Certificate(raw);
|
|
1278
|
+
const subjectAltName = String(certificate.subjectAltName || "");
|
|
1279
|
+
const available = new Set(
|
|
1280
|
+
subjectAltName
|
|
1281
|
+
.split(",")
|
|
1282
|
+
.map((part) => part.trim())
|
|
1283
|
+
.map((part) => part.replace(/^DNS:/u, "").replace(/^IP Address:/u, "").trim())
|
|
1284
|
+
.filter(Boolean)
|
|
1285
|
+
);
|
|
1286
|
+
return expectedHosts
|
|
1287
|
+
.filter((host) => !available.has(host))
|
|
1288
|
+
.map((host) => `TLS certificate is missing SAN entry for ${host}`);
|
|
1289
|
+
} catch (error) {
|
|
1290
|
+
return [`Unable to inspect TLS certificate: ${error.message || String(error)}`];
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function resolvePath(targetPath) {
|
|
1295
|
+
if (!targetPath) {
|
|
1296
|
+
return targetPath;
|
|
1297
|
+
}
|
|
1298
|
+
if (targetPath === "~") {
|
|
1299
|
+
return os.homedir();
|
|
1300
|
+
}
|
|
1301
|
+
if (targetPath.startsWith("~/")) {
|
|
1302
|
+
return path.join(os.homedir(), targetPath.slice(2));
|
|
1303
|
+
}
|
|
1304
|
+
if (path.isAbsolute(targetPath)) {
|
|
1305
|
+
return targetPath;
|
|
1306
|
+
}
|
|
1307
|
+
return path.resolve(process.cwd(), targetPath);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
async function fileExists(filePath) {
|
|
1311
|
+
try {
|
|
1312
|
+
await fs.access(filePath);
|
|
1313
|
+
return true;
|
|
1314
|
+
} catch {
|
|
1315
|
+
return false;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function escapeXml(value) {
|
|
1320
|
+
return String(value)
|
|
1321
|
+
.replace(/&/gu, "&")
|
|
1322
|
+
.replace(/</gu, "<")
|
|
1323
|
+
.replace(/>/gu, ">")
|
|
1324
|
+
.replace(/"/gu, """);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
async function printPairingInfo(locale, config) {
|
|
1328
|
+
const baseUrl = String(config.NATIVE_APPROVAL_SERVER_PUBLIC_BASE_URL || "").trim();
|
|
1329
|
+
const pairCode = String(config.PAIRING_CODE || "").trim();
|
|
1330
|
+
const pairToken = String(config.PAIRING_TOKEN || "").trim();
|
|
1331
|
+
if (!baseUrl || !pairCode || !pairToken) {
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const pairPath = `/app?pairToken=${encodeURIComponent(pairToken)}`;
|
|
1336
|
+
const ips = await findLocalIpv4Addresses();
|
|
1337
|
+
const fallbackBaseUrl = buildFallbackBaseUrl(baseUrl, ips[0] || "127.0.0.1");
|
|
1338
|
+
|
|
1339
|
+
console.log("");
|
|
1340
|
+
console.log(t(locale, "cli.setup.pairingCode", { code: pairCode }));
|
|
1341
|
+
console.log(t(locale, "cli.setup.pairingUrlLocal", { url: `${baseUrl}${pairPath}` }));
|
|
1342
|
+
console.log(t(locale, "cli.setup.pairingUrlIp", { url: `${fallbackBaseUrl}${pairPath}` }));
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function buildFallbackBaseUrl(baseUrl, ipAddress) {
|
|
1346
|
+
try {
|
|
1347
|
+
const url = new URL(baseUrl);
|
|
1348
|
+
url.hostname = ipAddress;
|
|
1349
|
+
return url.toString().replace(/\/$/u, "");
|
|
1350
|
+
} catch {
|
|
1351
|
+
return baseUrl;
|
|
1352
|
+
}
|
|
1353
|
+
}
|