@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.
@@ -0,0 +1,80 @@
1
+ import { section, success, fail } from '../output.js';
2
+ import { EXIT } from '../constants.js';
3
+ import { CliError } from '../errors.js';
4
+ import { inferDomainFromRedirect, loadEnvFile, normalizeEnv, promptForEnv, redactEnv, validateEnvValues } from '../env-file.js';
5
+ import { updateHiveConfig } from '../config.js';
6
+ import { assertEnvFileExists, loadValidatedEnv, resolveContext } from '../core/context.js';
7
+
8
+ export function registerEnvCommands(program) {
9
+ const env = program.command('env').description('Manage Hive .env configuration');
10
+
11
+ env
12
+ .command('init')
13
+ .description('Create or update env file from prompts')
14
+ .option('--env-file <path>', 'env file path')
15
+ .option('--yes', 'accept defaults where possible', false)
16
+ .action(async (options) => {
17
+ section('Env Init');
18
+ const { config, envFile } = await resolveContext(options);
19
+ const existing = await loadEnvFile(envFile);
20
+ const values = await promptForEnv({ envFile, existing, yes: options.yes });
21
+ const issues = validateEnvValues(values);
22
+ if (issues.length > 0) {
23
+ for (const issue of issues) fail(issue);
24
+ throw new CliError('Env file has validation issues', EXIT.FAIL);
25
+ }
26
+
27
+ const domain = inferDomainFromRedirect(values.DISCORD_REDIRECT_URI) || config.domain;
28
+ await updateHiveConfig({ envFile, domain });
29
+ success(`Env file ready at ${envFile}`);
30
+ });
31
+
32
+ env
33
+ .command('edit')
34
+ .description('Edit env values interactively')
35
+ .option('--env-file <path>', 'env file path')
36
+ .option('--yes', 'accept defaults where possible', false)
37
+ .action(async (options) => {
38
+ section('Env Edit');
39
+ const { envFile } = await resolveContext(options);
40
+ assertEnvFileExists(envFile);
41
+
42
+ const existing = await loadEnvFile(envFile);
43
+ const values = await promptForEnv({ envFile, existing, yes: options.yes });
44
+ const issues = validateEnvValues(values);
45
+ if (issues.length > 0) {
46
+ for (const issue of issues) fail(issue);
47
+ throw new CliError('Env file has validation issues', EXIT.FAIL);
48
+ }
49
+ success(`Env file updated: ${envFile}`);
50
+ });
51
+
52
+ env
53
+ .command('check')
54
+ .description('Validate env file')
55
+ .option('--env-file <path>', 'env file path')
56
+ .action(async (options) => {
57
+ section('Env Check');
58
+ const { envFile } = await resolveContext(options);
59
+ const { issues } = await loadValidatedEnv(envFile, { requireFile: true });
60
+ if (issues.length > 0) {
61
+ for (const issue of issues) fail(issue);
62
+ throw new CliError('Env validation failed', EXIT.FAIL);
63
+ }
64
+ success('Env validation passed');
65
+ });
66
+
67
+ env
68
+ .command('print')
69
+ .description('Print redacted env values')
70
+ .option('--env-file <path>', 'env file path')
71
+ .action(async (options) => {
72
+ const { envFile } = await resolveContext(options);
73
+ const values = normalizeEnv(await loadEnvFile(envFile));
74
+ const redacted = redactEnv(values);
75
+ section(`Env (${envFile})`);
76
+ for (const [key, value] of Object.entries(redacted)) {
77
+ console.log(`${key}=${value}`);
78
+ }
79
+ });
80
+ }
@@ -0,0 +1,91 @@
1
+ import { existsSync } from 'fs';
2
+ import { HIVE_CONFIG_FILE, EXIT } from '../constants.js';
3
+ import { CliError } from '../errors.js';
4
+ import { loadHiveConfig } from '../config.js';
5
+ import { section, info } from '../output.js';
6
+ import { cloudflaredServiceStatus } from '../tunnel.js';
7
+ import { getHiveServiceStatus } from '../service.js';
8
+ import { resolveContext, resolveServiceConfig } from '../core/context.js';
9
+ import { runDoctorChecks } from '../flows/doctor.js';
10
+ import { runSetupWizard } from '../flows/setup.js';
11
+ import { runDownFlow, runLogsFlow, runUpFlow, runUpdateFlow } from '../flows/system.js';
12
+ import { startHiveServer } from '../../index.js';
13
+
14
+ export function registerRootCommands(program) {
15
+ program
16
+ .command('setup')
17
+ .description('Run guided setup for env, tunnel, and service')
18
+ .option('--env-file <path>', 'env file path')
19
+ .option('--domain <domain>', 'public domain')
20
+ .option('--tunnel-name <name>', 'tunnel name')
21
+ .option('--cloudflared-config-file <path>', 'cloudflared config file')
22
+ .option('--yes', 'non-interactive mode', false)
23
+ .action(runSetupWizard);
24
+
25
+ program
26
+ .command('update')
27
+ .description('Update Hive from npm and restart installed services')
28
+ .option('--package <name>', 'npm package override')
29
+ .action(runUpdateFlow);
30
+
31
+ program
32
+ .command('up')
33
+ .description('Start installed Hive + cloudflared services')
34
+ .action(runUpFlow);
35
+
36
+ program
37
+ .command('down')
38
+ .description('Stop installed Hive + cloudflared services')
39
+ .action(runDownFlow);
40
+
41
+ program
42
+ .command('logs')
43
+ .description('Stream logs for Hive and/or cloudflared services')
44
+ .option('-c, --component <name>', 'hive|tunnel|both', 'hive')
45
+ .option('-n, --lines <n>', 'lines to show', '80')
46
+ .option('--no-follow', 'do not follow logs')
47
+ .action(runLogsFlow);
48
+
49
+ program
50
+ .command('doctor')
51
+ .description('Run prerequisite and configuration checks')
52
+ .option('--env-file <path>', 'env file path')
53
+ .action(async (options) => {
54
+ const { envFile } = await resolveContext(options);
55
+ await runDoctorChecks({ envFile, includeCloudflared: true });
56
+ });
57
+
58
+ program
59
+ .command('run')
60
+ .description('Start Hive server immediately')
61
+ .option('--env-file <path>', 'env file path')
62
+ .option('--quiet', 'reduce startup logs', false)
63
+ .action(async (options) => {
64
+ const { envFile } = await resolveContext(options);
65
+ if (!existsSync(envFile)) {
66
+ throw new CliError(`Env file not found: ${envFile}. Run: hive env init`, EXIT.FAIL);
67
+ }
68
+ await startHiveServer({ envFile, quiet: Boolean(options.quiet) });
69
+ info(`Hive server started using env: ${envFile}`);
70
+ });
71
+
72
+ program
73
+ .command('status')
74
+ .description('Quick status summary (service + tunnel + doctor-lite)')
75
+ .option('--env-file <path>', 'env file path')
76
+ .action(async (options) => {
77
+ const { config, envFile } = await resolveContext(options);
78
+ section('Hive Status');
79
+ console.log(`Config: ${HIVE_CONFIG_FILE}`);
80
+ console.log(`Env: ${envFile} ${existsSync(envFile) ? '' : '(missing)'}`);
81
+ if (config.domain) console.log(`Domain: ${config.domain}`);
82
+ if (config.tunnelName) console.log(`Tunnel: ${config.tunnelName}`);
83
+
84
+ const svc = resolveServiceConfig(config);
85
+ const serviceStatus = await getHiveServiceStatus(svc).catch(() => ({ active: false, detail: 'not installed' }));
86
+ console.log(`Service ${svc.serviceName}: ${serviceStatus.active ? 'active' : 'inactive'}`);
87
+
88
+ const tunnelSvc = await cloudflaredServiceStatus().catch(() => false);
89
+ console.log(`cloudflared service: ${tunnelSvc ? 'active' : 'inactive or unknown'}`);
90
+ });
91
+ }
@@ -0,0 +1,112 @@
1
+ import { loadHiveConfig, updateHiveConfig } from '../config.js';
2
+ import { parseInteger, resolveServiceConfig, resolveContext } from '../core/context.js';
3
+ import {
4
+ getHiveServiceStatus,
5
+ installHiveService,
6
+ restartHiveService,
7
+ startHiveService,
8
+ stopHiveService,
9
+ streamHiveServiceLogs,
10
+ uninstallHiveService,
11
+ } from '../service.js';
12
+ import { section, success } from '../output.js';
13
+
14
+ export function registerServiceCommands(program) {
15
+ const service = program.command('service').description('Manage Hive OS service');
16
+
17
+ service
18
+ .command('install')
19
+ .description('Install Hive as launchd/systemd service')
20
+ .option('--env-file <path>', 'env file path')
21
+ .option('--yes', 'non-interactive mode', false)
22
+ .action(async (options) => {
23
+ const { config, envFile } = await resolveContext(options);
24
+ const infoOut = await installHiveService({
25
+ envFile,
26
+ yes: Boolean(options.yes),
27
+ serviceName: config.serviceName,
28
+ });
29
+ await updateHiveConfig({
30
+ envFile,
31
+ servicePlatform: infoOut.servicePlatform,
32
+ serviceName: infoOut.serviceName,
33
+ });
34
+ success(`Service installed: ${infoOut.serviceName}`);
35
+ });
36
+
37
+ service
38
+ .command('start')
39
+ .description('Start Hive service')
40
+ .action(async () => {
41
+ const config = await loadHiveConfig();
42
+ const svc = resolveServiceConfig(config);
43
+ await startHiveService(svc);
44
+ success('Hive service started');
45
+ });
46
+
47
+ service
48
+ .command('stop')
49
+ .description('Stop Hive service')
50
+ .action(async () => {
51
+ const config = await loadHiveConfig();
52
+ const svc = resolveServiceConfig(config);
53
+ await stopHiveService(svc);
54
+ success('Hive service stopped');
55
+ });
56
+
57
+ service
58
+ .command('restart')
59
+ .description('Restart Hive service')
60
+ .action(async () => {
61
+ const config = await loadHiveConfig();
62
+ const svc = resolveServiceConfig(config);
63
+ await restartHiveService(svc);
64
+ success('Hive service restarted');
65
+ });
66
+
67
+ service
68
+ .command('status')
69
+ .description('Show Hive service status')
70
+ .action(async () => {
71
+ const config = await loadHiveConfig();
72
+ const svc = resolveServiceConfig(config);
73
+ const status = await getHiveServiceStatus(svc);
74
+ section('Hive Service Status');
75
+ console.log(`Service: ${svc.serviceName} (${svc.servicePlatform})`);
76
+ console.log(`Active: ${status.active ? 'yes' : 'no'}`);
77
+ if (status.detail) {
78
+ console.log('');
79
+ console.log(status.detail);
80
+ }
81
+ });
82
+
83
+ service
84
+ .command('logs')
85
+ .description('Stream service logs')
86
+ .option('-n, --lines <n>', 'lines to show', '80')
87
+ .option('--no-follow', 'do not follow logs')
88
+ .action(async (options) => {
89
+ const config = await loadHiveConfig();
90
+ const svc = resolveServiceConfig(config);
91
+ const lines = parseInteger(options.lines, 'lines');
92
+ await streamHiveServiceLogs({
93
+ ...svc,
94
+ follow: Boolean(options.follow),
95
+ lines,
96
+ });
97
+ });
98
+
99
+ service
100
+ .command('uninstall')
101
+ .description('Uninstall Hive service')
102
+ .option('--yes', 'skip confirmation', false)
103
+ .action(async (options) => {
104
+ const config = await loadHiveConfig();
105
+ const svc = resolveServiceConfig(config);
106
+ await uninstallHiveService({
107
+ ...svc,
108
+ yes: Boolean(options.yes),
109
+ });
110
+ success(`Service removed: ${svc.serviceName}`);
111
+ });
112
+ }
@@ -0,0 +1,165 @@
1
+ import process from 'process';
2
+ import {
3
+ DEFAULT_CLOUDFLARED_CERT,
4
+ DEFAULT_CLOUDFLARED_CONFIG,
5
+ DEFAULT_TUNNEL_NAME,
6
+ EXIT,
7
+ } from '../constants.js';
8
+ import { CliError } from '../errors.js';
9
+ import { inferDomainFromRedirect } from '../env-file.js';
10
+ import { loadHiveConfig, updateHiveConfig } from '../config.js';
11
+ import { validateDomain } from '../checks.js';
12
+ import { run } from '../exec.js';
13
+ import {
14
+ cloudflaredServiceStatus,
15
+ installCloudflaredService,
16
+ runTunnelForeground,
17
+ setupTunnel,
18
+ tunnelStatus,
19
+ } from '../tunnel.js';
20
+ import { section, success, fail } from '../output.js';
21
+ import {
22
+ loadValidatedEnv,
23
+ parseInteger,
24
+ requiredOrFallback,
25
+ resolveContext,
26
+ setRedirectUriForDomain,
27
+ } from '../core/context.js';
28
+
29
+ export function registerTunnelCommands(program) {
30
+ const tunnel = program.command('tunnel').description('Manage Cloudflare tunnel');
31
+
32
+ tunnel
33
+ .command('setup')
34
+ .description('Run full tunnel lifecycle setup')
35
+ .option('--env-file <path>', 'env file path')
36
+ .option('--domain <domain>', 'public domain')
37
+ .option('--tunnel-name <name>', 'tunnel name')
38
+ .option('--cloudflared-config-file <path>', 'cloudflared config file')
39
+ .option('--install-service', 'install cloudflared service', false)
40
+ .option('--yes', 'non-interactive mode', false)
41
+ .action(async (options) => {
42
+ section('Tunnel Setup');
43
+ const { config, envFile } = await resolveContext(options);
44
+ const { env, issues } = await loadValidatedEnv(envFile, { requireFile: true });
45
+ if (issues.length > 0) {
46
+ for (const issue of issues) fail(issue);
47
+ throw new CliError('Fix env file first (hive env check)', EXIT.FAIL);
48
+ }
49
+
50
+ const domain = requiredOrFallback(
51
+ options.domain,
52
+ inferDomainFromRedirect(env.DISCORD_REDIRECT_URI) || config.domain
53
+ );
54
+ if (!validateDomain(domain)) {
55
+ throw new CliError(`Invalid domain: ${domain}`);
56
+ }
57
+
58
+ const tunnelName = requiredOrFallback(options.tunnelName, config.tunnelName || DEFAULT_TUNNEL_NAME);
59
+ const cloudflaredConfigFile = requiredOrFallback(
60
+ options.cloudflaredConfigFile,
61
+ config.cloudflaredConfigFile || DEFAULT_CLOUDFLARED_CONFIG
62
+ );
63
+
64
+ const tunnelResult = await setupTunnel({
65
+ tunnelName,
66
+ domain,
67
+ configFile: cloudflaredConfigFile,
68
+ certPath: DEFAULT_CLOUDFLARED_CERT,
69
+ port: parseInteger(env.PORT, 'PORT'),
70
+ yjsPort: parseInteger(env.YJS_PORT, 'YJS_PORT'),
71
+ yes: Boolean(options.yes),
72
+ installService: Boolean(options.installService),
73
+ });
74
+
75
+ const nextEnv = await setRedirectUriForDomain({
76
+ envFile,
77
+ env,
78
+ domain,
79
+ yes: Boolean(options.yes),
80
+ });
81
+
82
+ await updateHiveConfig({
83
+ envFile,
84
+ domain,
85
+ tunnelName,
86
+ tunnelId: tunnelResult.tunnelId,
87
+ tunnelCredentialsFile: tunnelResult.credentialsFile,
88
+ cloudflaredConfigFile,
89
+ });
90
+
91
+ if (nextEnv.DISCORD_REDIRECT_URI !== env.DISCORD_REDIRECT_URI) {
92
+ success('Redirect URI synced for tunnel domain');
93
+ }
94
+ success('Tunnel setup complete');
95
+ });
96
+
97
+ tunnel
98
+ .command('status')
99
+ .description('Show tunnel status and config')
100
+ .option('--tunnel-name <name>', 'tunnel name')
101
+ .option('--cloudflared-config-file <path>', 'cloudflared config path')
102
+ .action(async (options) => {
103
+ const config = await loadHiveConfig();
104
+ const tunnelName = requiredOrFallback(options.tunnelName, config.tunnelName || DEFAULT_TUNNEL_NAME);
105
+ const cloudflaredConfigFile = requiredOrFallback(
106
+ options.cloudflaredConfigFile,
107
+ config.cloudflaredConfigFile || DEFAULT_CLOUDFLARED_CONFIG
108
+ );
109
+ const status = await tunnelStatus({ tunnelName, configFile: cloudflaredConfigFile });
110
+ section('Tunnel Status');
111
+ console.log(`Name: ${tunnelName}`);
112
+ console.log(`Tunnel ID: ${status.tunnel?.id || '(not found)'}`);
113
+ console.log(`Config file: ${status.configFile} ${status.configExists ? '' : '(missing)'}`);
114
+ if (config.domain) {
115
+ console.log(`Domain: ${config.domain}`);
116
+ }
117
+ const svc = await cloudflaredServiceStatus().catch(() => false);
118
+ console.log(`cloudflared service: ${svc ? 'active' : 'inactive or unknown'}`);
119
+ });
120
+
121
+ tunnel
122
+ .command('run')
123
+ .description('Run tunnel in foreground')
124
+ .option('--tunnel-name <name>', 'tunnel name')
125
+ .action(async (options) => {
126
+ const config = await loadHiveConfig();
127
+ const tunnelName = requiredOrFallback(options.tunnelName, config.tunnelName || DEFAULT_TUNNEL_NAME);
128
+ await runTunnelForeground({ tunnelName });
129
+ });
130
+
131
+ tunnel
132
+ .command('service-install')
133
+ .description('Install cloudflared as a system service')
134
+ .action(async () => {
135
+ section('Tunnel Service Install');
136
+ await installCloudflaredService();
137
+ success('cloudflared service installed');
138
+ });
139
+
140
+ tunnel
141
+ .command('service-status')
142
+ .description('Show cloudflared service status')
143
+ .action(async () => {
144
+ const active = await cloudflaredServiceStatus();
145
+ section('Tunnel Service Status');
146
+ console.log(active ? 'active' : 'inactive');
147
+ if (process.platform === 'darwin') {
148
+ const listing = await run('launchctl', ['list']).catch(() => ({ stdout: '' }));
149
+ const row = listing.stdout
150
+ .split('\n')
151
+ .find((line) => line.toLowerCase().includes('cloudflared'));
152
+ if (row) {
153
+ console.log('');
154
+ console.log(row.trim());
155
+ }
156
+ } else if (process.platform === 'linux') {
157
+ const status = await run('sudo', ['systemctl', 'status', 'cloudflared', '--no-pager', '--lines', '20'])
158
+ .catch(() => ({ stdout: '' }));
159
+ if (status.stdout) {
160
+ console.log('');
161
+ console.log(status.stdout);
162
+ }
163
+ }
164
+ });
165
+ }
@@ -0,0 +1,57 @@
1
+ import process from 'process';
2
+ import { Command, CommanderError } from 'commander';
3
+ import { EXIT } from '../constants.js';
4
+ import { CliError } from '../errors.js';
5
+ import { registerEnvCommands } from '../commands/env.js';
6
+ import { registerRootCommands } from '../commands/root.js';
7
+ import { registerServiceCommands } from '../commands/service.js';
8
+ import { registerTunnelCommands } from '../commands/tunnel.js';
9
+
10
+ export class HiveCliApp {
11
+ constructor() {
12
+ this.program = this.createProgram();
13
+ }
14
+
15
+ createProgram() {
16
+ const program = new Command();
17
+
18
+ program
19
+ .name('hive')
20
+ .description('Hive server operations CLI')
21
+ .showHelpAfterError()
22
+ .version('1.0.0');
23
+
24
+ registerRootCommands(program);
25
+ registerEnvCommands(program);
26
+ registerTunnelCommands(program);
27
+ registerServiceCommands(program);
28
+
29
+ return program;
30
+ }
31
+
32
+ async run(argv = process.argv) {
33
+ if ((argv?.length ?? 0) <= 2) {
34
+ this.program.outputHelp();
35
+ return EXIT.OK;
36
+ }
37
+
38
+ this.program.exitOverride();
39
+
40
+ try {
41
+ await this.program.parseAsync(argv);
42
+ return EXIT.OK;
43
+ } catch (err) {
44
+ if (err instanceof CommanderError) {
45
+ if (
46
+ err.code === 'commander.helpDisplayed'
47
+ || err.code === 'commander.help'
48
+ || err.message === '(outputHelp)'
49
+ ) {
50
+ return EXIT.OK;
51
+ }
52
+ throw new CliError(err.message, err.exitCode ?? EXIT.FAIL);
53
+ }
54
+ throw err;
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,110 @@
1
+ import { existsSync } from 'fs';
2
+ import { readFile } from 'fs/promises';
3
+ import { homedir } from 'os';
4
+ import { join } from 'path';
5
+ import prompts from 'prompts';
6
+ import { DEFAULT_ENV_FILE, EXIT } from '../constants.js';
7
+ import { CliError } from '../errors.js';
8
+ import { loadHiveConfig } from '../config.js';
9
+ import { loadEnvFile, normalizeEnv, validateEnvValues, writeEnvFile } from '../env-file.js';
10
+ import { success } from '../output.js';
11
+ import { getServiceDefaults } from '../service.js';
12
+
13
+ export function parseInteger(value, key) {
14
+ const parsed = parseInt(String(value ?? '').trim(), 10);
15
+ if (!Number.isInteger(parsed) || parsed <= 0) {
16
+ throw new CliError(`${key} must be a positive integer`);
17
+ }
18
+ return parsed;
19
+ }
20
+
21
+ export function requiredOrFallback(value, fallback) {
22
+ const trimmed = String(value ?? '').trim();
23
+ return trimmed || fallback;
24
+ }
25
+
26
+ export async function promptConfirm(message, yes = false, initial = true) {
27
+ if (yes) return true;
28
+ const answer = await prompts({
29
+ type: 'confirm',
30
+ name: 'ok',
31
+ message,
32
+ initial,
33
+ });
34
+ return Boolean(answer.ok);
35
+ }
36
+
37
+ export async function resolveContext(options = {}) {
38
+ const config = await loadHiveConfig();
39
+ const envFile = options.envFile || config.envFile || DEFAULT_ENV_FILE;
40
+ return { config, envFile };
41
+ }
42
+
43
+ export function resolveServiceConfig(config) {
44
+ const defaults = getServiceDefaults();
45
+ return {
46
+ servicePlatform: config.servicePlatform || defaults.servicePlatform,
47
+ serviceName: config.serviceName || defaults.serviceName,
48
+ };
49
+ }
50
+
51
+ export async function loadPackageMeta() {
52
+ const raw = await readFile(new URL('../../package.json', import.meta.url), 'utf-8');
53
+ const parsed = JSON.parse(raw);
54
+ const name = String(parsed?.name ?? '').trim();
55
+ const version = String(parsed?.version ?? '').trim() || 'unknown';
56
+ if (!name) {
57
+ throw new CliError('Could not resolve package name from package.json', EXIT.FAIL);
58
+ }
59
+ return { name, version };
60
+ }
61
+
62
+ export function isHiveServiceInstalled({ servicePlatform, serviceName }) {
63
+ if (servicePlatform === 'launchd') {
64
+ return existsSync(join(homedir(), 'Library', 'LaunchAgents', `${serviceName}.plist`));
65
+ }
66
+ return existsSync(`/etc/systemd/system/${serviceName}.service`);
67
+ }
68
+
69
+ export function normalizeLogsComponent(value) {
70
+ const component = String(value ?? 'hive').trim().toLowerCase();
71
+ if (component === 'hive' || component === 'tunnel' || component === 'both') {
72
+ return component;
73
+ }
74
+ throw new CliError(`Invalid logs component: ${value}. Use hive, tunnel, or both.`);
75
+ }
76
+
77
+ export function assertEnvFileExists(envFile) {
78
+ if (!existsSync(envFile)) {
79
+ throw new CliError(`Env file not found: ${envFile}. Run: hive env init`, EXIT.FAIL);
80
+ }
81
+ }
82
+
83
+ export async function loadValidatedEnv(envFile, { requireFile = true } = {}) {
84
+ if (requireFile) {
85
+ assertEnvFileExists(envFile);
86
+ }
87
+
88
+ const raw = await loadEnvFile(envFile);
89
+ const env = normalizeEnv(raw);
90
+ const issues = validateEnvValues(env);
91
+ return { env, issues };
92
+ }
93
+
94
+ export async function setRedirectUriForDomain({ envFile, env, domain, yes = false }) {
95
+ const expected = `https://${domain}/auth/callback`;
96
+ if (env.DISCORD_REDIRECT_URI === expected) return env;
97
+
98
+ const shouldUpdate = await promptConfirm(
99
+ `Set DISCORD_REDIRECT_URI to ${expected}?`,
100
+ yes,
101
+ true
102
+ );
103
+
104
+ if (!shouldUpdate) return env;
105
+
106
+ const next = { ...env, DISCORD_REDIRECT_URI: expected };
107
+ await writeEnvFile(envFile, next);
108
+ success(`Updated DISCORD_REDIRECT_URI -> ${expected}`);
109
+ return next;
110
+ }