-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.js
More file actions
310 lines (285 loc) · 11.6 KB
/
Copy pathapp.js
File metadata and controls
310 lines (285 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
/**
* SPDX-License-Identifier: Apache-2.0
* BlockPilot Minecraft Panel
* 功能說明:此 JavaScript 檔案包含啟動或執行階段邏輯。
* 維護原則:重要分支、跨平台處理與錯誤回復流程必須保留註解。
*/
import fs from 'node:fs';
import crypto from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
const root = path.dirname(fileURLToPath(import.meta.url));
const envPath = path.join(root, '.env');
let generatedInitialCredentials = null;
/**
* 第一次啟動若沒有 .env,建立具隨機密碼且權限受限的本機設定。
* 這避免完整發行包或原始碼直接使用可預測的預設管理員密碼。
*/
function ensureInitialEnvironment() {
if (fs.existsSync(envPath)) return;
const username = 'admin';
const password = crypto.randomBytes(24).toString('base64url');
const content = [
'PORT=8787',
'HOST=0.0.0.0',
`ADMIN_USERNAME=${username}`,
`ADMIN_PASSWORD=${password}`,
'# 可選:前端與 API 分開部署時使用,多個來源以逗號分隔。',
'WEB_ORIGINS=http://localhost:8787,http://127.0.0.1:8787',
'',
].join('\n');
fs.writeFileSync(envPath, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
generatedInitialCredentials = { username, password };
}
process.chdir(root);
process.env.BLOCKPILOT_ROOT = root;
process.env.NODE_ENV = process.env.NODE_ENV || 'production';
const launcherConsoleLocale = process.env.BLOCKPILOT_LOCALE;
ensureInitialEnvironment();
dotenv.config({ path: envPath, override: true });
if (launcherConsoleLocale) process.env.BLOCKPILOT_LOCALE = launcherConsoleLocale;
const VERSION = '1.0.4';
const CONSOLE_LOCALE = (() => {
const value = process.env.BLOCKPILOT_LOCALE?.trim().toLowerCase();
if (value === 'zh-tw' || value === 'tw' || value === 'traditional') return 'zh-TW';
if (value === 'zh-cn' || value === 'zh' || value === 'cn' || value === 'simplified') return 'zh-CN';
return 'en';
})();
const MESSAGES = {
en: {
frontendReady: 'Frontend interface is ready',
rebuildWeb: 'An interface update was detected. Rebuilding; please wait…',
firstBuild: 'First startup is building the frontend interface; please wait…',
prebuiltMissing: 'The frontend build for this version was not found. Run scripts/install-systemd.sh again to complete the update.',
viteMissing: 'Frontend build program not found',
sourceMissing: 'Frontend source files not found',
buildFailed: 'Frontend build failed',
buildOutputMissing: 'The frontend build completed, but apps\web\dist\index.html was not found',
buildComplete: 'Frontend build completed',
uncaught: 'Uncaught error',
promise: 'Promise rejection',
firstEnv: 'A .env file was created for first startup. Save the administrator credentials below and change the password after signing in.',
initialAdminUser: 'Initial administrator username',
initialAdminPassword: 'Initial administrator password',
loadingCore: 'Loading BlockPilot core modules…',
runtime: 'Runtime environment',
workingDirectory: 'Working directory',
started: 'BlockPilot Minecraft Panel started',
panelUrl: 'Panel URL',
apiHealth: 'API health check',
lanUrl: 'LAN URL',
devPort: 'Port 5173 is for development mode and is not required for standard startup',
stop: 'Press Ctrl+C to stop this program',
startupFailed: 'Panel startup failed',
unknown: 'unknown',
},
'zh-TW': {
frontendReady: '前端介面已就緒',
rebuildWeb: '偵測到介面更新,正在重新建置,請稍候…',
firstBuild: '首次啟動正在建置前端介面,請稍候…',
prebuiltMissing: '找不到目前版本的前端建置檔。請重新執行 scripts/install-systemd.sh 完成更新。',
viteMissing: '找不到前端建置程式',
sourceMissing: '找不到前端原始檔',
buildFailed: '前端建置失敗',
buildOutputMissing: '前端建置程序已完成,但找不到 apps\web\dist\index.html',
buildComplete: '前端介面建置完成',
uncaught: '未捕捉錯誤',
promise: 'Promise 錯誤',
firstEnv: '首次啟動已建立 .env,請保存下列管理員帳密並於登入後變更密碼。',
initialAdminUser: '初始管理員帳號',
initialAdminPassword: '初始管理員密碼',
loadingCore: '正在載入 BlockPilot 核心模組…',
runtime: '執行環境',
workingDirectory: '工作目錄',
started: 'BlockPilot Minecraft Panel 已啟動',
panelUrl: '面板網址',
apiHealth: 'API 檢查',
lanUrl: '區域網路',
devPort: '5173 為開發模式連接埠,標準啟動不需使用',
stop: '關閉本程式請按 Ctrl+C',
startupFailed: '面板啟動失敗',
unknown: '未知',
},
'zh-CN': {
frontendReady: '前端界面已就绪',
rebuildWeb: '检测到界面更新,正在重新构建,请稍候…',
firstBuild: '首次启动正在构建前端界面,请稍候…',
prebuiltMissing: '找不到当前版本的前端构建文件。请重新运行 scripts/install-systemd.sh 完成更新。',
viteMissing: '找不到前端构建程序',
sourceMissing: '找不到前端源文件',
buildFailed: '前端构建失败',
buildOutputMissing: '前端构建程序已完成,但找不到 apps\web\dist\index.html',
buildComplete: '前端界面构建完成',
uncaught: '未捕获错误',
promise: 'Promise 错误',
firstEnv: '首次启动已创建 .env,请保存以下管理员账号密码,并在登录后更改密码。',
initialAdminUser: '初始管理员账号',
initialAdminPassword: '初始管理员密码',
loadingCore: '正在加载 BlockPilot 核心模块…',
runtime: '运行环境',
workingDirectory: '工作目录',
started: 'BlockPilot Minecraft Panel 已启动',
panelUrl: '面板网址',
apiHealth: 'API 检查',
lanUrl: '局域网',
devPort: '5173 是开发模式端口,标准启动无需使用',
stop: '关闭本程序请按 Ctrl+C',
startupFailed: '面板启动失败',
unknown: '未知',
},
};
const message = MESSAGES[CONSOLE_LOCALE];
const WEB_BUILD_REVISION = VERSION;
const PORT = Number(process.env.PORT || 8787);
const COLOR = process.stdout.isTTY && process.env.NO_COLOR !== '1';
const ansi = {
reset: COLOR ? '\x1b[0m' : '',
bold: COLOR ? '\x1b[1m' : '',
cyan: COLOR ? '\x1b[96m' : '',
green: COLOR ? '\x1b[92m' : '',
yellow: COLOR ? '\x1b[93m' : '',
red: COLOR ? '\x1b[91m' : '',
gray: COLOR ? '\x1b[90m' : '',
white: COLOR ? '\x1b[97m' : '',
};
function time() {
return new Intl.DateTimeFormat(CONSOLE_LOCALE, {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(new Date());
}
function log(level, message) {
const colors = { INFO: ansi.green, WARN: ansi.yellow, ERROR: ansi.red };
const levelColor = colors[level] || ansi.white;
console.log(`${ansi.gray}[${time()}]${ansi.reset}${levelColor}[${level}]${ansi.reset} ${message}`);
}
function getLanAddresses() {
const result = [];
for (const items of Object.values(os.networkInterfaces())) {
for (const item of items || []) {
if (item.family === 'IPv4' && !item.internal) result.push(item.address);
}
}
return [...new Set(result)];
}
function printBanner() {
const logo = String.raw`
____ _ _ ____ _ _ _
| __ )| | ___ ___| | _| _ \(_) | ___ | |_
| _ \| |/ _ \ / __| |/ / |_) | | |/ _ \| __|
| |_) | | (_) | (__| <| __/| | | (_) | |_
|____/|_|\___/ \___|_|\_\_| |_|_|\___/ \__|
`;
console.clear();
console.log(`${ansi.cyan}${ansi.bold}${logo}${ansi.reset}`);
console.log(`${ansi.white}+ Copyright (C) 2026 BlockPilot Minecraft Panel${ansi.reset}`);
console.log(`${ansi.white}+ Version ${VERSION}${ansi.reset}`);
console.log('');
}
function webIndexPaths() {
return [
path.join(root, 'web', 'index.html'),
path.join(root, 'apps', 'web', 'dist', 'index.html'),
];
}
function webBuildStampPaths() {
return [
path.join(root, 'web', '.blockpilot-build'),
path.join(root, 'apps', 'web', 'dist', '.blockpilot-build'),
];
}
function sourceWebBuildStampPath() {
return path.join(root, 'apps', 'web', 'dist', '.blockpilot-build');
}
function webIsBuilt() {
return webIndexPaths().some((file) => fs.existsSync(file));
}
function webBuildIsCurrent() {
if (!webIsBuilt()) return false;
return webBuildStampPaths().some((file) => {
try { return fs.readFileSync(file, 'utf8').trim() === WEB_BUILD_REVISION; }
catch { return false; }
});
}
function buildWebIfNeeded() {
const forceBuild = process.env.BLOCKPILOT_REBUILD_WEB === '1';
const prebuiltService = process.env.BLOCKPILOT_PREBUILT === '1';
if (webBuildIsCurrent() && !forceBuild) {
log('INFO', message.frontendReady);
return;
}
// systemd 安裝會在啟用服務前完成建置;正式服務不應在唯讀環境執行 npm 或 Vite。
if (prebuiltService) {
throw new Error(message.prebuiltMissing);
}
const webRoot = path.join(root, 'apps', 'web');
const viteEntry = path.join(root, 'node_modules', 'vite', 'bin', 'vite.js');
if (!fs.existsSync(viteEntry)) {
throw new Error(`${message.viteMissing}: ${viteEntry}`);
}
if (!fs.existsSync(path.join(webRoot, 'index.html'))) {
throw new Error(`${message.sourceMissing}: ${webRoot}`);
}
log('WARN', forceBuild || webIsBuilt() ? message.rebuildWeb : message.firstBuild);
const result = spawnSync(process.execPath, [viteEntry, 'build'], {
cwd: webRoot,
env: { ...process.env, NODE_ENV: 'production' },
encoding: 'utf8',
windowsHide: true,
maxBuffer: 16 * 1024 * 1024,
});
if (result.error) throw result.error;
if (result.status !== 0) {
const details = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
throw new Error(`${message.buildFailed} (code ${result.status ?? message.unknown})${details ? `\n${details}` : ''}`);
}
if (!webIsBuilt()) {
throw new Error(message.buildOutputMissing);
}
fs.writeFileSync(sourceWebBuildStampPath(), `${WEB_BUILD_REVISION}\n`, 'utf8');
log('INFO', message.buildComplete);
}
process.on('uncaughtException', (error) => {
log('ERROR', `${message.uncaught}: ${error?.stack || error}`);
});
process.on('unhandledRejection', (error) => {
log('ERROR', `${message.promise}: ${error?.stack || error}`);
});
printBanner();
if (generatedInitialCredentials) {
log('WARN', message.firstEnv);
log('INFO', `${message.initialAdminUser}: ${generatedInitialCredentials.username}`);
log('INFO', `${message.initialAdminPassword}: ${generatedInitialCredentials.password}`);
}
log('INFO', message.loadingCore);
log('INFO', `${message.runtime}: Node.js ${process.version} / ${process.platform} ${process.arch}`);
log('INFO', `${message.workingDirectory}: ${root}`);
const releaseApi = new URL('./app/api/dist/index.js', import.meta.url);
const sourceApi = new URL('./apps/api/dist/index.js', import.meta.url);
try {
buildWebIfNeeded();
try {
await import(releaseApi.href);
} catch (error) {
if (error?.code !== 'ERR_MODULE_NOT_FOUND') throw error;
await import(sourceApi.href);
}
log('INFO', '==============================================');
log('INFO', message.started);
log('INFO', `${message.panelUrl}: http://127.0.0.1:${PORT}`);
log('INFO', `${message.apiHealth}: http://127.0.0.1:${PORT}/api/health`);
for (const address of getLanAddresses()) {
log('INFO', `${message.lanUrl}: http://${address}:${PORT}`);
}
log('INFO', message.devPort);
log('INFO', message.stop);
log('INFO', '==============================================');
} catch (error) {
log('ERROR', `${message.startupFailed}: ${error?.stack || error}`);
process.exitCode = 1;
}