@fyresmith/hive-server 2.3.2 → 3.0.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/cli/main.js CHANGED
@@ -1,933 +1,12 @@
1
- import { existsSync } from 'fs';
2
- import { access, readFile } from 'fs/promises';
3
- import { constants as fsConstants } from 'fs';
4
- import { homedir } from 'os';
5
- import { join } from 'path';
6
1
  import process from 'process';
7
- import { Command, CommanderError } from 'commander';
8
- import prompts from 'prompts';
9
- import {
10
- DEFAULT_ENV_FILE,
11
- DEFAULT_CLOUDFLARED_CERT,
12
- DEFAULT_CLOUDFLARED_CONFIG,
13
- DEFAULT_TUNNEL_NAME,
14
- EXIT,
15
- HIVE_CONFIG_FILE,
16
- LEGACY_ENV_FILE,
17
- } from './constants.js';
2
+ import { EXIT } from './constants.js';
18
3
  import { CliError } from './errors.js';
19
- import { section, info, success, warn, fail } from './output.js';
20
- import { loadHiveConfig, updateHiveConfig } from './config.js';
21
- import {
22
- inferDomainFromRedirect,
23
- loadEnvFile,
24
- normalizeEnv,
25
- promptForEnv,
26
- redactEnv,
27
- validateEnvValues,
28
- writeEnvFile,
29
- } from './env-file.js';
30
- import { isPortAvailable, pathExists, validateDomain } from './checks.js';
31
- import { run, runInherit } from './exec.js';
32
- import {
33
- detectPlatform,
34
- cloudflaredServiceStatus,
35
- isCloudflaredServiceInstalled,
36
- installCloudflaredService,
37
- runTunnelForeground,
38
- restartCloudflaredServiceIfInstalled,
39
- startCloudflaredServiceIfInstalled,
40
- stopCloudflaredServiceIfInstalled,
41
- streamCloudflaredServiceLogs,
42
- setupTunnel,
43
- tunnelStatus,
44
- getCloudflaredPath,
45
- } from './tunnel.js';
46
- import {
47
- getServiceDefaults,
48
- getHiveServiceStatus,
49
- installHiveService,
50
- restartHiveService,
51
- startHiveService,
52
- stopHiveService,
53
- streamHiveServiceLogs,
54
- uninstallHiveService,
55
- } from './service.js';
56
- import { startHiveServer } from '../index.js';
57
-
58
- function parseInteger(value, key) {
59
- const parsed = parseInt(String(value ?? '').trim(), 10);
60
- if (!Number.isInteger(parsed) || parsed <= 0) {
61
- throw new CliError(`${key} must be a positive integer`);
62
- }
63
- return parsed;
64
- }
65
-
66
- function requiredOrFallback(value, fallback) {
67
- const trimmed = String(value ?? '').trim();
68
- return trimmed || fallback;
69
- }
70
-
71
- async function promptConfirm(message, yes = false, initial = true) {
72
- if (yes) return true;
73
- const answer = await prompts({
74
- type: 'confirm',
75
- name: 'ok',
76
- message,
77
- initial,
78
- });
79
- return Boolean(answer.ok);
80
- }
81
-
82
- async function resolveContext(options = {}) {
83
- const config = await loadHiveConfig();
84
- const envFile = options.envFile || config.envFile || DEFAULT_ENV_FILE;
85
- return { config, envFile };
86
- }
87
-
88
- function resolveServiceConfig(config) {
89
- const defaults = getServiceDefaults();
90
- return {
91
- servicePlatform: config.servicePlatform || defaults.servicePlatform,
92
- serviceName: config.serviceName || defaults.serviceName,
93
- };
94
- }
95
-
96
- async function loadPackageMeta() {
97
- const raw = await readFile(new URL('../package.json', import.meta.url), 'utf-8');
98
- const parsed = JSON.parse(raw);
99
- const name = String(parsed?.name ?? '').trim();
100
- const version = String(parsed?.version ?? '').trim() || 'unknown';
101
- if (!name) {
102
- throw new CliError('Could not resolve package name from package.json', EXIT.FAIL);
103
- }
104
- return { name, version };
105
- }
106
-
107
- function isHiveServiceInstalled({ servicePlatform, serviceName }) {
108
- if (servicePlatform === 'launchd') {
109
- return existsSync(join(homedir(), 'Library', 'LaunchAgents', `${serviceName}.plist`));
110
- }
111
- return existsSync(`/etc/systemd/system/${serviceName}.service`);
112
- }
113
-
114
- function normalizeLogsComponent(value) {
115
- const component = String(value ?? 'hive').trim().toLowerCase();
116
- if (component === 'hive' || component === 'tunnel' || component === 'both') {
117
- return component;
118
- }
119
- throw new CliError(`Invalid logs component: ${value}. Use hive, tunnel, or both.`);
120
- }
121
-
122
- async function runUpFlow() {
123
- section('Hive Up');
124
-
125
- const { config } = await resolveContext({});
126
- const hiveService = resolveServiceConfig(config);
127
-
128
- let startedAny = false;
129
-
130
- if (isHiveServiceInstalled(hiveService)) {
131
- info(`Starting Hive service: ${hiveService.serviceName}`);
132
- await startHiveService(hiveService);
133
- startedAny = true;
134
- success('Hive service started');
135
- } else {
136
- warn('Hive service is not installed. Use `hive service install` (or `hive setup`).');
137
- }
138
-
139
- info('Starting cloudflared service if installed');
140
- const tunnelStart = await startCloudflaredServiceIfInstalled();
141
- if (tunnelStart.installed) {
142
- startedAny = true;
143
- success('cloudflared service started');
144
- } else {
145
- warn('cloudflared service is not installed. Use `hive tunnel service-install`.');
146
- }
147
-
148
- if (!startedAny) {
149
- throw new CliError('No installed services were started.', EXIT.FAIL);
150
- }
151
- }
152
-
153
- async function runDownFlow() {
154
- section('Hive Down');
155
-
156
- const { config } = await resolveContext({});
157
- const hiveService = resolveServiceConfig(config);
158
-
159
- let stoppedAny = false;
160
-
161
- if (isHiveServiceInstalled(hiveService)) {
162
- info(`Stopping Hive service: ${hiveService.serviceName}`);
163
- await stopHiveService(hiveService);
164
- stoppedAny = true;
165
- success('Hive service stopped');
166
- } else {
167
- warn('Hive service is not installed.');
168
- }
169
-
170
- info('Stopping cloudflared service if installed');
171
- const tunnelStop = await stopCloudflaredServiceIfInstalled();
172
- if (tunnelStop.installed) {
173
- stoppedAny = true;
174
- success('cloudflared service stopped');
175
- } else {
176
- warn('cloudflared service is not installed.');
177
- }
178
-
179
- if (!stoppedAny) {
180
- throw new CliError('No installed services were stopped.', EXIT.FAIL);
181
- }
182
- }
183
-
184
- async function runLogsFlow(options = {}) {
185
- const component = normalizeLogsComponent(options.component);
186
- const follow = Boolean(options.follow);
187
- const lines = parseInteger(options.lines, 'lines');
188
- const { config } = await resolveContext({});
189
- const hiveService = resolveServiceConfig(config);
190
- const hiveInstalled = isHiveServiceInstalled(hiveService);
191
- const tunnelInstalled = isCloudflaredServiceInstalled();
192
-
193
- if (component === 'hive') {
194
- if (!hiveInstalled) {
195
- throw new CliError(`Hive service is not installed: ${hiveService.serviceName}`);
196
- }
197
- await streamHiveServiceLogs({ ...hiveService, follow, lines });
198
- return;
199
- }
200
-
201
- if (component === 'tunnel') {
202
- if (!tunnelInstalled) {
203
- throw new CliError('cloudflared service is not installed');
204
- }
205
- await streamCloudflaredServiceLogs({ follow, lines });
206
- return;
207
- }
208
-
209
- if (!hiveInstalled && !tunnelInstalled) {
210
- throw new CliError('No installed services found for logs');
211
- }
212
-
213
- if (detectPlatform() === 'linux') {
214
- const args = ['journalctl', '--no-pager', '-n', String(lines)];
215
- if (hiveInstalled) args.push('-u', hiveService.serviceName);
216
- if (tunnelInstalled) args.push('-u', 'cloudflared');
217
- if (follow) args.push('-f');
218
- await runInherit('sudo', args);
219
- return;
220
- }
221
-
222
- if (follow) {
223
- throw new CliError('Combined follow logs are not supported on macOS. Use --component hive or --component tunnel.');
224
- }
225
-
226
- if (hiveInstalled) {
227
- section('Hive Service Logs');
228
- await streamHiveServiceLogs({ ...hiveService, follow: false, lines });
229
- }
230
- if (tunnelInstalled) {
231
- section('Tunnel Service Logs');
232
- await streamCloudflaredServiceLogs({ follow: false, lines });
233
- }
234
- }
235
-
236
- async function runUpdateFlow(options = {}) {
237
- section('Hive Update');
238
-
239
- const { config } = await resolveContext({});
240
- const pkg = await loadPackageMeta();
241
- const packageName = requiredOrFallback(options.package, pkg.name);
242
- const hiveService = resolveServiceConfig(config);
243
-
244
- info(`Current CLI version: ${pkg.version}`);
245
- info(`Updating ${packageName} from npm (latest)`);
246
- await runInherit('npm', ['install', '-g', `${packageName}@latest`]);
247
- success(`Installed latest ${packageName}`);
248
-
249
- if (isHiveServiceInstalled(hiveService)) {
250
- info(`Restarting Hive service: ${hiveService.serviceName}`);
251
- await restartHiveService(hiveService);
252
- success('Hive service restarted');
253
- } else {
254
- info(`Hive service not installed: ${hiveService.serviceName}`);
255
- }
256
-
257
- info('Restarting cloudflared service if installed');
258
- const tunnelRestart = await restartCloudflaredServiceIfInstalled();
259
- if (tunnelRestart.installed) {
260
- success('cloudflared service restarted');
261
- } else {
262
- info('cloudflared service not installed');
263
- }
264
- }
265
-
266
- async function loadValidatedEnv(envFile, { requireFile = true } = {}) {
267
- if (requireFile && !existsSync(envFile)) {
268
- throw new CliError(`Env file not found: ${envFile}. Run: hive env init`, EXIT.FAIL);
269
- }
270
- const raw = await loadEnvFile(envFile);
271
- const env = normalizeEnv(raw);
272
- const issues = validateEnvValues(env);
273
- return { env, issues };
274
- }
275
-
276
- async function setRedirectUriForDomain({ envFile, env, domain, yes = false }) {
277
- const expected = `https://${domain}/auth/callback`;
278
- if (env.DISCORD_REDIRECT_URI === expected) return env;
279
-
280
- const shouldUpdate = await promptConfirm(
281
- `Set DISCORD_REDIRECT_URI to ${expected}?`,
282
- yes,
283
- true
284
- );
285
-
286
- if (!shouldUpdate) return env;
287
-
288
- const next = { ...env, DISCORD_REDIRECT_URI: expected };
289
- await writeEnvFile(envFile, next);
290
- success(`Updated DISCORD_REDIRECT_URI -> ${expected}`);
291
- return next;
292
- }
293
-
294
- async function runDoctorChecks({ envFile, includeCloudflared = true }) {
295
- section('Hive Doctor');
296
-
297
- let prereqFailures = 0;
298
- let failures = 0;
299
-
300
- const major = parseInt(process.versions.node.split('.')[0], 10);
301
- if (major >= 18) {
302
- success(`Node version OK (${process.versions.node})`);
303
- } else {
304
- fail(`Node >= 18 is required (current: ${process.versions.node})`);
305
- prereqFailures += 1;
306
- }
307
-
308
- if (includeCloudflared) {
309
- const cloudflaredPath = getCloudflaredPath();
310
- if (!cloudflaredPath) {
311
- fail('cloudflared is not installed or not on PATH');
312
- prereqFailures += 1;
313
- } else {
314
- const versionOutput = await run('cloudflared', ['--version']).catch(() => ({ stdout: '' }));
315
- success(`cloudflared found (${cloudflaredPath}) ${versionOutput.stdout.trim()}`);
316
- }
317
- }
318
-
319
- if (!existsSync(envFile)) {
320
- fail(`Env file missing: ${envFile}`);
321
- failures += 1;
322
- } else {
323
- success(`Env file found: ${envFile}`);
324
- const { env, issues } = await loadValidatedEnv(envFile, { requireFile: true });
325
- if (issues.length > 0) {
326
- for (const issue of issues) fail(issue);
327
- failures += issues.length;
328
- } else {
329
- success('Env values look valid');
330
- }
331
-
332
- if (env.VAULT_PATH) {
333
- const vaultExists = await pathExists(env.VAULT_PATH);
334
- if (!vaultExists) {
335
- fail(`VAULT_PATH does not exist: ${env.VAULT_PATH}`);
336
- failures += 1;
337
- } else {
338
- try {
339
- await access(env.VAULT_PATH, fsConstants.R_OK | fsConstants.W_OK);
340
- success(`VAULT_PATH is readable/writable: ${env.VAULT_PATH}`);
341
- } catch {
342
- fail(`VAULT_PATH is not readable/writable: ${env.VAULT_PATH}`);
343
- failures += 1;
344
- }
345
- }
346
- }
347
-
348
- const port = parseInt(env.PORT, 10);
349
- const yjsPort = parseInt(env.YJS_PORT, 10);
350
- if (Number.isInteger(port) && port > 0) {
351
- const portFree = await isPortAvailable(port);
352
- if (portFree) info(`PORT ${port} is available`);
353
- else warn(`PORT ${port} is in use`);
354
- }
355
- if (Number.isInteger(yjsPort) && yjsPort > 0) {
356
- const yjsFree = await isPortAvailable(yjsPort);
357
- if (yjsFree) info(`YJS_PORT ${yjsPort} is available`);
358
- else warn(`YJS_PORT ${yjsPort} is in use`);
359
- }
360
-
361
- if (Number.isInteger(port) && port > 0) {
362
- const health = await fetch(`http://127.0.0.1:${port}/health`)
363
- .then((res) => ({ ok: res.ok, status: res.status }))
364
- .catch(() => null);
365
- if (health?.ok) {
366
- success(`Health endpoint reachable on :${port}`);
367
- } else if (health) {
368
- warn(`Health endpoint returned HTTP ${health.status}`);
369
- } else {
370
- info(`Health endpoint not reachable on :${port} (server may not be running)`);
371
- }
372
- }
373
- }
374
-
375
- if (prereqFailures > 0) {
376
- throw new CliError('Doctor found missing prerequisites', EXIT.PREREQ);
377
- }
378
- if (failures > 0) {
379
- throw new CliError('Doctor found configuration issues', EXIT.FAIL);
380
- }
381
-
382
- success('Doctor checks passed');
383
- }
384
-
385
- async function runSetupWizard(options) {
386
- const yes = Boolean(options.yes);
387
-
388
- section('Hive Setup');
389
-
390
- const { config, envFile } = await resolveContext(options);
391
- let nextConfig = { ...config, envFile };
392
- let importedLegacy = false;
393
-
394
- if (!existsSync(HIVE_CONFIG_FILE) && existsSync(LEGACY_ENV_FILE)) {
395
- const shouldImportLegacy = await promptConfirm(
396
- `Import existing legacy env from ${LEGACY_ENV_FILE}?`,
397
- yes,
398
- true
399
- );
400
- if (shouldImportLegacy) {
401
- const legacyValues = await loadEnvFile(LEGACY_ENV_FILE);
402
- await writeEnvFile(envFile, legacyValues);
403
- importedLegacy = true;
404
- success(`Imported legacy env into ${envFile}`);
405
- }
406
- }
407
-
408
- const envExists = existsSync(envFile);
409
- let envValues;
410
- if (!envExists || importedLegacy) {
411
- info(`Initializing env file at ${envFile}`);
412
- const existing = await loadEnvFile(envFile);
413
- envValues = await promptForEnv({ envFile, existing, yes });
414
- } else {
415
- const edit = await promptConfirm('Env file exists. Edit it now?', yes, false);
416
- if (edit) {
417
- const existing = await loadEnvFile(envFile);
418
- envValues = await promptForEnv({ envFile, existing, yes });
419
- } else {
420
- envValues = normalizeEnv(await loadEnvFile(envFile));
421
- }
422
- }
423
-
424
- const envIssues = validateEnvValues(envValues);
425
- if (envIssues.length > 0) {
426
- for (const issue of envIssues) fail(issue);
427
- throw new CliError('Env configuration is invalid', EXIT.FAIL);
428
- }
429
-
430
- let domain = requiredOrFallback(
431
- options.domain,
432
- inferDomainFromRedirect(envValues.DISCORD_REDIRECT_URI) || nextConfig.domain
433
- );
434
-
435
- if (!yes) {
436
- const response = await prompts({
437
- type: 'text',
438
- name: 'domain',
439
- message: 'Public domain for Hive server',
440
- initial: domain,
441
- });
442
- if (response.domain !== undefined) {
443
- domain = String(response.domain).trim();
444
- }
445
- }
446
-
447
- if (!validateDomain(domain)) {
448
- throw new CliError(`Invalid domain: ${domain}`);
449
- }
450
-
451
- envValues = await setRedirectUriForDomain({ envFile, env: envValues, domain, yes });
452
-
453
- const shouldSetupTunnel = await promptConfirm('Configure Cloudflare Tunnel now?', yes, true);
454
- if (shouldSetupTunnel) {
455
- const port = parseInteger(envValues.PORT, 'PORT');
456
- const yjsPort = parseInteger(envValues.YJS_PORT, 'YJS_PORT');
457
- const tunnelName = requiredOrFallback(options.tunnelName, nextConfig.tunnelName || DEFAULT_TUNNEL_NAME);
458
- const cloudflaredConfigFile = requiredOrFallback(
459
- options.cloudflaredConfigFile,
460
- nextConfig.cloudflaredConfigFile || DEFAULT_CLOUDFLARED_CONFIG
461
- );
462
- const tunnelService = await promptConfirm('Install cloudflared as a service?', yes, true);
463
-
464
- const tunnelResult = await setupTunnel({
465
- tunnelName,
466
- domain,
467
- configFile: cloudflaredConfigFile,
468
- certPath: DEFAULT_CLOUDFLARED_CERT,
469
- port,
470
- yjsPort,
471
- yes,
472
- installService: tunnelService,
473
- });
474
-
475
- nextConfig = {
476
- ...nextConfig,
477
- domain,
478
- tunnelName,
479
- tunnelId: tunnelResult.tunnelId,
480
- tunnelCredentialsFile: tunnelResult.credentialsFile,
481
- cloudflaredConfigFile: tunnelResult.configFile,
482
- };
483
- }
484
-
485
- const shouldInstallService = await promptConfirm('Install Hive server as an OS service?', yes, true);
486
- if (shouldInstallService) {
487
- const serviceInfo = await installHiveService({ envFile, yes });
488
- nextConfig = {
489
- ...nextConfig,
490
- servicePlatform: serviceInfo.servicePlatform,
491
- serviceName: serviceInfo.serviceName,
492
- };
493
- success(`Installed service ${serviceInfo.serviceName}`);
494
- }
495
-
496
- await updateHiveConfig(nextConfig);
497
- success(`Saved config: ${HIVE_CONFIG_FILE}`);
498
-
499
- await runDoctorChecks({ envFile, includeCloudflared: shouldSetupTunnel });
500
- }
501
-
502
- function registerEnvCommands(program) {
503
- const env = program.command('env').description('Manage Hive .env configuration');
504
-
505
- env
506
- .command('init')
507
- .description('Create or update env file from prompts')
508
- .option('--env-file <path>', 'env file path')
509
- .option('--yes', 'accept defaults where possible', false)
510
- .action(async (options) => {
511
- section('Env Init');
512
- const { config, envFile } = await resolveContext(options);
513
- const existing = await loadEnvFile(envFile);
514
- const values = await promptForEnv({ envFile, existing, yes: options.yes });
515
- const issues = validateEnvValues(values);
516
- if (issues.length > 0) {
517
- for (const issue of issues) fail(issue);
518
- throw new CliError('Env file has validation issues', EXIT.FAIL);
519
- }
520
-
521
- const domain = inferDomainFromRedirect(values.DISCORD_REDIRECT_URI) || config.domain;
522
- await updateHiveConfig({ envFile, domain });
523
- success(`Env file ready at ${envFile}`);
524
- });
525
-
526
- env
527
- .command('edit')
528
- .description('Edit env values interactively')
529
- .option('--env-file <path>', 'env file path')
530
- .option('--yes', 'accept defaults where possible', false)
531
- .action(async (options) => {
532
- section('Env Edit');
533
- const { envFile } = await resolveContext(options);
534
- if (!existsSync(envFile)) {
535
- throw new CliError(`Env file not found: ${envFile}. Run: hive env init`, EXIT.FAIL);
536
- }
537
- const existing = await loadEnvFile(envFile);
538
- const values = await promptForEnv({ envFile, existing, yes: options.yes });
539
- const issues = validateEnvValues(values);
540
- if (issues.length > 0) {
541
- for (const issue of issues) fail(issue);
542
- throw new CliError('Env file has validation issues', EXIT.FAIL);
543
- }
544
- success(`Env file updated: ${envFile}`);
545
- });
546
-
547
- env
548
- .command('check')
549
- .description('Validate env file')
550
- .option('--env-file <path>', 'env file path')
551
- .action(async (options) => {
552
- section('Env Check');
553
- const { envFile } = await resolveContext(options);
554
- const { issues } = await loadValidatedEnv(envFile, { requireFile: true });
555
- if (issues.length > 0) {
556
- for (const issue of issues) fail(issue);
557
- throw new CliError('Env validation failed', EXIT.FAIL);
558
- }
559
- success('Env validation passed');
560
- });
561
-
562
- env
563
- .command('print')
564
- .description('Print redacted env values')
565
- .option('--env-file <path>', 'env file path')
566
- .action(async (options) => {
567
- const { envFile } = await resolveContext(options);
568
- const values = normalizeEnv(await loadEnvFile(envFile));
569
- const redacted = redactEnv(values);
570
- section(`Env (${envFile})`);
571
- for (const [key, value] of Object.entries(redacted)) {
572
- console.log(`${key}=${value}`);
573
- }
574
- });
575
- }
576
-
577
- function registerTunnelCommands(program) {
578
- const tunnel = program.command('tunnel').description('Manage Cloudflare tunnel');
579
-
580
- tunnel
581
- .command('setup')
582
- .description('Run full tunnel lifecycle setup')
583
- .option('--env-file <path>', 'env file path')
584
- .option('--domain <domain>', 'public domain')
585
- .option('--tunnel-name <name>', 'tunnel name')
586
- .option('--cloudflared-config-file <path>', 'cloudflared config file')
587
- .option('--install-service', 'install cloudflared service', false)
588
- .option('--yes', 'non-interactive mode', false)
589
- .action(async (options) => {
590
- section('Tunnel Setup');
591
- const { config, envFile } = await resolveContext(options);
592
- const { env, issues } = await loadValidatedEnv(envFile, { requireFile: true });
593
- if (issues.length > 0) {
594
- for (const issue of issues) fail(issue);
595
- throw new CliError('Fix env file first (hive env check)', EXIT.FAIL);
596
- }
597
-
598
- const domain = requiredOrFallback(
599
- options.domain,
600
- inferDomainFromRedirect(env.DISCORD_REDIRECT_URI) || config.domain
601
- );
602
- if (!validateDomain(domain)) {
603
- throw new CliError(`Invalid domain: ${domain}`);
604
- }
605
-
606
- const tunnelName = requiredOrFallback(options.tunnelName, config.tunnelName || DEFAULT_TUNNEL_NAME);
607
- const cloudflaredConfigFile = requiredOrFallback(
608
- options.cloudflaredConfigFile,
609
- config.cloudflaredConfigFile || DEFAULT_CLOUDFLARED_CONFIG
610
- );
611
-
612
- const tunnelResult = await setupTunnel({
613
- tunnelName,
614
- domain,
615
- configFile: cloudflaredConfigFile,
616
- certPath: DEFAULT_CLOUDFLARED_CERT,
617
- port: parseInteger(env.PORT, 'PORT'),
618
- yjsPort: parseInteger(env.YJS_PORT, 'YJS_PORT'),
619
- yes: Boolean(options.yes),
620
- installService: Boolean(options.installService),
621
- });
622
-
623
- const nextEnv = await setRedirectUriForDomain({
624
- envFile,
625
- env,
626
- domain,
627
- yes: Boolean(options.yes),
628
- });
629
-
630
- await updateHiveConfig({
631
- envFile,
632
- domain,
633
- tunnelName,
634
- tunnelId: tunnelResult.tunnelId,
635
- tunnelCredentialsFile: tunnelResult.credentialsFile,
636
- cloudflaredConfigFile: cloudflaredConfigFile,
637
- });
638
-
639
- if (nextEnv.DISCORD_REDIRECT_URI !== env.DISCORD_REDIRECT_URI) {
640
- success('Redirect URI synced for tunnel domain');
641
- }
642
- success('Tunnel setup complete');
643
- });
644
-
645
- tunnel
646
- .command('status')
647
- .description('Show tunnel status and config')
648
- .option('--tunnel-name <name>', 'tunnel name')
649
- .option('--cloudflared-config-file <path>', 'cloudflared config path')
650
- .action(async (options) => {
651
- const config = await loadHiveConfig();
652
- const tunnelName = requiredOrFallback(options.tunnelName, config.tunnelName || DEFAULT_TUNNEL_NAME);
653
- const cloudflaredConfigFile = requiredOrFallback(
654
- options.cloudflaredConfigFile,
655
- config.cloudflaredConfigFile || DEFAULT_CLOUDFLARED_CONFIG
656
- );
657
- const status = await tunnelStatus({ tunnelName, configFile: cloudflaredConfigFile });
658
- section('Tunnel Status');
659
- console.log(`Name: ${tunnelName}`);
660
- console.log(`Tunnel ID: ${status.tunnel?.id || '(not found)'}`);
661
- console.log(`Config file: ${status.configFile} ${status.configExists ? '' : '(missing)'}`);
662
- if (config.domain) {
663
- console.log(`Domain: ${config.domain}`);
664
- }
665
- const svc = await cloudflaredServiceStatus().catch(() => false);
666
- console.log(`cloudflared service: ${svc ? 'active' : 'inactive or unknown'}`);
667
- });
668
-
669
- tunnel
670
- .command('run')
671
- .description('Run tunnel in foreground')
672
- .option('--tunnel-name <name>', 'tunnel name')
673
- .action(async (options) => {
674
- const config = await loadHiveConfig();
675
- const tunnelName = requiredOrFallback(options.tunnelName, config.tunnelName || DEFAULT_TUNNEL_NAME);
676
- await runTunnelForeground({ tunnelName });
677
- });
678
-
679
- tunnel
680
- .command('service-install')
681
- .description('Install cloudflared as a system service')
682
- .action(async () => {
683
- section('Tunnel Service Install');
684
- await installCloudflaredService();
685
- success('cloudflared service installed');
686
- });
687
-
688
- tunnel
689
- .command('service-status')
690
- .description('Show cloudflared service status')
691
- .action(async () => {
692
- const active = await cloudflaredServiceStatus();
693
- section('Tunnel Service Status');
694
- console.log(active ? 'active' : 'inactive');
695
- if (process.platform === 'darwin') {
696
- const listing = await run('launchctl', ['list']).catch(() => ({ stdout: '' }));
697
- const row = listing.stdout
698
- .split('\n')
699
- .find((line) => line.toLowerCase().includes('cloudflared'));
700
- if (row) {
701
- console.log('');
702
- console.log(row.trim());
703
- }
704
- } else if (process.platform === 'linux') {
705
- const status = await run('sudo', ['systemctl', 'status', 'cloudflared', '--no-pager', '--lines', '20'])
706
- .catch(() => ({ stdout: '' }));
707
- if (status.stdout) {
708
- console.log('');
709
- console.log(status.stdout);
710
- }
711
- }
712
- });
713
- }
714
-
715
- function registerServiceCommands(program) {
716
- const service = program.command('service').description('Manage Hive OS service');
717
-
718
- service
719
- .command('install')
720
- .description('Install Hive as launchd/systemd service')
721
- .option('--env-file <path>', 'env file path')
722
- .option('--yes', 'non-interactive mode', false)
723
- .action(async (options) => {
724
- const { config, envFile } = await resolveContext(options);
725
- const infoOut = await installHiveService({
726
- envFile,
727
- yes: Boolean(options.yes),
728
- serviceName: config.serviceName,
729
- });
730
- await updateHiveConfig({
731
- envFile,
732
- servicePlatform: infoOut.servicePlatform,
733
- serviceName: infoOut.serviceName,
734
- });
735
- success(`Service installed: ${infoOut.serviceName}`);
736
- });
737
-
738
- service
739
- .command('start')
740
- .description('Start Hive service')
741
- .action(async () => {
742
- const config = await loadHiveConfig();
743
- const svc = resolveServiceConfig(config);
744
- await startHiveService(svc);
745
- success('Hive service started');
746
- });
747
-
748
- service
749
- .command('stop')
750
- .description('Stop Hive service')
751
- .action(async () => {
752
- const config = await loadHiveConfig();
753
- const svc = resolveServiceConfig(config);
754
- await stopHiveService(svc);
755
- success('Hive service stopped');
756
- });
757
-
758
- service
759
- .command('restart')
760
- .description('Restart Hive service')
761
- .action(async () => {
762
- const config = await loadHiveConfig();
763
- const svc = resolveServiceConfig(config);
764
- await restartHiveService(svc);
765
- success('Hive service restarted');
766
- });
767
-
768
- service
769
- .command('status')
770
- .description('Show Hive service status')
771
- .action(async () => {
772
- const config = await loadHiveConfig();
773
- const svc = resolveServiceConfig(config);
774
- const status = await getHiveServiceStatus(svc);
775
- section('Hive Service Status');
776
- console.log(`Service: ${svc.serviceName} (${svc.servicePlatform})`);
777
- console.log(`Active: ${status.active ? 'yes' : 'no'}`);
778
- if (status.detail) {
779
- console.log('');
780
- console.log(status.detail);
781
- }
782
- });
783
-
784
- service
785
- .command('logs')
786
- .description('Stream service logs')
787
- .option('-n, --lines <n>', 'lines to show', '80')
788
- .option('--no-follow', 'do not follow logs')
789
- .action(async (options) => {
790
- const config = await loadHiveConfig();
791
- const svc = resolveServiceConfig(config);
792
- const lines = parseInteger(options.lines, 'lines');
793
- await streamHiveServiceLogs({
794
- ...svc,
795
- follow: Boolean(options.follow),
796
- lines,
797
- });
798
- });
799
-
800
- service
801
- .command('uninstall')
802
- .description('Uninstall Hive service')
803
- .option('--yes', 'skip confirmation', false)
804
- .action(async (options) => {
805
- const config = await loadHiveConfig();
806
- const svc = resolveServiceConfig(config);
807
- await uninstallHiveService({
808
- ...svc,
809
- yes: Boolean(options.yes),
810
- });
811
- success(`Service removed: ${svc.serviceName}`);
812
- });
813
- }
814
-
815
- function registerRootCommands(program) {
816
- program
817
- .command('setup')
818
- .description('Run guided setup for env, tunnel, and service')
819
- .option('--env-file <path>', 'env file path')
820
- .option('--domain <domain>', 'public domain')
821
- .option('--tunnel-name <name>', 'tunnel name')
822
- .option('--cloudflared-config-file <path>', 'cloudflared config file')
823
- .option('--yes', 'non-interactive mode', false)
824
- .action(runSetupWizard);
825
-
826
- program
827
- .command('update')
828
- .description('Update Hive from npm and restart installed services')
829
- .option('--package <name>', 'npm package override')
830
- .action(runUpdateFlow);
831
-
832
- program
833
- .command('up')
834
- .description('Start installed Hive + cloudflared services')
835
- .action(runUpFlow);
836
-
837
- program
838
- .command('down')
839
- .description('Stop installed Hive + cloudflared services')
840
- .action(runDownFlow);
841
-
842
- program
843
- .command('logs')
844
- .description('Stream logs for Hive and/or cloudflared services')
845
- .option('-c, --component <name>', 'hive|tunnel|both', 'hive')
846
- .option('-n, --lines <n>', 'lines to show', '80')
847
- .option('--no-follow', 'do not follow logs')
848
- .action(runLogsFlow);
849
-
850
- program
851
- .command('doctor')
852
- .description('Run prerequisite and configuration checks')
853
- .option('--env-file <path>', 'env file path')
854
- .action(async (options) => {
855
- const { envFile } = await resolveContext(options);
856
- await runDoctorChecks({ envFile, includeCloudflared: true });
857
- });
858
-
859
- program
860
- .command('run')
861
- .description('Start Hive server immediately')
862
- .option('--env-file <path>', 'env file path')
863
- .option('--quiet', 'reduce startup logs', false)
864
- .action(async (options) => {
865
- const { envFile } = await resolveContext(options);
866
- if (!existsSync(envFile)) {
867
- throw new CliError(`Env file not found: ${envFile}. Run: hive env init`, EXIT.FAIL);
868
- }
869
- await startHiveServer({ envFile, quiet: Boolean(options.quiet) });
870
- info(`Hive server started using env: ${envFile}`);
871
- });
872
-
873
- program
874
- .command('status')
875
- .description('Quick status summary (service + tunnel + doctor-lite)')
876
- .option('--env-file <path>', 'env file path')
877
- .action(async (options) => {
878
- const { config, envFile } = await resolveContext(options);
879
- section('Hive Status');
880
- console.log(`Config: ${HIVE_CONFIG_FILE}`);
881
- console.log(`Env: ${envFile} ${existsSync(envFile) ? '' : '(missing)'}`);
882
- if (config.domain) console.log(`Domain: ${config.domain}`);
883
- if (config.tunnelName) console.log(`Tunnel: ${config.tunnelName}`);
884
-
885
- const svc = resolveServiceConfig(config);
886
- const serviceStatus = await getHiveServiceStatus(svc).catch(() => ({ active: false, detail: 'not installed' }));
887
- console.log(`Service ${svc.serviceName}: ${serviceStatus.active ? 'active' : 'inactive'}`);
888
-
889
- const tunnelSvc = await cloudflaredServiceStatus().catch(() => false);
890
- console.log(`cloudflared service: ${tunnelSvc ? 'active' : 'inactive or unknown'}`);
891
- });
892
- }
4
+ import { fail } from './output.js';
5
+ import { HiveCliApp } from './core/app.js';
893
6
 
894
7
  export async function runCli(argv = process.argv) {
895
- const program = new Command();
896
-
897
- program
898
- .name('hive')
899
- .description('Hive server operations CLI')
900
- .showHelpAfterError()
901
- .version('1.0.0');
902
-
903
- registerRootCommands(program);
904
- registerEnvCommands(program);
905
- registerTunnelCommands(program);
906
- registerServiceCommands(program);
907
-
908
- if ((argv?.length ?? 0) <= 2) {
909
- program.outputHelp();
910
- return EXIT.OK;
911
- }
912
-
913
- program.exitOverride();
914
-
915
- try {
916
- await program.parseAsync(argv);
917
- return EXIT.OK;
918
- } catch (err) {
919
- if (err instanceof CommanderError) {
920
- if (
921
- err.code === 'commander.helpDisplayed'
922
- || err.code === 'commander.help'
923
- || err.message === '(outputHelp)'
924
- ) {
925
- return EXIT.OK;
926
- }
927
- throw new CliError(err.message, err.exitCode ?? EXIT.FAIL);
928
- }
929
- throw err;
930
- }
8
+ const app = new HiveCliApp();
9
+ return app.run(argv);
931
10
  }
932
11
 
933
12
  export async function runCliOrExit(argv = process.argv) {