@seip/blue-bird 1.1.4 → 1.1.6
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/.vscode/extensions.json +6 -6
- package/.vscode/settings.json +10 -10
- package/core/cli/doctor.js +294 -294
- package/core/cli/migrate.js +342 -342
- package/core/cli/nginx.js +138 -138
- package/core/cli/route.js +143 -143
- package/core/hash.js +201 -201
- package/core/queue.js +121 -121
- package/docker/docker-compose.mysql.yml +13 -3
- package/docker/docker-compose.sqlite.yml +69 -69
- package/docker-compose.yml +5 -3
- package/frontend/about.html +156 -156
- package/frontend/js/bluebird.d.ts +423 -423
- package/jsconfig.json +15 -15
- package/package.json +1 -1
package/.vscode/extensions.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
{
|
|
2
|
-
"recommendations": [
|
|
3
|
-
"Zignd.html-css-class-completion",
|
|
4
|
-
"pranaygp.vscode-css-peek"
|
|
5
|
-
]
|
|
6
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"recommendations": [
|
|
3
|
+
"Zignd.html-css-class-completion",
|
|
4
|
+
"pranaygp.vscode-css-peek"
|
|
5
|
+
]
|
|
6
|
+
}
|
package/.vscode/settings.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
{
|
|
2
|
-
"html-css-class-completion.includeGlobPattern": "frontend/css/**/*.css",
|
|
3
|
-
"html-css-class-completion.enableEmmetSupport": true,
|
|
4
|
-
"css.validate": true,
|
|
5
|
-
"editor.quickSuggestions": {
|
|
6
|
-
"other": true,
|
|
7
|
-
"comments": false,
|
|
8
|
-
"strings": true
|
|
9
|
-
}
|
|
10
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"html-css-class-completion.includeGlobPattern": "frontend/css/**/*.css",
|
|
3
|
+
"html-css-class-completion.enableEmmetSupport": true,
|
|
4
|
+
"css.validate": true,
|
|
5
|
+
"editor.quickSuggestions": {
|
|
6
|
+
"other": true,
|
|
7
|
+
"comments": false,
|
|
8
|
+
"strings": true
|
|
9
|
+
}
|
|
10
|
+
}
|
package/core/cli/doctor.js
CHANGED
|
@@ -1,294 +1,294 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import fs from "node:fs";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import net from "node:net";
|
|
6
|
-
import chalk from "chalk";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Parses .env file into key-value map.
|
|
10
|
-
* @returns {Record<string, string>}
|
|
11
|
-
*/
|
|
12
|
-
function parseEnv() {
|
|
13
|
-
const envPath = path.resolve(process.cwd(), ".env");
|
|
14
|
-
const env = {};
|
|
15
|
-
if (fs.existsSync(envPath)) {
|
|
16
|
-
const lines = fs.readFileSync(envPath, "utf-8").split("\n");
|
|
17
|
-
for (const line of lines) {
|
|
18
|
-
const trimmed = line.trim();
|
|
19
|
-
if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
|
|
20
|
-
const idx = trimmed.indexOf("=");
|
|
21
|
-
const key = trimmed.substring(0, idx).trim();
|
|
22
|
-
let val = trimmed.substring(idx + 1).trim();
|
|
23
|
-
if (
|
|
24
|
-
(val.startsWith('"') && val.endsWith('"')) ||
|
|
25
|
-
(val.startsWith("'") && val.endsWith("'"))
|
|
26
|
-
) {
|
|
27
|
-
val = val.slice(1, -1);
|
|
28
|
-
}
|
|
29
|
-
env[key] = val;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return env;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Checks if a TCP port is currently open for binding on localhost.
|
|
38
|
-
* @param {number} port
|
|
39
|
-
* @returns {Promise<boolean>} Resolves to true if available, false if in use.
|
|
40
|
-
*/
|
|
41
|
-
function isPortAvailable(port) {
|
|
42
|
-
return new Promise((resolve) => {
|
|
43
|
-
const server = net.createServer();
|
|
44
|
-
server.once("error", () => {
|
|
45
|
-
resolve(false);
|
|
46
|
-
});
|
|
47
|
-
server.once("listening", () => {
|
|
48
|
-
server.close(() => resolve(true));
|
|
49
|
-
});
|
|
50
|
-
server.listen(port, "127.0.0.1");
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Finds a representative static file in frontend/ directory.
|
|
56
|
-
* @returns {string|null} Relative path like 'css/bluebird.css' or 'index.html'
|
|
57
|
-
*/
|
|
58
|
-
function findSampleStaticAsset() {
|
|
59
|
-
const frontendDir = path.resolve(process.cwd(), "frontend");
|
|
60
|
-
if (!fs.existsSync(frontendDir)) return null;
|
|
61
|
-
|
|
62
|
-
const candidates = [
|
|
63
|
-
"css/bluebird.css",
|
|
64
|
-
"css/style.css",
|
|
65
|
-
"js/bluebird.js",
|
|
66
|
-
"js/index.js",
|
|
67
|
-
"index.html",
|
|
68
|
-
"favicon.ico",
|
|
69
|
-
];
|
|
70
|
-
|
|
71
|
-
for (const candidate of candidates) {
|
|
72
|
-
if (fs.existsSync(path.join(frontendDir, candidate))) {
|
|
73
|
-
return candidate;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
const files = fs.readdirSync(frontendDir, { recursive: true });
|
|
79
|
-
for (const f of files) {
|
|
80
|
-
const full = path.join(frontendDir, f);
|
|
81
|
-
if (fs.statSync(full).isFile()) {
|
|
82
|
-
return f.replace(/\\/g, "/");
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
} catch {
|
|
86
|
-
// fallback
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Formats file permissions mode (e.g. 0600, 0755).
|
|
94
|
-
* @param {string} filePath
|
|
95
|
-
* @returns {string}
|
|
96
|
-
*/
|
|
97
|
-
function getOctalPermissions(filePath) {
|
|
98
|
-
try {
|
|
99
|
-
const stats = fs.statSync(filePath);
|
|
100
|
-
return "0" + (stats.mode & 0o777).toString(8);
|
|
101
|
-
} catch {
|
|
102
|
-
return "unknown";
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Runs the Blue Bird Doctor diagnostic suite.
|
|
108
|
-
*/
|
|
109
|
-
export async function runDoctor() {
|
|
110
|
-
console.log(chalk.bold.cyan("============================================================="));
|
|
111
|
-
console.log(chalk.bold.cyan(" Blue Bird System & Security Health Diagnostic (Doctor)"));
|
|
112
|
-
console.log(chalk.bold.cyan("============================================================="));
|
|
113
|
-
console.log("");
|
|
114
|
-
|
|
115
|
-
const env = parseEnv();
|
|
116
|
-
const envPath = path.resolve(process.cwd(), ".env");
|
|
117
|
-
const projectDir = process.cwd();
|
|
118
|
-
const isLinux = process.platform === "linux";
|
|
119
|
-
const port = parseInt(env.PORT || "3000", 10);
|
|
120
|
-
let appUrl = (env.APP_URL || `http://localhost:${port}`).replace(/\/$/, "");
|
|
121
|
-
if (
|
|
122
|
-
(appUrl === "http://localhost" || appUrl === "http://127.0.0.1") &&
|
|
123
|
-
port !== 80
|
|
124
|
-
) {
|
|
125
|
-
appUrl = `http://localhost:${port}`;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
let issuesFound = 0;
|
|
129
|
-
let warningsFound = 0;
|
|
130
|
-
|
|
131
|
-
// -------------------------------------------------------------
|
|
132
|
-
// 1. Environment & Secrets Check (.env)
|
|
133
|
-
// -------------------------------------------------------------
|
|
134
|
-
console.log(chalk.bold("[1/5] Environment & Configuration (.env):"));
|
|
135
|
-
if (!fs.existsSync(envPath)) {
|
|
136
|
-
console.log(chalk.red(" [FAIL] .env file is missing in project root."));
|
|
137
|
-
console.log(chalk.gray(" Run 'npx blue-bird' or copy .env_example to .env."));
|
|
138
|
-
issuesFound++;
|
|
139
|
-
} else {
|
|
140
|
-
console.log(chalk.green(" [PASS] .env file exists."));
|
|
141
|
-
|
|
142
|
-
if (isLinux) {
|
|
143
|
-
const envMode = getOctalPermissions(envPath);
|
|
144
|
-
if (envMode !== "0600" && envMode !== "0400") {
|
|
145
|
-
console.log(chalk.yellow(` [WARN] .env permissions are '${envMode}'. Recommended mode is '0600' (chmod 600 .env).`));
|
|
146
|
-
warningsFound++;
|
|
147
|
-
} else {
|
|
148
|
-
console.log(chalk.green(` [PASS] .env file permissions are strictly isolated (${envMode}).`));
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (!env.JWT_SECRET || env.JWT_SECRET.length < 16) {
|
|
153
|
-
console.log(chalk.yellow(" [WARN] JWT_SECRET is missing or shorter than 16 characters."));
|
|
154
|
-
warningsFound++;
|
|
155
|
-
} else {
|
|
156
|
-
console.log(chalk.green(" [PASS] JWT_SECRET is configured."));
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
console.log("");
|
|
160
|
-
|
|
161
|
-
// -------------------------------------------------------------
|
|
162
|
-
// 2. Filesystem Hierarchy & Permissions (FHS)
|
|
163
|
-
// -------------------------------------------------------------
|
|
164
|
-
console.log(chalk.bold("[2/5] Filesystem Hierarchy Standard & Static Asset Structure:"));
|
|
165
|
-
if (isLinux) {
|
|
166
|
-
if (projectDir.startsWith("/home/")) {
|
|
167
|
-
console.log(chalk.yellow(" [WARN] Project is deployed inside '/home/user/'."));
|
|
168
|
-
console.log(chalk.gray(" Recommended FHS production standard is '/var/www/<project>' or '/srv/<project>'"));
|
|
169
|
-
console.log(chalk.gray(" to prevent path traversal restrictions for unprivileged containers (nginx UID 101)."));
|
|
170
|
-
warningsFound++;
|
|
171
|
-
} else {
|
|
172
|
-
console.log(chalk.green(" [PASS] Project directory follows recommended FHS location."));
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const frontendDir = path.resolve(process.cwd(), "frontend");
|
|
177
|
-
if (!fs.existsSync(frontendDir)) {
|
|
178
|
-
console.log(chalk.red(" [FAIL] 'frontend/' directory is missing."));
|
|
179
|
-
issuesFound++;
|
|
180
|
-
} else {
|
|
181
|
-
console.log(chalk.green(" [PASS] 'frontend/' directory exists."));
|
|
182
|
-
const sampleAsset = findSampleStaticAsset();
|
|
183
|
-
if (sampleAsset) {
|
|
184
|
-
console.log(chalk.green(` [PASS] Detected sample static asset: 'frontend/${sampleAsset}'`));
|
|
185
|
-
} else {
|
|
186
|
-
console.log(chalk.yellow(" [WARN] No static files found inside 'frontend/'."));
|
|
187
|
-
warningsFound++;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
console.log("");
|
|
191
|
-
|
|
192
|
-
// -------------------------------------------------------------
|
|
193
|
-
// 3. Port & Local Network Binding Check
|
|
194
|
-
// -------------------------------------------------------------
|
|
195
|
-
console.log(chalk.bold("[3/5] Port Availability & Binding:"));
|
|
196
|
-
const portFree = await isPortAvailable(port);
|
|
197
|
-
if (portFree) {
|
|
198
|
-
console.log(chalk.green(` [INFO] Port ${port} is currently free and available for binding.`));
|
|
199
|
-
} else {
|
|
200
|
-
console.log(chalk.cyan(` [INFO] Port ${port} is currently in use (application server or container is active).`));
|
|
201
|
-
}
|
|
202
|
-
console.log("");
|
|
203
|
-
|
|
204
|
-
// -------------------------------------------------------------
|
|
205
|
-
// 4. Live Smoke Test (API & Nginx Static Asset Delivery)
|
|
206
|
-
// -------------------------------------------------------------
|
|
207
|
-
console.log(chalk.bold("[4/5] Live HTTP Smoke Test (Health & Static Delivery):"));
|
|
208
|
-
const sampleAsset = findSampleStaticAsset();
|
|
209
|
-
const testUrls = [
|
|
210
|
-
{ name: "API Health Endpoint", url: `${appUrl}/api/health`, isApi: true },
|
|
211
|
-
];
|
|
212
|
-
if (sampleAsset) {
|
|
213
|
-
testUrls.push({
|
|
214
|
-
name: `Static Asset (/${sampleAsset})`,
|
|
215
|
-
url: `${appUrl}/${sampleAsset}`,
|
|
216
|
-
isApi: false,
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
let serverReachable = false;
|
|
221
|
-
|
|
222
|
-
for (const item of testUrls) {
|
|
223
|
-
try {
|
|
224
|
-
const controller = new AbortController();
|
|
225
|
-
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
|
226
|
-
const res = await fetch(item.url, { signal: controller.signal });
|
|
227
|
-
clearTimeout(timeoutId);
|
|
228
|
-
|
|
229
|
-
serverReachable = true;
|
|
230
|
-
|
|
231
|
-
if (res.status === 200) {
|
|
232
|
-
console.log(chalk.green(` [PASS] ${item.name} returned HTTP 200 OK.`));
|
|
233
|
-
} else if (res.status === 403) {
|
|
234
|
-
console.log(chalk.red(` [FAIL] ${item.name} returned HTTP 403 Forbidden.`));
|
|
235
|
-
console.log(chalk.yellow(" Cause: Unprivileged Nginx worker (UID 101) lacks path traversal (+x) or read (+r) permissions."));
|
|
236
|
-
console.log(chalk.yellow(" Remediation: Run the following commands on your host:"));
|
|
237
|
-
console.log(chalk.white(` chmod 755 ${projectDir}`));
|
|
238
|
-
console.log(chalk.white(" find frontend -type d -exec chmod 755 {} +"));
|
|
239
|
-
console.log(chalk.white(" find frontend -type f -exec chmod 644 {} +"));
|
|
240
|
-
issuesFound++;
|
|
241
|
-
} else if (res.status === 404) {
|
|
242
|
-
console.log(chalk.red(` [FAIL] ${item.name} returned HTTP 404 Not Found.`));
|
|
243
|
-
console.log(chalk.yellow(" Cause: File not found or Docker volume mount inode desynchronization."));
|
|
244
|
-
console.log(chalk.yellow(" Remediation: Recreate container volume mounts:"));
|
|
245
|
-
console.log(chalk.white(" npx blue-bird docker stop && npx blue-bird docker start prod"));
|
|
246
|
-
issuesFound++;
|
|
247
|
-
} else {
|
|
248
|
-
console.log(chalk.yellow(` [WARN] ${item.name} returned HTTP ${res.status}.`));
|
|
249
|
-
warningsFound++;
|
|
250
|
-
}
|
|
251
|
-
} catch (err) {
|
|
252
|
-
if (err.name === "AbortError") {
|
|
253
|
-
console.log(chalk.yellow(` [WARN] Request to ${item.name} (${item.url}) timed out after 3s.`));
|
|
254
|
-
} else {
|
|
255
|
-
console.log(chalk.gray(` [INFO] Cannot connect to ${item.name} at ${item.url} (${err.code || err.message}).`));
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
if (!serverReachable) {
|
|
261
|
-
console.log(chalk.gray(" [INFO] Application server is not running locally. Start it with:"));
|
|
262
|
-
console.log(chalk.gray(" Development: npm run dev"));
|
|
263
|
-
console.log(chalk.gray(" Production: npx blue-bird docker start prod"));
|
|
264
|
-
}
|
|
265
|
-
console.log("");
|
|
266
|
-
|
|
267
|
-
// -------------------------------------------------------------
|
|
268
|
-
// 5. Database & Cache Driver Status
|
|
269
|
-
// -------------------------------------------------------------
|
|
270
|
-
console.log(chalk.bold("[5/5] Database & Cache Architecture:"));
|
|
271
|
-
const dbType = (env.DB_TYPE || "sqlite").toLowerCase();
|
|
272
|
-
console.log(chalk.green(` [INFO] Database Type: ${dbType.toUpperCase()}`));
|
|
273
|
-
const cacheMode = (env.CACHE_MODE || "memory").toLowerCase();
|
|
274
|
-
console.log(chalk.green(` [INFO] Cache Mode: ${cacheMode.toUpperCase()}`));
|
|
275
|
-
console.log("");
|
|
276
|
-
|
|
277
|
-
// -------------------------------------------------------------
|
|
278
|
-
// Summary
|
|
279
|
-
// -------------------------------------------------------------
|
|
280
|
-
console.log(chalk.bold.cyan("============================================================="));
|
|
281
|
-
if (issuesFound === 0 && warningsFound === 0) {
|
|
282
|
-
console.log(chalk.bold.green(" Diagnostic Summary: All checks passed with zero issues!"));
|
|
283
|
-
} else if (issuesFound === 0) {
|
|
284
|
-
console.log(chalk.bold.yellow(` Diagnostic Summary: System is operational with ${warningsFound} warning(s).`));
|
|
285
|
-
} else {
|
|
286
|
-
console.log(chalk.bold.red(` Diagnostic Summary: Found ${issuesFound} error(s) and ${warningsFound} warning(s).`));
|
|
287
|
-
}
|
|
288
|
-
console.log(chalk.bold.cyan("============================================================="));
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// If executed directly from CLI
|
|
292
|
-
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("doctor.js") || process.argv[2] === "doctor") {
|
|
293
|
-
runDoctor();
|
|
294
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parses .env file into key-value map.
|
|
10
|
+
* @returns {Record<string, string>}
|
|
11
|
+
*/
|
|
12
|
+
function parseEnv() {
|
|
13
|
+
const envPath = path.resolve(process.cwd(), ".env");
|
|
14
|
+
const env = {};
|
|
15
|
+
if (fs.existsSync(envPath)) {
|
|
16
|
+
const lines = fs.readFileSync(envPath, "utf-8").split("\n");
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
const trimmed = line.trim();
|
|
19
|
+
if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
|
|
20
|
+
const idx = trimmed.indexOf("=");
|
|
21
|
+
const key = trimmed.substring(0, idx).trim();
|
|
22
|
+
let val = trimmed.substring(idx + 1).trim();
|
|
23
|
+
if (
|
|
24
|
+
(val.startsWith('"') && val.endsWith('"')) ||
|
|
25
|
+
(val.startsWith("'") && val.endsWith("'"))
|
|
26
|
+
) {
|
|
27
|
+
val = val.slice(1, -1);
|
|
28
|
+
}
|
|
29
|
+
env[key] = val;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return env;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Checks if a TCP port is currently open for binding on localhost.
|
|
38
|
+
* @param {number} port
|
|
39
|
+
* @returns {Promise<boolean>} Resolves to true if available, false if in use.
|
|
40
|
+
*/
|
|
41
|
+
function isPortAvailable(port) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const server = net.createServer();
|
|
44
|
+
server.once("error", () => {
|
|
45
|
+
resolve(false);
|
|
46
|
+
});
|
|
47
|
+
server.once("listening", () => {
|
|
48
|
+
server.close(() => resolve(true));
|
|
49
|
+
});
|
|
50
|
+
server.listen(port, "127.0.0.1");
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Finds a representative static file in frontend/ directory.
|
|
56
|
+
* @returns {string|null} Relative path like 'css/bluebird.css' or 'index.html'
|
|
57
|
+
*/
|
|
58
|
+
function findSampleStaticAsset() {
|
|
59
|
+
const frontendDir = path.resolve(process.cwd(), "frontend");
|
|
60
|
+
if (!fs.existsSync(frontendDir)) return null;
|
|
61
|
+
|
|
62
|
+
const candidates = [
|
|
63
|
+
"css/bluebird.css",
|
|
64
|
+
"css/style.css",
|
|
65
|
+
"js/bluebird.js",
|
|
66
|
+
"js/index.js",
|
|
67
|
+
"index.html",
|
|
68
|
+
"favicon.ico",
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
for (const candidate of candidates) {
|
|
72
|
+
if (fs.existsSync(path.join(frontendDir, candidate))) {
|
|
73
|
+
return candidate;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const files = fs.readdirSync(frontendDir, { recursive: true });
|
|
79
|
+
for (const f of files) {
|
|
80
|
+
const full = path.join(frontendDir, f);
|
|
81
|
+
if (fs.statSync(full).isFile()) {
|
|
82
|
+
return f.replace(/\\/g, "/");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// fallback
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Formats file permissions mode (e.g. 0600, 0755).
|
|
94
|
+
* @param {string} filePath
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
function getOctalPermissions(filePath) {
|
|
98
|
+
try {
|
|
99
|
+
const stats = fs.statSync(filePath);
|
|
100
|
+
return "0" + (stats.mode & 0o777).toString(8);
|
|
101
|
+
} catch {
|
|
102
|
+
return "unknown";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Runs the Blue Bird Doctor diagnostic suite.
|
|
108
|
+
*/
|
|
109
|
+
export async function runDoctor() {
|
|
110
|
+
console.log(chalk.bold.cyan("============================================================="));
|
|
111
|
+
console.log(chalk.bold.cyan(" Blue Bird System & Security Health Diagnostic (Doctor)"));
|
|
112
|
+
console.log(chalk.bold.cyan("============================================================="));
|
|
113
|
+
console.log("");
|
|
114
|
+
|
|
115
|
+
const env = parseEnv();
|
|
116
|
+
const envPath = path.resolve(process.cwd(), ".env");
|
|
117
|
+
const projectDir = process.cwd();
|
|
118
|
+
const isLinux = process.platform === "linux";
|
|
119
|
+
const port = parseInt(env.PORT || "3000", 10);
|
|
120
|
+
let appUrl = (env.APP_URL || `http://localhost:${port}`).replace(/\/$/, "");
|
|
121
|
+
if (
|
|
122
|
+
(appUrl === "http://localhost" || appUrl === "http://127.0.0.1") &&
|
|
123
|
+
port !== 80
|
|
124
|
+
) {
|
|
125
|
+
appUrl = `http://localhost:${port}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let issuesFound = 0;
|
|
129
|
+
let warningsFound = 0;
|
|
130
|
+
|
|
131
|
+
// -------------------------------------------------------------
|
|
132
|
+
// 1. Environment & Secrets Check (.env)
|
|
133
|
+
// -------------------------------------------------------------
|
|
134
|
+
console.log(chalk.bold("[1/5] Environment & Configuration (.env):"));
|
|
135
|
+
if (!fs.existsSync(envPath)) {
|
|
136
|
+
console.log(chalk.red(" [FAIL] .env file is missing in project root."));
|
|
137
|
+
console.log(chalk.gray(" Run 'npx blue-bird' or copy .env_example to .env."));
|
|
138
|
+
issuesFound++;
|
|
139
|
+
} else {
|
|
140
|
+
console.log(chalk.green(" [PASS] .env file exists."));
|
|
141
|
+
|
|
142
|
+
if (isLinux) {
|
|
143
|
+
const envMode = getOctalPermissions(envPath);
|
|
144
|
+
if (envMode !== "0600" && envMode !== "0400") {
|
|
145
|
+
console.log(chalk.yellow(` [WARN] .env permissions are '${envMode}'. Recommended mode is '0600' (chmod 600 .env).`));
|
|
146
|
+
warningsFound++;
|
|
147
|
+
} else {
|
|
148
|
+
console.log(chalk.green(` [PASS] .env file permissions are strictly isolated (${envMode}).`));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!env.JWT_SECRET || env.JWT_SECRET.length < 16) {
|
|
153
|
+
console.log(chalk.yellow(" [WARN] JWT_SECRET is missing or shorter than 16 characters."));
|
|
154
|
+
warningsFound++;
|
|
155
|
+
} else {
|
|
156
|
+
console.log(chalk.green(" [PASS] JWT_SECRET is configured."));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
console.log("");
|
|
160
|
+
|
|
161
|
+
// -------------------------------------------------------------
|
|
162
|
+
// 2. Filesystem Hierarchy & Permissions (FHS)
|
|
163
|
+
// -------------------------------------------------------------
|
|
164
|
+
console.log(chalk.bold("[2/5] Filesystem Hierarchy Standard & Static Asset Structure:"));
|
|
165
|
+
if (isLinux) {
|
|
166
|
+
if (projectDir.startsWith("/home/")) {
|
|
167
|
+
console.log(chalk.yellow(" [WARN] Project is deployed inside '/home/user/'."));
|
|
168
|
+
console.log(chalk.gray(" Recommended FHS production standard is '/var/www/<project>' or '/srv/<project>'"));
|
|
169
|
+
console.log(chalk.gray(" to prevent path traversal restrictions for unprivileged containers (nginx UID 101)."));
|
|
170
|
+
warningsFound++;
|
|
171
|
+
} else {
|
|
172
|
+
console.log(chalk.green(" [PASS] Project directory follows recommended FHS location."));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const frontendDir = path.resolve(process.cwd(), "frontend");
|
|
177
|
+
if (!fs.existsSync(frontendDir)) {
|
|
178
|
+
console.log(chalk.red(" [FAIL] 'frontend/' directory is missing."));
|
|
179
|
+
issuesFound++;
|
|
180
|
+
} else {
|
|
181
|
+
console.log(chalk.green(" [PASS] 'frontend/' directory exists."));
|
|
182
|
+
const sampleAsset = findSampleStaticAsset();
|
|
183
|
+
if (sampleAsset) {
|
|
184
|
+
console.log(chalk.green(` [PASS] Detected sample static asset: 'frontend/${sampleAsset}'`));
|
|
185
|
+
} else {
|
|
186
|
+
console.log(chalk.yellow(" [WARN] No static files found inside 'frontend/'."));
|
|
187
|
+
warningsFound++;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
console.log("");
|
|
191
|
+
|
|
192
|
+
// -------------------------------------------------------------
|
|
193
|
+
// 3. Port & Local Network Binding Check
|
|
194
|
+
// -------------------------------------------------------------
|
|
195
|
+
console.log(chalk.bold("[3/5] Port Availability & Binding:"));
|
|
196
|
+
const portFree = await isPortAvailable(port);
|
|
197
|
+
if (portFree) {
|
|
198
|
+
console.log(chalk.green(` [INFO] Port ${port} is currently free and available for binding.`));
|
|
199
|
+
} else {
|
|
200
|
+
console.log(chalk.cyan(` [INFO] Port ${port} is currently in use (application server or container is active).`));
|
|
201
|
+
}
|
|
202
|
+
console.log("");
|
|
203
|
+
|
|
204
|
+
// -------------------------------------------------------------
|
|
205
|
+
// 4. Live Smoke Test (API & Nginx Static Asset Delivery)
|
|
206
|
+
// -------------------------------------------------------------
|
|
207
|
+
console.log(chalk.bold("[4/5] Live HTTP Smoke Test (Health & Static Delivery):"));
|
|
208
|
+
const sampleAsset = findSampleStaticAsset();
|
|
209
|
+
const testUrls = [
|
|
210
|
+
{ name: "API Health Endpoint", url: `${appUrl}/api/health`, isApi: true },
|
|
211
|
+
];
|
|
212
|
+
if (sampleAsset) {
|
|
213
|
+
testUrls.push({
|
|
214
|
+
name: `Static Asset (/${sampleAsset})`,
|
|
215
|
+
url: `${appUrl}/${sampleAsset}`,
|
|
216
|
+
isApi: false,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let serverReachable = false;
|
|
221
|
+
|
|
222
|
+
for (const item of testUrls) {
|
|
223
|
+
try {
|
|
224
|
+
const controller = new AbortController();
|
|
225
|
+
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
|
226
|
+
const res = await fetch(item.url, { signal: controller.signal });
|
|
227
|
+
clearTimeout(timeoutId);
|
|
228
|
+
|
|
229
|
+
serverReachable = true;
|
|
230
|
+
|
|
231
|
+
if (res.status === 200) {
|
|
232
|
+
console.log(chalk.green(` [PASS] ${item.name} returned HTTP 200 OK.`));
|
|
233
|
+
} else if (res.status === 403) {
|
|
234
|
+
console.log(chalk.red(` [FAIL] ${item.name} returned HTTP 403 Forbidden.`));
|
|
235
|
+
console.log(chalk.yellow(" Cause: Unprivileged Nginx worker (UID 101) lacks path traversal (+x) or read (+r) permissions."));
|
|
236
|
+
console.log(chalk.yellow(" Remediation: Run the following commands on your host:"));
|
|
237
|
+
console.log(chalk.white(` chmod 755 ${projectDir}`));
|
|
238
|
+
console.log(chalk.white(" find frontend -type d -exec chmod 755 {} +"));
|
|
239
|
+
console.log(chalk.white(" find frontend -type f -exec chmod 644 {} +"));
|
|
240
|
+
issuesFound++;
|
|
241
|
+
} else if (res.status === 404) {
|
|
242
|
+
console.log(chalk.red(` [FAIL] ${item.name} returned HTTP 404 Not Found.`));
|
|
243
|
+
console.log(chalk.yellow(" Cause: File not found or Docker volume mount inode desynchronization."));
|
|
244
|
+
console.log(chalk.yellow(" Remediation: Recreate container volume mounts:"));
|
|
245
|
+
console.log(chalk.white(" npx blue-bird docker stop && npx blue-bird docker start prod"));
|
|
246
|
+
issuesFound++;
|
|
247
|
+
} else {
|
|
248
|
+
console.log(chalk.yellow(` [WARN] ${item.name} returned HTTP ${res.status}.`));
|
|
249
|
+
warningsFound++;
|
|
250
|
+
}
|
|
251
|
+
} catch (err) {
|
|
252
|
+
if (err.name === "AbortError") {
|
|
253
|
+
console.log(chalk.yellow(` [WARN] Request to ${item.name} (${item.url}) timed out after 3s.`));
|
|
254
|
+
} else {
|
|
255
|
+
console.log(chalk.gray(` [INFO] Cannot connect to ${item.name} at ${item.url} (${err.code || err.message}).`));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!serverReachable) {
|
|
261
|
+
console.log(chalk.gray(" [INFO] Application server is not running locally. Start it with:"));
|
|
262
|
+
console.log(chalk.gray(" Development: npm run dev"));
|
|
263
|
+
console.log(chalk.gray(" Production: npx blue-bird docker start prod"));
|
|
264
|
+
}
|
|
265
|
+
console.log("");
|
|
266
|
+
|
|
267
|
+
// -------------------------------------------------------------
|
|
268
|
+
// 5. Database & Cache Driver Status
|
|
269
|
+
// -------------------------------------------------------------
|
|
270
|
+
console.log(chalk.bold("[5/5] Database & Cache Architecture:"));
|
|
271
|
+
const dbType = (env.DB_TYPE || "sqlite").toLowerCase();
|
|
272
|
+
console.log(chalk.green(` [INFO] Database Type: ${dbType.toUpperCase()}`));
|
|
273
|
+
const cacheMode = (env.CACHE_MODE || "memory").toLowerCase();
|
|
274
|
+
console.log(chalk.green(` [INFO] Cache Mode: ${cacheMode.toUpperCase()}`));
|
|
275
|
+
console.log("");
|
|
276
|
+
|
|
277
|
+
// -------------------------------------------------------------
|
|
278
|
+
// Summary
|
|
279
|
+
// -------------------------------------------------------------
|
|
280
|
+
console.log(chalk.bold.cyan("============================================================="));
|
|
281
|
+
if (issuesFound === 0 && warningsFound === 0) {
|
|
282
|
+
console.log(chalk.bold.green(" Diagnostic Summary: All checks passed with zero issues!"));
|
|
283
|
+
} else if (issuesFound === 0) {
|
|
284
|
+
console.log(chalk.bold.yellow(` Diagnostic Summary: System is operational with ${warningsFound} warning(s).`));
|
|
285
|
+
} else {
|
|
286
|
+
console.log(chalk.bold.red(` Diagnostic Summary: Found ${issuesFound} error(s) and ${warningsFound} warning(s).`));
|
|
287
|
+
}
|
|
288
|
+
console.log(chalk.bold.cyan("============================================================="));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// If executed directly from CLI
|
|
292
|
+
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("doctor.js") || process.argv[2] === "doctor") {
|
|
293
|
+
runDoctor();
|
|
294
|
+
}
|