@fyresmith/hive-server 1.0.1-3
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 +143 -0
- package/bin/hive.js +4 -0
- package/cli/checks.js +28 -0
- package/cli/config.js +33 -0
- package/cli/constants.js +45 -0
- package/cli/env-file.js +141 -0
- package/cli/errors.js +11 -0
- package/cli/exec.js +12 -0
- package/cli/main.js +730 -0
- package/cli/output.js +21 -0
- package/cli/service.js +360 -0
- package/cli/tunnel.js +238 -0
- package/index.js +129 -0
- package/lib/auth.js +50 -0
- package/lib/socketHandler.js +226 -0
- package/lib/vaultManager.js +258 -0
- package/lib/yjsServer.js +277 -0
- package/package.json +52 -0
- package/routes/auth.js +99 -0
package/cli/output.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
export function section(title) {
|
|
4
|
+
console.log(`\n${chalk.bold.cyan(title)}`);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function info(msg) {
|
|
8
|
+
console.log(chalk.cyan(`→ ${msg}`));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function success(msg) {
|
|
12
|
+
console.log(chalk.green(`✓ ${msg}`));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function warn(msg) {
|
|
16
|
+
console.log(chalk.yellow(`! ${msg}`));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function fail(msg) {
|
|
20
|
+
console.error(chalk.red(`✗ ${msg}`));
|
|
21
|
+
}
|
package/cli/service.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { access, mkdir, readFile, rm, writeFile } from 'fs/promises';
|
|
3
|
+
import { constants as fsConstants } from 'fs';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { homedir } from 'os';
|
|
7
|
+
import prompts from 'prompts';
|
|
8
|
+
import { CliError } from './errors.js';
|
|
9
|
+
import { EXIT, HIVE_HOME } from './constants.js';
|
|
10
|
+
import { run, runInherit } from './exec.js';
|
|
11
|
+
|
|
12
|
+
const MAC_SERVICE_NAME = 'com.hive.server';
|
|
13
|
+
const LINUX_SERVICE_NAME = 'hive-server';
|
|
14
|
+
|
|
15
|
+
function detectPlatform() {
|
|
16
|
+
if (process.platform === 'darwin') return 'launchd';
|
|
17
|
+
if (process.platform === 'linux') return 'systemd';
|
|
18
|
+
throw new CliError(`Unsupported OS for service management: ${process.platform}`, EXIT.PREREQ);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getUid() {
|
|
22
|
+
return process.env.UID || String(process.getuid?.() ?? '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getServiceDefaults() {
|
|
26
|
+
const servicePlatform = detectPlatform();
|
|
27
|
+
const serviceName = servicePlatform === 'launchd' ? MAC_SERVICE_NAME : LINUX_SERVICE_NAME;
|
|
28
|
+
return { servicePlatform, serviceName };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getHiveBinPath() {
|
|
32
|
+
return fileURLToPath(new URL('../bin/hive.js', import.meta.url));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getLaunchAgentPath(serviceName) {
|
|
36
|
+
return join(homedir(), 'Library', 'LaunchAgents', `${serviceName}.plist`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getLaunchdTarget(serviceName) {
|
|
40
|
+
const uid = getUid();
|
|
41
|
+
if (!uid) {
|
|
42
|
+
throw new CliError('Could not resolve UID for launchctl', EXIT.FAIL);
|
|
43
|
+
}
|
|
44
|
+
return `gui/${uid}/${serviceName}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getLaunchdLogsDir() {
|
|
48
|
+
return join(HIVE_HOME, 'logs');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function xmlEscape(input) {
|
|
52
|
+
return String(input)
|
|
53
|
+
.replaceAll('&', '&')
|
|
54
|
+
.replaceAll('<', '<')
|
|
55
|
+
.replaceAll('>', '>')
|
|
56
|
+
.replaceAll('"', '"')
|
|
57
|
+
.replaceAll("'", ''');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildLaunchdPlist({
|
|
61
|
+
serviceName,
|
|
62
|
+
nodePath,
|
|
63
|
+
hiveBinPath,
|
|
64
|
+
envFile,
|
|
65
|
+
stdoutPath,
|
|
66
|
+
stderrPath,
|
|
67
|
+
}) {
|
|
68
|
+
const args = [nodePath, hiveBinPath, 'run', '--env-file', envFile, '--quiet']
|
|
69
|
+
.map((arg) => ` <string>${xmlEscape(arg)}</string>`)
|
|
70
|
+
.join('\n');
|
|
71
|
+
|
|
72
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
73
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
74
|
+
<plist version="1.0">
|
|
75
|
+
<dict>
|
|
76
|
+
<key>Label</key>
|
|
77
|
+
<string>${xmlEscape(serviceName)}</string>
|
|
78
|
+
<key>ProgramArguments</key>
|
|
79
|
+
<array>
|
|
80
|
+
${args}
|
|
81
|
+
</array>
|
|
82
|
+
<key>RunAtLoad</key>
|
|
83
|
+
<true/>
|
|
84
|
+
<key>KeepAlive</key>
|
|
85
|
+
<true/>
|
|
86
|
+
<key>WorkingDirectory</key>
|
|
87
|
+
<string>${xmlEscape(HIVE_HOME)}</string>
|
|
88
|
+
<key>StandardOutPath</key>
|
|
89
|
+
<string>${xmlEscape(stdoutPath)}</string>
|
|
90
|
+
<key>StandardErrorPath</key>
|
|
91
|
+
<string>${xmlEscape(stderrPath)}</string>
|
|
92
|
+
</dict>
|
|
93
|
+
</plist>
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildSystemdUnit({
|
|
98
|
+
serviceName,
|
|
99
|
+
nodePath,
|
|
100
|
+
hiveBinPath,
|
|
101
|
+
envFile,
|
|
102
|
+
user,
|
|
103
|
+
}) {
|
|
104
|
+
const execStart = `${nodePath} ${hiveBinPath} run --env-file ${envFile} --quiet`;
|
|
105
|
+
return `[Unit]
|
|
106
|
+
Description=Hive Collaborative Vault Server
|
|
107
|
+
After=network.target
|
|
108
|
+
|
|
109
|
+
[Service]
|
|
110
|
+
Type=simple
|
|
111
|
+
User=${user}
|
|
112
|
+
WorkingDirectory=${HIVE_HOME}
|
|
113
|
+
ExecStart=${execStart}
|
|
114
|
+
Restart=on-failure
|
|
115
|
+
RestartSec=5s
|
|
116
|
+
NoNewPrivileges=true
|
|
117
|
+
PrivateTmp=true
|
|
118
|
+
StandardOutput=journal
|
|
119
|
+
StandardError=journal
|
|
120
|
+
SyslogIdentifier=${serviceName}
|
|
121
|
+
|
|
122
|
+
[Install]
|
|
123
|
+
WantedBy=multi-user.target
|
|
124
|
+
`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function installLaunchdService({ serviceName, envFile }) {
|
|
128
|
+
const plistPath = getLaunchAgentPath(serviceName);
|
|
129
|
+
const target = getLaunchdTarget(serviceName);
|
|
130
|
+
const logsDir = getLaunchdLogsDir();
|
|
131
|
+
const stdoutPath = join(logsDir, 'hive-server.out.log');
|
|
132
|
+
const stderrPath = join(logsDir, 'hive-server.err.log');
|
|
133
|
+
|
|
134
|
+
await mkdir(dirname(plistPath), { recursive: true });
|
|
135
|
+
await mkdir(logsDir, { recursive: true });
|
|
136
|
+
await mkdir(HIVE_HOME, { recursive: true });
|
|
137
|
+
|
|
138
|
+
const plist = buildLaunchdPlist({
|
|
139
|
+
serviceName,
|
|
140
|
+
nodePath: process.execPath,
|
|
141
|
+
hiveBinPath: getHiveBinPath(),
|
|
142
|
+
envFile,
|
|
143
|
+
stdoutPath,
|
|
144
|
+
stderrPath,
|
|
145
|
+
});
|
|
146
|
+
await writeFile(plistPath, plist, 'utf-8');
|
|
147
|
+
|
|
148
|
+
await run('launchctl', ['bootout', target]).catch(() => {});
|
|
149
|
+
await runInherit('launchctl', ['bootstrap', `gui/${getUid()}`, plistPath]);
|
|
150
|
+
await runInherit('launchctl', ['enable', target]);
|
|
151
|
+
await runInherit('launchctl', ['kickstart', '-k', target]);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
servicePlatform: 'launchd',
|
|
155
|
+
serviceName,
|
|
156
|
+
serviceFile: plistPath,
|
|
157
|
+
logs: { stdoutPath, stderrPath },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function installSystemdService({ serviceName, envFile }) {
|
|
162
|
+
const unitFile = `/etc/systemd/system/${serviceName}.service`;
|
|
163
|
+
const tmpFile = join('/tmp', `${serviceName}.service`);
|
|
164
|
+
const user = process.env.SUDO_USER || process.env.USER;
|
|
165
|
+
if (!user) {
|
|
166
|
+
throw new CliError('Could not resolve current user for systemd unit', EXIT.FAIL);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
await mkdir(HIVE_HOME, { recursive: true });
|
|
170
|
+
|
|
171
|
+
const unit = buildSystemdUnit({
|
|
172
|
+
serviceName,
|
|
173
|
+
nodePath: process.execPath,
|
|
174
|
+
hiveBinPath: getHiveBinPath(),
|
|
175
|
+
envFile,
|
|
176
|
+
user,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await writeFile(tmpFile, unit, 'utf-8');
|
|
180
|
+
await runInherit('sudo', ['cp', tmpFile, unitFile]);
|
|
181
|
+
await runInherit('sudo', ['chmod', '644', unitFile]);
|
|
182
|
+
await runInherit('sudo', ['systemctl', 'daemon-reload']);
|
|
183
|
+
await runInherit('sudo', ['systemctl', 'enable', '--now', serviceName]);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
servicePlatform: 'systemd',
|
|
187
|
+
serviceName,
|
|
188
|
+
serviceFile: unitFile,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function shouldProceed({ message, yes }) {
|
|
193
|
+
if (yes) return true;
|
|
194
|
+
const answer = await prompts({
|
|
195
|
+
type: 'confirm',
|
|
196
|
+
name: 'ok',
|
|
197
|
+
message,
|
|
198
|
+
initial: true,
|
|
199
|
+
});
|
|
200
|
+
return Boolean(answer.ok);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function installHiveService({ envFile, yes = false, serviceName }) {
|
|
204
|
+
const defaults = getServiceDefaults();
|
|
205
|
+
const resolvedServiceName = serviceName || defaults.serviceName;
|
|
206
|
+
const servicePlatform = defaults.servicePlatform;
|
|
207
|
+
|
|
208
|
+
if (!(await shouldProceed({ yes, message: `Install Hive as a ${servicePlatform} service?` }))) {
|
|
209
|
+
throw new CliError('Service install cancelled', EXIT.FAIL);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (servicePlatform === 'launchd') {
|
|
213
|
+
return installLaunchdService({ serviceName: resolvedServiceName, envFile });
|
|
214
|
+
}
|
|
215
|
+
return installSystemdService({ serviceName: resolvedServiceName, envFile });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function startHiveService({ servicePlatform, serviceName }) {
|
|
219
|
+
if (servicePlatform === 'launchd') {
|
|
220
|
+
await runInherit('launchctl', ['kickstart', '-k', getLaunchdTarget(serviceName)]);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
await runInherit('sudo', ['systemctl', 'start', serviceName]);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function stopHiveService({ servicePlatform, serviceName }) {
|
|
227
|
+
if (servicePlatform === 'launchd') {
|
|
228
|
+
await runInherit('launchctl', ['bootout', getLaunchdTarget(serviceName)]);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
await runInherit('sudo', ['systemctl', 'stop', serviceName]);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function restartHiveService({ servicePlatform, serviceName }) {
|
|
235
|
+
if (servicePlatform === 'launchd') {
|
|
236
|
+
await runInherit('launchctl', ['kickstart', '-k', getLaunchdTarget(serviceName)]);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
await runInherit('sudo', ['systemctl', 'restart', serviceName]);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function getHiveServiceStatus({ servicePlatform, serviceName }) {
|
|
243
|
+
if (servicePlatform === 'launchd') {
|
|
244
|
+
const target = getLaunchdTarget(serviceName);
|
|
245
|
+
const { stdout } = await run('launchctl', ['print', target]).catch(() => ({ stdout: '' }));
|
|
246
|
+
return {
|
|
247
|
+
active: stdout.includes('state = running') || stdout.includes('last exit code = 0'),
|
|
248
|
+
detail: stdout || 'No launchd service output found',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const activeCmd = await run('sudo', ['systemctl', 'is-active', serviceName]).catch(() => ({ stdout: 'inactive' }));
|
|
253
|
+
const statusCmd = await run('sudo', ['systemctl', 'status', serviceName, '--no-pager', '--lines', '30']).catch(() => ({ stdout: '' }));
|
|
254
|
+
return {
|
|
255
|
+
active: activeCmd.stdout.trim() === 'active',
|
|
256
|
+
detail: statusCmd.stdout || activeCmd.stdout,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function streamHiveServiceLogs({ servicePlatform, serviceName, follow = true, lines = 80 }) {
|
|
261
|
+
if (servicePlatform === 'launchd') {
|
|
262
|
+
const logsDir = getLaunchdLogsDir();
|
|
263
|
+
const stdoutPath = join(logsDir, 'hive-server.out.log');
|
|
264
|
+
const stderrPath = join(logsDir, 'hive-server.err.log');
|
|
265
|
+
|
|
266
|
+
await mkdir(logsDir, { recursive: true });
|
|
267
|
+
if (!existsSync(stdoutPath)) await writeFile(stdoutPath, '', 'utf-8');
|
|
268
|
+
if (!existsSync(stderrPath)) await writeFile(stderrPath, '', 'utf-8');
|
|
269
|
+
|
|
270
|
+
const args = follow ? ['-n', String(lines), '-f', stdoutPath, stderrPath] : ['-n', String(lines), stdoutPath, stderrPath];
|
|
271
|
+
await runInherit('tail', args);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const args = ['journalctl', '-u', serviceName, '--no-pager', '-n', String(lines)];
|
|
276
|
+
if (follow) args.push('-f');
|
|
277
|
+
await runInherit('sudo', args);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function ensureFileWritable(path) {
|
|
281
|
+
try {
|
|
282
|
+
await access(path, fsConstants.W_OK);
|
|
283
|
+
return true;
|
|
284
|
+
} catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function uninstallHiveService({ servicePlatform, serviceName, yes = false }) {
|
|
290
|
+
if (!(await shouldProceed({ yes, message: `Uninstall Hive service '${serviceName}'?` }))) {
|
|
291
|
+
throw new CliError('Service uninstall cancelled', EXIT.FAIL);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (servicePlatform === 'launchd') {
|
|
295
|
+
const target = getLaunchdTarget(serviceName);
|
|
296
|
+
const plistPath = getLaunchAgentPath(serviceName);
|
|
297
|
+
await run('launchctl', ['bootout', target]).catch(() => {});
|
|
298
|
+
if (existsSync(plistPath) && await ensureFileWritable(plistPath)) {
|
|
299
|
+
await rm(plistPath, { force: true });
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const unitFile = `/etc/systemd/system/${serviceName}.service`;
|
|
305
|
+
await run('sudo', ['systemctl', 'disable', '--now', serviceName]).catch(() => {});
|
|
306
|
+
if (existsSync(unitFile)) {
|
|
307
|
+
await runInherit('sudo', ['rm', '-f', unitFile]);
|
|
308
|
+
}
|
|
309
|
+
await runInherit('sudo', ['systemctl', 'daemon-reload']);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export async function cloudflaredServiceStatus() {
|
|
313
|
+
if (process.platform === 'darwin') {
|
|
314
|
+
const { stdout } = await run('launchctl', ['list']).catch(() => ({ stdout: '' }));
|
|
315
|
+
return stdout.includes('cloudflared');
|
|
316
|
+
}
|
|
317
|
+
const { stdout } = await run('sudo', ['systemctl', 'is-active', 'cloudflared']).catch(() => ({ stdout: 'inactive' }));
|
|
318
|
+
return stdout.trim() === 'active';
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function serviceStatusSummary({ servicePlatform, serviceName }) {
|
|
322
|
+
const service = await getHiveServiceStatus({ servicePlatform, serviceName });
|
|
323
|
+
const tunnelServiceActive = await cloudflaredServiceStatus().catch(() => false);
|
|
324
|
+
return {
|
|
325
|
+
...service,
|
|
326
|
+
tunnelServiceActive,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function previewServiceDefinition({ servicePlatform, serviceName, envFile }) {
|
|
331
|
+
if (servicePlatform === 'launchd') {
|
|
332
|
+
return buildLaunchdPlist({
|
|
333
|
+
serviceName,
|
|
334
|
+
nodePath: process.execPath,
|
|
335
|
+
hiveBinPath: getHiveBinPath(),
|
|
336
|
+
envFile,
|
|
337
|
+
stdoutPath: join(getLaunchdLogsDir(), 'hive-server.out.log'),
|
|
338
|
+
stderrPath: join(getLaunchdLogsDir(), 'hive-server.err.log'),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const user = process.env.SUDO_USER || process.env.USER || 'unknown';
|
|
342
|
+
return buildSystemdUnit({
|
|
343
|
+
serviceName,
|
|
344
|
+
nodePath: process.execPath,
|
|
345
|
+
hiveBinPath: getHiveBinPath(),
|
|
346
|
+
envFile,
|
|
347
|
+
user,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function readServiceFile({ servicePlatform, serviceName }) {
|
|
352
|
+
if (servicePlatform === 'launchd') {
|
|
353
|
+
const path = getLaunchAgentPath(serviceName);
|
|
354
|
+
if (!existsSync(path)) return null;
|
|
355
|
+
return readFile(path, 'utf-8');
|
|
356
|
+
}
|
|
357
|
+
const path = `/etc/systemd/system/${serviceName}.service`;
|
|
358
|
+
if (!existsSync(path)) return null;
|
|
359
|
+
return run('sudo', ['cat', path]).then((r) => r.stdout);
|
|
360
|
+
}
|
package/cli/tunnel.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'fs/promises';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import which from 'which';
|
|
6
|
+
import prompts from 'prompts';
|
|
7
|
+
import { CliError } from './errors.js';
|
|
8
|
+
import { EXIT } from './constants.js';
|
|
9
|
+
import { run, runInherit } from './exec.js';
|
|
10
|
+
import { info, success, warn } from './output.js';
|
|
11
|
+
|
|
12
|
+
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
13
|
+
|
|
14
|
+
export function detectPlatform() {
|
|
15
|
+
if (process.platform === 'darwin') return 'darwin';
|
|
16
|
+
if (process.platform === 'linux') return 'linux';
|
|
17
|
+
throw new CliError(`Unsupported OS: ${process.platform}`, EXIT.PREREQ);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getCloudflaredPath() {
|
|
21
|
+
try {
|
|
22
|
+
return which.sync('cloudflared');
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function ensureCloudflaredInstalled({ yes = false } = {}) {
|
|
29
|
+
const existing = getCloudflaredPath();
|
|
30
|
+
if (existing) return existing;
|
|
31
|
+
|
|
32
|
+
const platform = detectPlatform();
|
|
33
|
+
let install = yes;
|
|
34
|
+
if (!yes) {
|
|
35
|
+
const answer = await prompts({
|
|
36
|
+
type: 'confirm',
|
|
37
|
+
name: 'ok',
|
|
38
|
+
message: 'cloudflared is missing. Install now?',
|
|
39
|
+
initial: true,
|
|
40
|
+
});
|
|
41
|
+
install = Boolean(answer.ok);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!install) {
|
|
45
|
+
throw new CliError('cloudflared is required', EXIT.PREREQ);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (platform === 'darwin') {
|
|
49
|
+
if (!which.sync('brew', { nothrow: true })) {
|
|
50
|
+
throw new CliError('Homebrew not found. Install brew first: https://brew.sh', EXIT.PREREQ);
|
|
51
|
+
}
|
|
52
|
+
await runInherit('brew', ['install', 'cloudflared']);
|
|
53
|
+
} else {
|
|
54
|
+
if (!which.sync('apt-get', { nothrow: true })) {
|
|
55
|
+
throw new CliError('Unsupported Linux package manager. Install cloudflared manually.', EXIT.PREREQ);
|
|
56
|
+
}
|
|
57
|
+
await runInherit('sudo', ['mkdir', '-p', '/usr/share/keyrings']);
|
|
58
|
+
await runInherit('bash', ['-lc', 'curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null']);
|
|
59
|
+
await runInherit('bash', ['-lc', "echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list >/dev/null"]);
|
|
60
|
+
await runInherit('sudo', ['apt-get', 'update', '-q']);
|
|
61
|
+
await runInherit('sudo', ['apt-get', 'install', '-y', 'cloudflared']);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const after = getCloudflaredPath();
|
|
65
|
+
if (!after) {
|
|
66
|
+
throw new CliError('Failed to install cloudflared', EXIT.PREREQ);
|
|
67
|
+
}
|
|
68
|
+
return after;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function ensureCloudflaredLogin({ certPath, yes = false }) {
|
|
72
|
+
if (existsSync(certPath)) {
|
|
73
|
+
success(`Cloudflare auth cert found: ${certPath}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!yes) {
|
|
78
|
+
warn('A browser window will open for Cloudflare login.');
|
|
79
|
+
const answer = await prompts({
|
|
80
|
+
type: 'confirm',
|
|
81
|
+
name: 'ok',
|
|
82
|
+
message: 'Continue with cloudflared login?',
|
|
83
|
+
initial: true,
|
|
84
|
+
});
|
|
85
|
+
if (!answer.ok) throw new CliError('Cloudflare login cancelled', EXIT.FAIL);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await runInherit('cloudflared', ['tunnel', 'login']);
|
|
89
|
+
|
|
90
|
+
if (!existsSync(certPath)) {
|
|
91
|
+
throw new CliError(`Cloudflare login did not create cert at ${certPath}`, EXIT.PREREQ);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function listTunnels() {
|
|
96
|
+
try {
|
|
97
|
+
const { stdout } = await run('cloudflared', ['tunnel', 'list', '--output', 'json']);
|
|
98
|
+
return JSON.parse(stdout);
|
|
99
|
+
} catch {
|
|
100
|
+
const { stdout } = await run('cloudflared', ['tunnel', 'list']);
|
|
101
|
+
const lines = stdout.split('\n').slice(1).filter(Boolean);
|
|
102
|
+
return lines.map((line) => {
|
|
103
|
+
const parts = line.trim().split(/\s+/);
|
|
104
|
+
return { id: parts[0], name: parts[1] };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function ensureTunnel({ tunnelName }) {
|
|
110
|
+
const tunnels = await listTunnels();
|
|
111
|
+
const existing = tunnels.find((t) => t.name === tunnelName);
|
|
112
|
+
if (existing?.id) {
|
|
113
|
+
success(`Using existing tunnel '${tunnelName}' (${existing.id})`);
|
|
114
|
+
return existing.id;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const { stdout, stderr } = await run('cloudflared', ['tunnel', 'create', tunnelName]);
|
|
118
|
+
const combined = `${stdout}\n${stderr}`;
|
|
119
|
+
const match = combined.match(UUID_RE);
|
|
120
|
+
if (!match) {
|
|
121
|
+
throw new CliError(`Could not parse tunnel ID from output:\n${combined}`);
|
|
122
|
+
}
|
|
123
|
+
const tunnelId = match[0];
|
|
124
|
+
success(`Created tunnel '${tunnelName}' (${tunnelId})`);
|
|
125
|
+
return tunnelId;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function getTunnelCredentialsFile(tunnelId) {
|
|
129
|
+
return join(homedir(), '.cloudflared', `${tunnelId}.json`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function writeCloudflaredConfig({
|
|
133
|
+
configFile,
|
|
134
|
+
tunnelId,
|
|
135
|
+
credentialsFile,
|
|
136
|
+
domain,
|
|
137
|
+
port,
|
|
138
|
+
yjsPort,
|
|
139
|
+
}) {
|
|
140
|
+
await mkdir(dirname(configFile), { recursive: true });
|
|
141
|
+
const yaml = `tunnel: ${tunnelId}\ncredentials-file: ${credentialsFile}\n\ningress:\n - hostname: ${domain}\n path: /yjs/*\n service: http://localhost:${yjsPort}\n\n - hostname: ${domain}\n service: http://localhost:${port}\n\n - service: http_status:404\n`;
|
|
142
|
+
await writeFile(configFile, yaml, 'utf-8');
|
|
143
|
+
return yaml;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function ensureDnsRoute({ tunnelName, domain }) {
|
|
147
|
+
try {
|
|
148
|
+
await runInherit('cloudflared', ['tunnel', 'route', 'dns', tunnelName, domain]);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
const output = `${err?.stdout ?? ''}\n${err?.stderr ?? ''}`;
|
|
151
|
+
if (output.includes('already exists')) {
|
|
152
|
+
warn(`DNS route already exists for ${domain}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function installCloudflaredService() {
|
|
160
|
+
await runInherit('sudo', ['cloudflared', 'service', 'install']);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function cloudflaredServiceStatus() {
|
|
164
|
+
const platform = detectPlatform();
|
|
165
|
+
if (platform === 'darwin') {
|
|
166
|
+
const { stdout } = await run('launchctl', ['list']);
|
|
167
|
+
return stdout.includes('cloudflared');
|
|
168
|
+
}
|
|
169
|
+
const { stdout } = await run('systemctl', ['is-active', 'cloudflared']);
|
|
170
|
+
return stdout.trim() === 'active';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function runTunnelForeground({ tunnelName }) {
|
|
174
|
+
await runInherit('cloudflared', ['tunnel', 'run', tunnelName]);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function tunnelStatus({ tunnelName, configFile }) {
|
|
178
|
+
const tunnels = await listTunnels();
|
|
179
|
+
const tunnel = tunnels.find((t) => t.name === tunnelName);
|
|
180
|
+
const configExists = existsSync(configFile);
|
|
181
|
+
let configPreview = '';
|
|
182
|
+
if (configExists) {
|
|
183
|
+
configPreview = await readFile(configFile, 'utf-8');
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
tunnel,
|
|
187
|
+
configExists,
|
|
188
|
+
configFile,
|
|
189
|
+
configPreview,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function setupTunnel({
|
|
194
|
+
tunnelName,
|
|
195
|
+
domain,
|
|
196
|
+
configFile,
|
|
197
|
+
certPath,
|
|
198
|
+
port,
|
|
199
|
+
yjsPort,
|
|
200
|
+
yes = false,
|
|
201
|
+
installService = false,
|
|
202
|
+
}) {
|
|
203
|
+
info('Ensuring cloudflared is installed');
|
|
204
|
+
await ensureCloudflaredInstalled({ yes });
|
|
205
|
+
|
|
206
|
+
info('Ensuring cloudflared is authenticated');
|
|
207
|
+
await ensureCloudflaredLogin({ certPath, yes });
|
|
208
|
+
|
|
209
|
+
info(`Ensuring tunnel '${tunnelName}' exists`);
|
|
210
|
+
const tunnelId = await ensureTunnel({ tunnelName });
|
|
211
|
+
const credentialsFile = getTunnelCredentialsFile(tunnelId);
|
|
212
|
+
|
|
213
|
+
info(`Writing cloudflared config at ${configFile}`);
|
|
214
|
+
await writeCloudflaredConfig({
|
|
215
|
+
configFile,
|
|
216
|
+
tunnelId,
|
|
217
|
+
credentialsFile,
|
|
218
|
+
domain,
|
|
219
|
+
port,
|
|
220
|
+
yjsPort,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
info(`Ensuring DNS route for ${domain}`);
|
|
224
|
+
await ensureDnsRoute({ tunnelName, domain });
|
|
225
|
+
|
|
226
|
+
if (installService) {
|
|
227
|
+
info('Installing cloudflared as a system service');
|
|
228
|
+
await installCloudflaredService();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
success('Tunnel setup complete');
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
tunnelId,
|
|
235
|
+
credentialsFile,
|
|
236
|
+
configFile,
|
|
237
|
+
};
|
|
238
|
+
}
|