@fyresmith/hive-server 4.0.0 → 5.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/README.md +20 -17
- package/assets/plugin/hive/main.js +16384 -0
- package/assets/plugin/hive/manifest.json +9 -0
- package/assets/plugin/hive/styles.css +1040 -0
- package/assets/template-vault/.obsidian/app.json +1 -0
- package/assets/template-vault/.obsidian/appearance.json +1 -0
- package/assets/template-vault/.obsidian/core-plugins.json +33 -0
- package/assets/template-vault/.obsidian/graph.json +22 -0
- package/assets/template-vault/.obsidian/workspace.json +206 -0
- package/assets/template-vault/Welcome.md +5 -0
- package/cli/commands/env.js +4 -4
- package/cli/commands/managed.js +22 -17
- package/cli/commands/root.js +48 -2
- package/cli/commands/tunnel.js +1 -16
- package/cli/constants.js +1 -13
- package/cli/core/context.js +3 -22
- package/cli/env-file.js +15 -33
- package/cli/flows/doctor.js +1 -6
- package/cli/flows/setup.js +106 -33
- package/cli/tunnel.js +1 -4
- package/index.js +92 -39
- package/lib/accountState.js +189 -0
- package/lib/authTokens.js +75 -0
- package/lib/bundleBuilder.js +169 -0
- package/lib/dashboardAuth.js +80 -0
- package/lib/managedState.js +262 -55
- package/lib/setupOrchestrator.js +76 -0
- package/lib/yjsServer.js +12 -11
- package/package.json +3 -2
- package/routes/auth.js +403 -78
- package/routes/dashboard.js +590 -0
- package/routes/managed.js +0 -163
package/cli/env-file.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import dotenv from 'dotenv';
|
|
2
2
|
import prompts from 'prompts';
|
|
3
|
+
import { randomBytes } from 'crypto';
|
|
3
4
|
import { access, mkdir, readFile, writeFile } from 'fs/promises';
|
|
4
5
|
import { existsSync } from 'fs';
|
|
5
6
|
import { dirname } from 'path';
|
|
@@ -57,37 +58,19 @@ export async function writeEnvFile(envFile, values) {
|
|
|
57
58
|
await writeFile(envFile, serializeEnv(normalizeEnv(values)), 'utf-8');
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
export function
|
|
61
|
-
if (!redirectUri) return null;
|
|
62
|
-
try {
|
|
63
|
-
const u = new URL(redirectUri);
|
|
64
|
-
return u.host;
|
|
65
|
-
} catch {
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function validateEnvValues(values) {
|
|
61
|
+
export function validateEnvValues(values, { requireVaultPath = true } = {}) {
|
|
71
62
|
const issues = [];
|
|
72
|
-
|
|
63
|
+
const requiredKeys = requireVaultPath
|
|
64
|
+
? REQUIRED_ENV_KEYS
|
|
65
|
+
: REQUIRED_ENV_KEYS.filter((key) => key !== 'VAULT_PATH');
|
|
66
|
+
for (const key of requiredKeys) {
|
|
73
67
|
if (!String(values[key] ?? '').trim()) {
|
|
74
68
|
issues.push(`Missing ${key}`);
|
|
75
69
|
}
|
|
76
70
|
}
|
|
77
71
|
|
|
78
72
|
const port = parseInt(values.PORT ?? '', 10);
|
|
79
|
-
const yjsPort = parseInt(values.YJS_PORT ?? '', 10);
|
|
80
73
|
if (!Number.isInteger(port) || port <= 0) issues.push('PORT must be a positive integer');
|
|
81
|
-
if (!Number.isInteger(yjsPort) || yjsPort <= 0) issues.push('YJS_PORT must be a positive integer');
|
|
82
|
-
|
|
83
|
-
try {
|
|
84
|
-
const uri = new URL(values.DISCORD_REDIRECT_URI ?? '');
|
|
85
|
-
if (!/^https?:$/.test(uri.protocol)) {
|
|
86
|
-
issues.push('DISCORD_REDIRECT_URI must use http or https');
|
|
87
|
-
}
|
|
88
|
-
} catch {
|
|
89
|
-
issues.push('DISCORD_REDIRECT_URI must be a valid URL');
|
|
90
|
-
}
|
|
91
74
|
|
|
92
75
|
return issues;
|
|
93
76
|
}
|
|
@@ -103,7 +86,14 @@ export async function ensureVaultPathReadable(pathValue) {
|
|
|
103
86
|
}
|
|
104
87
|
|
|
105
88
|
export async function promptForEnv({ envFile, existing, yes = false, preset = {} }) {
|
|
106
|
-
const
|
|
89
|
+
const base = { ...existing, ...preset };
|
|
90
|
+
|
|
91
|
+
// Auto-generate secrets if not already set — never prompt for these
|
|
92
|
+
if (!String(base.JWT_SECRET ?? '').trim()) {
|
|
93
|
+
base.JWT_SECRET = randomBytes(32).toString('hex');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const current = normalizeEnv(base);
|
|
107
97
|
|
|
108
98
|
if (yes) {
|
|
109
99
|
await writeEnvFile(envFile, current);
|
|
@@ -111,21 +101,13 @@ export async function promptForEnv({ envFile, existing, yes = false, preset = {}
|
|
|
111
101
|
}
|
|
112
102
|
|
|
113
103
|
const questions = [
|
|
114
|
-
{ name: 'DISCORD_CLIENT_ID', message: 'Discord Client ID' },
|
|
115
|
-
{ name: 'DISCORD_CLIENT_SECRET', message: 'Discord Client Secret', secret: true },
|
|
116
|
-
{ name: 'DISCORD_GUILD_ID', message: 'Discord Guild ID' },
|
|
117
|
-
{ name: 'OWNER_DISCORD_ID', message: 'Managed vault owner Discord ID' },
|
|
118
|
-
{ name: 'JWT_SECRET', message: 'JWT secret', secret: true },
|
|
119
|
-
{ name: 'VAULT_PATH', message: 'Vault absolute path' },
|
|
120
104
|
{ name: 'PORT', message: 'HTTP port' },
|
|
121
|
-
{ name: 'YJS_PORT', message: 'Yjs WS port' },
|
|
122
|
-
{ name: 'DISCORD_REDIRECT_URI', message: 'Discord redirect URI' },
|
|
123
105
|
];
|
|
124
106
|
|
|
125
107
|
const answers = {};
|
|
126
108
|
for (const q of questions) {
|
|
127
109
|
const response = await prompts({
|
|
128
|
-
type:
|
|
110
|
+
type: 'text',
|
|
129
111
|
name: 'value',
|
|
130
112
|
message: q.message,
|
|
131
113
|
initial: current[q.name] ?? '',
|
package/cli/flows/doctor.js
CHANGED
|
@@ -64,16 +64,11 @@ export async function runDoctorChecks({ envFile, includeCloudflared = true }) {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
if (env.VAULT_PATH
|
|
67
|
+
if (env.VAULT_PATH) {
|
|
68
68
|
try {
|
|
69
69
|
const managedState = await loadManagedState(env.VAULT_PATH);
|
|
70
70
|
if (!managedState) {
|
|
71
71
|
info('Managed state not initialized yet');
|
|
72
|
-
} else if (managedState.ownerDiscordId !== env.OWNER_DISCORD_ID) {
|
|
73
|
-
fail(
|
|
74
|
-
`Managed owner mismatch: state=${managedState.ownerDiscordId} env=${env.OWNER_DISCORD_ID}`,
|
|
75
|
-
);
|
|
76
|
-
failures += 1;
|
|
77
72
|
} else {
|
|
78
73
|
success(`Managed state OK (vaultId ${managedState.vaultId})`);
|
|
79
74
|
}
|
package/cli/flows/setup.js
CHANGED
|
@@ -5,11 +5,11 @@ import {
|
|
|
5
5
|
DEFAULT_CLOUDFLARED_CONFIG,
|
|
6
6
|
DEFAULT_TUNNEL_NAME,
|
|
7
7
|
EXIT,
|
|
8
|
+
HIVE_HOME,
|
|
8
9
|
HIVE_CONFIG_FILE,
|
|
9
|
-
LEGACY_ENV_FILE,
|
|
10
10
|
} from '../constants.js';
|
|
11
11
|
import { CliError } from '../errors.js';
|
|
12
|
-
import {
|
|
12
|
+
import { loadEnvFile, normalizeEnv, promptForEnv, validateEnvValues, writeEnvFile } from '../env-file.js';
|
|
13
13
|
import { validateDomain } from '../checks.js';
|
|
14
14
|
import { updateHiveConfig } from '../config.js';
|
|
15
15
|
import { installHiveService } from '../service.js';
|
|
@@ -20,9 +20,11 @@ import {
|
|
|
20
20
|
promptConfirm,
|
|
21
21
|
requiredOrFallback,
|
|
22
22
|
resolveContext,
|
|
23
|
-
setRedirectUriForDomain,
|
|
24
23
|
} from '../core/context.js';
|
|
25
24
|
import { runDoctorChecks } from './doctor.js';
|
|
25
|
+
import { createVaultAtParent, initializeOwnerManagedVault } from '../../lib/setupOrchestrator.js';
|
|
26
|
+
import { loadManagedState } from '../../lib/managedState.js';
|
|
27
|
+
import { join } from 'path';
|
|
26
28
|
|
|
27
29
|
export async function runSetupWizard(options) {
|
|
28
30
|
const yes = Boolean(options.yes);
|
|
@@ -31,25 +33,10 @@ export async function runSetupWizard(options) {
|
|
|
31
33
|
|
|
32
34
|
const { config, envFile } = await resolveContext(options);
|
|
33
35
|
let nextConfig = { ...config, envFile };
|
|
34
|
-
let importedLegacy = false;
|
|
35
|
-
|
|
36
|
-
if (!existsSync(HIVE_CONFIG_FILE) && existsSync(LEGACY_ENV_FILE)) {
|
|
37
|
-
const shouldImportLegacy = await promptConfirm(
|
|
38
|
-
`Import existing legacy env from ${LEGACY_ENV_FILE}?`,
|
|
39
|
-
yes,
|
|
40
|
-
true
|
|
41
|
-
);
|
|
42
|
-
if (shouldImportLegacy) {
|
|
43
|
-
const legacyValues = await loadEnvFile(LEGACY_ENV_FILE);
|
|
44
|
-
await writeEnvFile(envFile, legacyValues);
|
|
45
|
-
importedLegacy = true;
|
|
46
|
-
success(`Imported legacy env into ${envFile}`);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
36
|
|
|
50
37
|
const envExists = existsSync(envFile);
|
|
51
38
|
let envValues;
|
|
52
|
-
if (!envExists
|
|
39
|
+
if (!envExists) {
|
|
53
40
|
info(`Initializing env file at ${envFile}`);
|
|
54
41
|
const existing = await loadEnvFile(envFile);
|
|
55
42
|
envValues = await promptForEnv({ envFile, existing, yes });
|
|
@@ -63,39 +50,121 @@ export async function runSetupWizard(options) {
|
|
|
63
50
|
}
|
|
64
51
|
}
|
|
65
52
|
|
|
53
|
+
// Prompt for vault display name and owner account credentials
|
|
54
|
+
let vaultName = '';
|
|
55
|
+
let vaultParentPath = '';
|
|
56
|
+
let ownerEmail = '';
|
|
57
|
+
let ownerDisplayName = '';
|
|
58
|
+
let ownerPassword = '';
|
|
59
|
+
if (!yes) {
|
|
60
|
+
const nameResp = await prompts({
|
|
61
|
+
type: 'text',
|
|
62
|
+
name: 'name',
|
|
63
|
+
message: 'Vault display name (e.g. "Team Vault")',
|
|
64
|
+
validate: (v) => String(v).trim().length > 0 || 'Vault name is required',
|
|
65
|
+
});
|
|
66
|
+
vaultName = String(nameResp.name ?? '').trim();
|
|
67
|
+
|
|
68
|
+
if (!String(envValues.VAULT_PATH ?? '').trim()) {
|
|
69
|
+
const parentResp = await prompts({
|
|
70
|
+
type: 'text',
|
|
71
|
+
name: 'path',
|
|
72
|
+
message: 'Choose folder location for the generated vault (parent directory)',
|
|
73
|
+
validate: (v) => String(v).trim().length > 0 || 'Parent folder path is required',
|
|
74
|
+
});
|
|
75
|
+
vaultParentPath = String(parentResp.path ?? '').trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const acctResp = await prompts([
|
|
79
|
+
{ type: 'text', name: 'email', message: 'Owner email (for dashboard login)' },
|
|
80
|
+
{ type: 'text', name: 'displayName', message: 'Owner display name' },
|
|
81
|
+
{ type: 'password', name: 'password', message: 'Owner password' },
|
|
82
|
+
]);
|
|
83
|
+
ownerEmail = String(acctResp.email ?? '').trim();
|
|
84
|
+
ownerDisplayName = String(acctResp.displayName ?? '').trim() || 'Owner';
|
|
85
|
+
ownerPassword = String(acctResp.password ?? '');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const existingVaultPath = String(envValues.VAULT_PATH ?? '').trim();
|
|
89
|
+
if (!existingVaultPath) {
|
|
90
|
+
if (yes) {
|
|
91
|
+
vaultName = vaultName || 'Hive Vault';
|
|
92
|
+
vaultParentPath = vaultParentPath || join(HIVE_HOME, 'vaults');
|
|
93
|
+
}
|
|
94
|
+
if (vaultName && vaultParentPath) {
|
|
95
|
+
const generatedVaultPath = await createVaultAtParent({
|
|
96
|
+
parentPath: vaultParentPath,
|
|
97
|
+
vaultName,
|
|
98
|
+
});
|
|
99
|
+
envValues = { ...envValues, VAULT_PATH: generatedVaultPath };
|
|
100
|
+
await writeEnvFile(envFile, envValues);
|
|
101
|
+
success(`Generated vault at ${generatedVaultPath}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
66
105
|
const envIssues = validateEnvValues(envValues);
|
|
67
106
|
if (envIssues.length > 0) {
|
|
68
107
|
for (const issue of envIssues) fail(issue);
|
|
69
108
|
throw new CliError('Env configuration is invalid', EXIT.FAIL);
|
|
70
109
|
}
|
|
71
110
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
111
|
+
// Initialize owner account + managed vault when interactive setup provides credentials.
|
|
112
|
+
if (vaultName && ownerEmail && ownerPassword) {
|
|
113
|
+
const existingState = await loadManagedState(String(envValues.VAULT_PATH));
|
|
114
|
+
if (existingState) {
|
|
115
|
+
info('Managed vault already initialized; skipping owner and vault initialization.');
|
|
116
|
+
} else {
|
|
117
|
+
await initializeOwnerManagedVault({
|
|
118
|
+
vaultPath: envValues.VAULT_PATH,
|
|
119
|
+
vaultName,
|
|
120
|
+
ownerEmail,
|
|
121
|
+
ownerDisplayName,
|
|
122
|
+
ownerPassword,
|
|
123
|
+
});
|
|
124
|
+
success('Owner account created');
|
|
125
|
+
success(`Vault initialized: ${vaultName}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Prompt for public server URL (used for invite claim redirects)
|
|
130
|
+
let hiveServerUrl = requiredOrFallback(
|
|
131
|
+
options.domain ? `https://${options.domain}` : '',
|
|
132
|
+
envValues.HIVE_SERVER_URL || (nextConfig.domain ? `https://${nextConfig.domain}` : ''),
|
|
75
133
|
);
|
|
76
134
|
|
|
77
135
|
if (!yes) {
|
|
78
136
|
const response = await prompts({
|
|
79
137
|
type: 'text',
|
|
80
|
-
name: '
|
|
81
|
-
message: 'Public
|
|
82
|
-
initial:
|
|
138
|
+
name: 'url',
|
|
139
|
+
message: 'Public URL for Hive server (optional, used for invite links)',
|
|
140
|
+
initial: hiveServerUrl || '',
|
|
83
141
|
});
|
|
84
|
-
if (response.
|
|
85
|
-
|
|
142
|
+
if (response.url !== undefined) {
|
|
143
|
+
hiveServerUrl = String(response.url).trim();
|
|
86
144
|
}
|
|
87
145
|
}
|
|
88
146
|
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
147
|
+
if (hiveServerUrl) {
|
|
148
|
+
const updated = { ...envValues, HIVE_SERVER_URL: hiveServerUrl };
|
|
149
|
+
await writeEnvFile(envFile, updated);
|
|
150
|
+
envValues = updated;
|
|
151
|
+
|
|
152
|
+
// Derive domain for Cloudflare Tunnel config
|
|
153
|
+
let domain = nextConfig.domain;
|
|
154
|
+
try {
|
|
155
|
+
domain = new URL(hiveServerUrl).hostname;
|
|
156
|
+
} catch {
|
|
157
|
+
// ignore parse error
|
|
158
|
+
}
|
|
92
159
|
|
|
93
|
-
|
|
160
|
+
if (domain && validateDomain(domain)) {
|
|
161
|
+
nextConfig = { ...nextConfig, domain };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
94
164
|
|
|
95
165
|
const shouldSetupTunnel = await promptConfirm('Configure Cloudflare Tunnel now?', yes, true);
|
|
96
166
|
if (shouldSetupTunnel) {
|
|
97
167
|
const port = parseInteger(envValues.PORT, 'PORT');
|
|
98
|
-
const yjsPort = parseInteger(envValues.YJS_PORT, 'YJS_PORT');
|
|
99
168
|
const tunnelName = requiredOrFallback(options.tunnelName, nextConfig.tunnelName || DEFAULT_TUNNEL_NAME);
|
|
100
169
|
const cloudflaredConfigFile = requiredOrFallback(
|
|
101
170
|
options.cloudflaredConfigFile,
|
|
@@ -103,13 +172,17 @@ export async function runSetupWizard(options) {
|
|
|
103
172
|
);
|
|
104
173
|
const tunnelService = await promptConfirm('Install cloudflared as a service?', yes, true);
|
|
105
174
|
|
|
175
|
+
const domain = nextConfig.domain;
|
|
176
|
+
if (!domain || !validateDomain(domain)) {
|
|
177
|
+
throw new CliError(`Cannot configure tunnel: invalid or missing domain (${domain})`);
|
|
178
|
+
}
|
|
179
|
+
|
|
106
180
|
const tunnelResult = await setupTunnel({
|
|
107
181
|
tunnelName,
|
|
108
182
|
domain,
|
|
109
183
|
configFile: cloudflaredConfigFile,
|
|
110
184
|
certPath: DEFAULT_CLOUDFLARED_CERT,
|
|
111
185
|
port,
|
|
112
|
-
yjsPort,
|
|
113
186
|
yes,
|
|
114
187
|
installService: tunnelService,
|
|
115
188
|
});
|
package/cli/tunnel.js
CHANGED
|
@@ -138,10 +138,9 @@ export async function writeCloudflaredConfig({
|
|
|
138
138
|
credentialsFile,
|
|
139
139
|
domain,
|
|
140
140
|
port,
|
|
141
|
-
yjsPort,
|
|
142
141
|
}) {
|
|
143
142
|
await mkdir(dirname(configFile), { recursive: true });
|
|
144
|
-
const yaml = `tunnel: ${tunnelId}\ncredentials-file: ${credentialsFile}\n\ningress:\n - hostname: ${domain}\n
|
|
143
|
+
const yaml = `tunnel: ${tunnelId}\ncredentials-file: ${credentialsFile}\n\ningress:\n - hostname: ${domain}\n service: http://localhost:${port}\n\n - service: http_status:404\n`;
|
|
145
144
|
await writeFile(configFile, yaml, 'utf-8');
|
|
146
145
|
return yaml;
|
|
147
146
|
}
|
|
@@ -358,7 +357,6 @@ export async function setupTunnel({
|
|
|
358
357
|
configFile,
|
|
359
358
|
certPath,
|
|
360
359
|
port,
|
|
361
|
-
yjsPort,
|
|
362
360
|
yes = false,
|
|
363
361
|
installService = false,
|
|
364
362
|
}) {
|
|
@@ -379,7 +377,6 @@ export async function setupTunnel({
|
|
|
379
377
|
credentialsFile,
|
|
380
378
|
domain,
|
|
381
379
|
port,
|
|
382
|
-
yjsPort,
|
|
383
380
|
});
|
|
384
381
|
|
|
385
382
|
info(`Ensuring DNS route for ${domain}`);
|
package/index.js
CHANGED
|
@@ -1,26 +1,20 @@
|
|
|
1
1
|
import dotenv from 'dotenv';
|
|
2
2
|
import { createServer } from 'http';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
3
4
|
import { fileURLToPath } from 'url';
|
|
4
5
|
import express from 'express';
|
|
5
6
|
import { Server } from 'socket.io';
|
|
6
7
|
import authRoutes from './routes/auth.js';
|
|
7
|
-
import
|
|
8
|
+
import dashboardRoutes from './routes/dashboard.js';
|
|
8
9
|
import * as vault from './lib/vaultManager.js';
|
|
9
10
|
import { attachHandlers } from './lib/socketHandler.js';
|
|
10
11
|
import { startYjsServer, getActiveRooms, forceCloseRoom, getRoomStatus } from './lib/yjsServer.js';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
'DISCORD_CLIENT_SECRET',
|
|
18
|
-
'DISCORD_REDIRECT_URI',
|
|
19
|
-
'DISCORD_GUILD_ID',
|
|
20
|
-
'OWNER_DISCORD_ID',
|
|
21
|
-
];
|
|
22
|
-
|
|
23
|
-
function validateEnv() {
|
|
12
|
+
import { loadManagedState } from './lib/managedState.js';
|
|
13
|
+
import { DEFAULT_ENV_FILE } from './cli/constants.js';
|
|
14
|
+
|
|
15
|
+
const REQUIRED = ['JWT_SECRET'];
|
|
16
|
+
|
|
17
|
+
function validateEnv({ allowSetupMode = false } = {}) {
|
|
24
18
|
for (const key of REQUIRED) {
|
|
25
19
|
if (!process.env[key]) {
|
|
26
20
|
throw new Error(`[startup] Missing required env var: ${key}`);
|
|
@@ -28,24 +22,31 @@ function validateEnv() {
|
|
|
28
22
|
}
|
|
29
23
|
|
|
30
24
|
const port = parseInt(process.env.PORT ?? '3000', 10);
|
|
31
|
-
const yjsPort = parseInt(process.env.YJS_PORT ?? '3001', 10);
|
|
32
25
|
if (!Number.isInteger(port) || port <= 0) {
|
|
33
26
|
throw new Error('[startup] PORT must be a positive integer');
|
|
34
27
|
}
|
|
35
|
-
|
|
36
|
-
|
|
28
|
+
|
|
29
|
+
// Ensure packaged onboarding assets are available on startup.
|
|
30
|
+
const assetsRoot = fileURLToPath(new URL('./assets', import.meta.url));
|
|
31
|
+
if (!existsSync(assetsRoot)) {
|
|
32
|
+
throw new Error('[startup] Missing packaged assets directory');
|
|
37
33
|
}
|
|
38
34
|
|
|
39
|
-
|
|
35
|
+
const hasVaultPath = Boolean(String(process.env.VAULT_PATH ?? '').trim());
|
|
36
|
+
if (!hasVaultPath && !allowSetupMode) {
|
|
37
|
+
throw new Error('[startup] Missing required env var: VAULT_PATH');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { port, hasVaultPath };
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* Start Hive server runtime.
|
|
44
45
|
*
|
|
45
|
-
* @param {{ envFile?: string, quiet?: boolean }} [options]
|
|
46
|
+
* @param {{ envFile?: string, quiet?: boolean, allowSetupMode?: boolean }} [options]
|
|
46
47
|
*/
|
|
47
48
|
export async function startHiveServer(options = {}) {
|
|
48
|
-
const { envFile, quiet = false } = options;
|
|
49
|
+
const { envFile, quiet = false, allowSetupMode = false } = options;
|
|
49
50
|
|
|
50
51
|
dotenv.config(
|
|
51
52
|
envFile
|
|
@@ -53,11 +54,17 @@ export async function startHiveServer(options = {}) {
|
|
|
53
54
|
: undefined
|
|
54
55
|
);
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
process.env.HIVE_ENV_FILE = envFile || process.env.HIVE_ENV_FILE || DEFAULT_ENV_FILE;
|
|
58
|
+
|
|
59
|
+
const { port, hasVaultPath } = validateEnv({ allowSetupMode });
|
|
60
|
+
if (hasVaultPath) {
|
|
61
|
+
await loadManagedState(process.env.VAULT_PATH);
|
|
62
|
+
}
|
|
58
63
|
|
|
59
64
|
const app = express();
|
|
60
65
|
const httpServer = createServer(app);
|
|
66
|
+
app.locals.hiveEnvFile = process.env.HIVE_ENV_FILE;
|
|
67
|
+
app.locals.activateRealtime = null;
|
|
61
68
|
|
|
62
69
|
const io = new Server(httpServer, {
|
|
63
70
|
cors: {
|
|
@@ -68,9 +75,28 @@ export async function startHiveServer(options = {}) {
|
|
|
68
75
|
|
|
69
76
|
app.use(express.json());
|
|
70
77
|
|
|
78
|
+
// Allow desktop plugin fetch calls (Authorization header triggers preflight).
|
|
79
|
+
app.use((req, res, next) => {
|
|
80
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
81
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
|
82
|
+
res.setHeader('Access-Control-Allow-Headers', 'Authorization,Content-Type');
|
|
83
|
+
if (req.method === 'OPTIONS') {
|
|
84
|
+
res.sendStatus(204);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
next();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const setupRequired = (req, res, next) => {
|
|
91
|
+
if (!String(process.env.VAULT_PATH ?? '').trim()) {
|
|
92
|
+
return res.status(503).json({ ok: false, error: 'Setup required: configure vault path first.' });
|
|
93
|
+
}
|
|
94
|
+
next();
|
|
95
|
+
};
|
|
96
|
+
|
|
71
97
|
// Auth routes
|
|
72
|
-
app.use('/auth', authRoutes);
|
|
73
|
-
app.use('/
|
|
98
|
+
app.use('/auth', setupRequired, authRoutes);
|
|
99
|
+
app.use('/dashboard', dashboardRoutes);
|
|
74
100
|
|
|
75
101
|
// Health check
|
|
76
102
|
app.get('/health', (req, res) => res.json({ ok: true, ts: Date.now() }));
|
|
@@ -84,23 +110,46 @@ export async function startHiveServer(options = {}) {
|
|
|
84
110
|
});
|
|
85
111
|
}
|
|
86
112
|
|
|
87
|
-
|
|
88
|
-
|
|
113
|
+
let realtimeActive = false;
|
|
114
|
+
let yjsWss = null;
|
|
115
|
+
const activateRealtime = async () => {
|
|
116
|
+
if (realtimeActive) return;
|
|
117
|
+
const vaultPath = String(process.env.VAULT_PATH ?? '').trim();
|
|
118
|
+
if (!vaultPath) return;
|
|
119
|
+
await loadManagedState(vaultPath);
|
|
120
|
+
attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCloseRoom);
|
|
121
|
+
yjsWss = startYjsServer(httpServer, broadcastFileUpdated);
|
|
122
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
123
|
+
const { pathname } = new URL(req.url, 'http://localhost');
|
|
124
|
+
if (pathname.startsWith('/yjs')) {
|
|
125
|
+
yjsWss.handleUpgrade(req, socket, head, (ws) => {
|
|
126
|
+
yjsWss.emit('connection', ws, req);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
// Socket.IO handles /socket.io upgrades automatically via its own listener
|
|
130
|
+
});
|
|
89
131
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
132
|
+
vault.initWatcher((relPath, event) => {
|
|
133
|
+
const docName = encodeURIComponent(relPath);
|
|
134
|
+
if (getActiveRooms().has(docName)) {
|
|
135
|
+
if (!quiet) {
|
|
136
|
+
console.log(`[chokidar] Ignoring external change to active room: ${relPath}`);
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
94
140
|
if (!quiet) {
|
|
95
|
-
console.log(`[chokidar]
|
|
141
|
+
console.log(`[chokidar] External ${event}: ${relPath}`);
|
|
96
142
|
}
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
143
|
+
io.emit('external-update', { relPath, event });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
realtimeActive = true;
|
|
147
|
+
};
|
|
148
|
+
app.locals.activateRealtime = activateRealtime;
|
|
149
|
+
|
|
150
|
+
if (hasVaultPath) {
|
|
151
|
+
await activateRealtime();
|
|
152
|
+
}
|
|
104
153
|
|
|
105
154
|
await new Promise((resolve, reject) => {
|
|
106
155
|
httpServer.once('error', reject);
|
|
@@ -109,7 +158,11 @@ export async function startHiveServer(options = {}) {
|
|
|
109
158
|
|
|
110
159
|
if (!quiet) {
|
|
111
160
|
console.log(`[server] Hive server listening on port ${port}`);
|
|
112
|
-
|
|
161
|
+
if (String(process.env.VAULT_PATH ?? '').trim()) {
|
|
162
|
+
console.log(`[server] Vault: ${process.env.VAULT_PATH}`);
|
|
163
|
+
} else {
|
|
164
|
+
console.log('[server] Setup mode: VAULT_PATH not configured yet');
|
|
165
|
+
}
|
|
113
166
|
}
|
|
114
167
|
|
|
115
168
|
return {
|