nport 2.0.3 → 2.0.5
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/CHANGELOG.md +93 -0
- package/README.md +117 -16
- package/index.js +48 -626
- package/package.json +4 -4
- package/{analytics.js → src/analytics.js} +8 -2
- package/src/api.js +87 -0
- package/src/args.js +83 -0
- package/{bin-manager.js → src/bin-manager.js} +2 -1
- package/src/binary.js +88 -0
- package/src/config.js +58 -0
- package/src/lang.js +279 -0
- package/src/state.js +76 -0
- package/src/tunnel.js +116 -0
- package/src/ui.js +98 -0
- package/src/version.js +56 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nport",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.5",
|
|
4
4
|
"description": "Free & open source ngrok alternative - Tunnel HTTP/HTTPS connections via Cloudflare Edge with custom subdomains",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
}
|
|
64
64
|
],
|
|
65
65
|
"scripts": {
|
|
66
|
-
"postinstall": "node bin-manager.js",
|
|
66
|
+
"postinstall": "node src/bin-manager.js",
|
|
67
67
|
"start": "node index.js"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
@@ -73,9 +73,9 @@
|
|
|
73
73
|
},
|
|
74
74
|
"files": [
|
|
75
75
|
"index.js",
|
|
76
|
-
"
|
|
77
|
-
"bin-manager.js",
|
|
76
|
+
"src/",
|
|
78
77
|
"README.md",
|
|
78
|
+
"CHANGELOG.md",
|
|
79
79
|
"LICENSE"
|
|
80
80
|
],
|
|
81
81
|
"os": [
|
|
@@ -11,7 +11,7 @@ import path from "path";
|
|
|
11
11
|
import { fileURLToPath } from "url";
|
|
12
12
|
|
|
13
13
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
-
const __dirname = path.dirname(__filename);
|
|
14
|
+
const __dirname = path.dirname(path.dirname(__filename));
|
|
15
15
|
|
|
16
16
|
// Firebase/GA4 Configuration (from website/home.html)
|
|
17
17
|
// Full Firebase config for reference (if needed for future features)
|
|
@@ -36,7 +36,7 @@ const ANALYTICS_CONFIG = {
|
|
|
36
36
|
enabled: true, // Can be disabled by environment variable
|
|
37
37
|
debug: process.env.NPORT_DEBUG === "true",
|
|
38
38
|
timeout: 2000, // Don't block CLI for too long
|
|
39
|
-
userIdFile: path.join(os.homedir(), ".nport-
|
|
39
|
+
userIdFile: path.join(os.homedir(), ".nport", "analytics-id"),
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
// ============================================================================
|
|
@@ -92,6 +92,12 @@ class AnalyticsManager {
|
|
|
92
92
|
*/
|
|
93
93
|
async getUserId() {
|
|
94
94
|
try {
|
|
95
|
+
// Ensure .nport directory exists
|
|
96
|
+
const configDir = path.join(os.homedir(), ".nport");
|
|
97
|
+
if (!fs.existsSync(configDir)) {
|
|
98
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
99
|
+
}
|
|
100
|
+
|
|
95
101
|
// Try to read existing user ID
|
|
96
102
|
if (fs.existsSync(ANALYTICS_CONFIG.userIdFile)) {
|
|
97
103
|
const userId = fs.readFileSync(ANALYTICS_CONFIG.userIdFile, "utf8").trim();
|
package/src/api.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { CONFIG } from "./config.js";
|
|
4
|
+
import { state } from "./state.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* API Client
|
|
8
|
+
* Handles communication with the NPort backend service
|
|
9
|
+
*/
|
|
10
|
+
export class APIClient {
|
|
11
|
+
static async createTunnel(subdomain) {
|
|
12
|
+
try {
|
|
13
|
+
const { data } = await axios.post(CONFIG.BACKEND_URL, { subdomain });
|
|
14
|
+
|
|
15
|
+
if (!data.success) {
|
|
16
|
+
throw new Error(data.error || "Unknown error from backend");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
tunnelId: data.tunnelId,
|
|
21
|
+
tunnelToken: data.tunnelToken,
|
|
22
|
+
url: data.url,
|
|
23
|
+
};
|
|
24
|
+
} catch (error) {
|
|
25
|
+
throw this.handleError(error, subdomain);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static async deleteTunnel(subdomain, tunnelId) {
|
|
30
|
+
await axios.delete(CONFIG.BACKEND_URL, {
|
|
31
|
+
data: { subdomain, tunnelId },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static handleError(error, subdomain) {
|
|
36
|
+
if (error.response?.data?.error) {
|
|
37
|
+
const errorMsg = error.response.data.error;
|
|
38
|
+
|
|
39
|
+
// Check for subdomain in use (active tunnel)
|
|
40
|
+
if (
|
|
41
|
+
errorMsg.includes("SUBDOMAIN_IN_USE:") ||
|
|
42
|
+
errorMsg.includes("currently in use") ||
|
|
43
|
+
errorMsg.includes("already exists and is currently active")
|
|
44
|
+
) {
|
|
45
|
+
return new Error(
|
|
46
|
+
chalk.red(`✗ Subdomain "${subdomain}" is already in use!\n\n`) +
|
|
47
|
+
chalk.yellow(`💡 This subdomain is currently being used by another active tunnel.\n\n`) +
|
|
48
|
+
chalk.white(`Choose a different subdomain:\n`) +
|
|
49
|
+
chalk.gray(` 1. Add a suffix: `) +
|
|
50
|
+
chalk.cyan(`nport ${state.port || CONFIG.DEFAULT_PORT} -s ${subdomain}-2\n`) +
|
|
51
|
+
chalk.gray(` 2. Try a variation: `) +
|
|
52
|
+
chalk.cyan(`nport ${state.port || CONFIG.DEFAULT_PORT} -s my-${subdomain}\n`) +
|
|
53
|
+
chalk.gray(` 3. Use random name: `) +
|
|
54
|
+
chalk.cyan(`nport ${state.port || CONFIG.DEFAULT_PORT}\n`)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check for duplicate tunnel error (other Cloudflare errors)
|
|
59
|
+
if (
|
|
60
|
+
errorMsg.includes("already have a tunnel") ||
|
|
61
|
+
errorMsg.includes("[1013]")
|
|
62
|
+
) {
|
|
63
|
+
return new Error(
|
|
64
|
+
`Subdomain "${subdomain}" is already taken or in use.\n\n` +
|
|
65
|
+
chalk.yellow(`💡 Try one of these options:\n`) +
|
|
66
|
+
chalk.gray(` 1. Choose a different subdomain: `) +
|
|
67
|
+
chalk.cyan(`nport ${state.port || CONFIG.DEFAULT_PORT} -s ${subdomain}-v2\n`) +
|
|
68
|
+
chalk.gray(` 2. Use a random subdomain: `) +
|
|
69
|
+
chalk.cyan(`nport ${state.port || CONFIG.DEFAULT_PORT}\n`) +
|
|
70
|
+
chalk.gray(
|
|
71
|
+
` 3. Wait a few minutes and retry if you just stopped a tunnel with this name`
|
|
72
|
+
)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return new Error(`Backend Error: ${errorMsg}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (error.response) {
|
|
80
|
+
const errorMsg = JSON.stringify(error.response.data, null, 2);
|
|
81
|
+
return new Error(`Backend Error: ${errorMsg}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
package/src/args.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { CONFIG } from "./config.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Command Line Argument Parser
|
|
5
|
+
* Handles parsing of CLI arguments for port, subdomain, and language
|
|
6
|
+
*/
|
|
7
|
+
export class ArgumentParser {
|
|
8
|
+
static parse(argv) {
|
|
9
|
+
const port = this.parsePort(argv);
|
|
10
|
+
const subdomain = this.parseSubdomain(argv);
|
|
11
|
+
const language = this.parseLanguage(argv);
|
|
12
|
+
return { port, subdomain, language };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static parsePort(argv) {
|
|
16
|
+
const portArg = parseInt(argv[0]);
|
|
17
|
+
return portArg || CONFIG.DEFAULT_PORT;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static parseSubdomain(argv) {
|
|
21
|
+
// Try all subdomain formats
|
|
22
|
+
const formats = [
|
|
23
|
+
() => this.findFlagWithEquals(argv, "--subdomain="),
|
|
24
|
+
() => this.findFlagWithEquals(argv, "-s="),
|
|
25
|
+
() => this.findFlagWithValue(argv, "--subdomain"),
|
|
26
|
+
() => this.findFlagWithValue(argv, "-s"),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
for (const format of formats) {
|
|
30
|
+
const subdomain = format();
|
|
31
|
+
if (subdomain) return subdomain;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return this.generateRandomSubdomain();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static parseLanguage(argv) {
|
|
38
|
+
// Check if --language flag exists without value (to trigger prompt)
|
|
39
|
+
if (argv.includes('--language') || argv.includes('--lang') || argv.includes('-l')) {
|
|
40
|
+
const langIndex = argv.indexOf('--language') !== -1 ? argv.indexOf('--language') :
|
|
41
|
+
argv.indexOf('--lang') !== -1 ? argv.indexOf('--lang') :
|
|
42
|
+
argv.indexOf('-l');
|
|
43
|
+
|
|
44
|
+
// If flag is present but next arg is another flag or doesn't exist, return 'prompt'
|
|
45
|
+
const nextArg = argv[langIndex + 1];
|
|
46
|
+
if (!nextArg || nextArg.startsWith('-')) {
|
|
47
|
+
return 'prompt';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Try language flag formats: --language=en, --lang=en, -l en
|
|
52
|
+
const formats = [
|
|
53
|
+
() => this.findFlagWithEquals(argv, "--language="),
|
|
54
|
+
() => this.findFlagWithEquals(argv, "--lang="),
|
|
55
|
+
() => this.findFlagWithEquals(argv, "-l="),
|
|
56
|
+
() => this.findFlagWithValue(argv, "--language"),
|
|
57
|
+
() => this.findFlagWithValue(argv, "--lang"),
|
|
58
|
+
() => this.findFlagWithValue(argv, "-l"),
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
for (const format of formats) {
|
|
62
|
+
const language = format();
|
|
63
|
+
if (language) return language;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null; // No language specified
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static findFlagWithEquals(argv, flag) {
|
|
70
|
+
const arg = argv.find((a) => a.startsWith(flag));
|
|
71
|
+
return arg ? arg.split("=")[1] : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
static findFlagWithValue(argv, flag) {
|
|
75
|
+
const index = argv.indexOf(flag);
|
|
76
|
+
return index !== -1 && argv[index + 1] ? argv[index + 1] : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static generateRandomSubdomain() {
|
|
80
|
+
return `${CONFIG.SUBDOMAIN_PREFIX}${Math.floor(Math.random() * 10000)}`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
@@ -11,7 +11,7 @@ import { fileURLToPath } from "url";
|
|
|
11
11
|
|
|
12
12
|
// Fix for __dirname in ES modules
|
|
13
13
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
-
const __dirname = path.dirname(__filename);
|
|
14
|
+
const __dirname = path.dirname(path.dirname(__filename));
|
|
15
15
|
|
|
16
16
|
// Binary configuration
|
|
17
17
|
const BIN_DIR = path.join(__dirname, "bin");
|
|
@@ -376,3 +376,4 @@ async function main() {
|
|
|
376
376
|
if (process.argv[1] === __filename) {
|
|
377
377
|
main();
|
|
378
378
|
}
|
|
379
|
+
|
package/src/binary.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { LOG_PATTERNS } from "./config.js";
|
|
5
|
+
import { state } from "./state.js";
|
|
6
|
+
import { UI } from "./ui.js";
|
|
7
|
+
import { lang } from "./lang.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Binary Manager
|
|
11
|
+
* Handles cloudflared binary validation, spawning, and process management
|
|
12
|
+
*/
|
|
13
|
+
export class BinaryManager {
|
|
14
|
+
static validate(binaryPath) {
|
|
15
|
+
if (fs.existsSync(binaryPath)) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
console.error(
|
|
20
|
+
chalk.red(`\n❌ Error: Cloudflared binary not found at: ${binaryPath}`)
|
|
21
|
+
);
|
|
22
|
+
console.error(
|
|
23
|
+
chalk.yellow(
|
|
24
|
+
"👉 Please run 'npm install' again to download the binary.\n"
|
|
25
|
+
)
|
|
26
|
+
);
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static spawn(binaryPath, token, port) {
|
|
31
|
+
return spawn(binaryPath, [
|
|
32
|
+
"tunnel",
|
|
33
|
+
"run",
|
|
34
|
+
"--token",
|
|
35
|
+
token,
|
|
36
|
+
"--url",
|
|
37
|
+
`http://localhost:${port}`,
|
|
38
|
+
]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static attachHandlers(process, spinner = null) {
|
|
42
|
+
process.stderr.on("data", (chunk) => this.handleStderr(chunk));
|
|
43
|
+
process.on("error", (err) => this.handleError(err, spinner));
|
|
44
|
+
process.on("close", (code) => this.handleClose(code));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static handleStderr(chunk) {
|
|
48
|
+
const msg = chunk.toString();
|
|
49
|
+
|
|
50
|
+
// Skip harmless warnings
|
|
51
|
+
if (LOG_PATTERNS.IGNORE.some((pattern) => msg.includes(pattern))) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Show success messages with connection count
|
|
56
|
+
if (LOG_PATTERNS.SUCCESS.some((pattern) => msg.includes(pattern))) {
|
|
57
|
+
const count = state.incrementConnection();
|
|
58
|
+
|
|
59
|
+
if (count === 1) {
|
|
60
|
+
console.log(chalk.green(lang.t("connection1")));
|
|
61
|
+
} else if (count === 4) {
|
|
62
|
+
console.log(chalk.green(lang.t("connection2")));
|
|
63
|
+
// Display footer after tunnel is fully active
|
|
64
|
+
UI.displayFooter(state.updateInfo);
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Show critical errors only
|
|
70
|
+
if (LOG_PATTERNS.ERROR.some((pattern) => msg.includes(pattern))) {
|
|
71
|
+
console.error(chalk.red(`[Cloudflared] ${msg.trim()}`));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static handleError(err, spinner) {
|
|
76
|
+
if (spinner) {
|
|
77
|
+
spinner.fail("Failed to spawn cloudflared process.");
|
|
78
|
+
}
|
|
79
|
+
console.error(chalk.red(`Process Error: ${err.message}`));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static handleClose(code) {
|
|
83
|
+
if (code !== 0 && code !== null) {
|
|
84
|
+
console.log(chalk.red(`Tunnel process exited with code ${code}`));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
package/src/config.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(path.dirname(__filename));
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const packageJson = require("../package.json");
|
|
9
|
+
|
|
10
|
+
// Application constants
|
|
11
|
+
export const CONFIG = {
|
|
12
|
+
PACKAGE_NAME: packageJson.name,
|
|
13
|
+
CURRENT_VERSION: packageJson.version,
|
|
14
|
+
BACKEND_URL: "https://nport.tuanngocptn.workers.dev",
|
|
15
|
+
DEFAULT_PORT: 8080,
|
|
16
|
+
SUBDOMAIN_PREFIX: "user-",
|
|
17
|
+
TUNNEL_TIMEOUT_HOURS: 4,
|
|
18
|
+
UPDATE_CHECK_TIMEOUT: 3000,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Platform-specific configuration
|
|
22
|
+
export const PLATFORM = {
|
|
23
|
+
IS_WINDOWS: process.platform === "win32",
|
|
24
|
+
BIN_NAME: process.platform === "win32" ? "cloudflared.exe" : "cloudflared",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Paths
|
|
28
|
+
export const PATHS = {
|
|
29
|
+
BIN_DIR: path.join(__dirname, "bin"),
|
|
30
|
+
BIN_PATH: path.join(__dirname, "bin", PLATFORM.BIN_NAME),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Log patterns for filtering cloudflared output
|
|
34
|
+
export const LOG_PATTERNS = {
|
|
35
|
+
SUCCESS: ["Registered tunnel connection"],
|
|
36
|
+
ERROR: ["ERR", "error"],
|
|
37
|
+
IGNORE: [
|
|
38
|
+
"Cannot determine default origin certificate path",
|
|
39
|
+
"No file cert.pem",
|
|
40
|
+
"origincert option",
|
|
41
|
+
"TUNNEL_ORIGIN_CERT",
|
|
42
|
+
"context canceled",
|
|
43
|
+
"failed to run the datagram handler",
|
|
44
|
+
"failed to serve tunnel connection",
|
|
45
|
+
"Connection terminated",
|
|
46
|
+
"no more connections active and exiting",
|
|
47
|
+
"Serve tunnel error",
|
|
48
|
+
"accept stream listener encountered a failure",
|
|
49
|
+
"Retrying connection",
|
|
50
|
+
"icmp router terminated",
|
|
51
|
+
"use of closed network connection",
|
|
52
|
+
"Application error 0x0",
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Computed constants
|
|
57
|
+
export const TUNNEL_TIMEOUT_MS = CONFIG.TUNNEL_TIMEOUT_HOURS * 60 * 60 * 1000;
|
|
58
|
+
|
package/src/lang.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import readline from "readline";
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Language Translations
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
const TRANSLATIONS = {
|
|
11
|
+
en: {
|
|
12
|
+
// Header
|
|
13
|
+
header: "N P O R T ⚡️ Free & Open Source from Vietnam",
|
|
14
|
+
|
|
15
|
+
// Spinners
|
|
16
|
+
creatingTunnel: "Creating tunnel for port {port}...",
|
|
17
|
+
checkingUpdates: "Checking for updates...",
|
|
18
|
+
|
|
19
|
+
// Success messages
|
|
20
|
+
tunnelLive: "🚀 WE LIVE BABY!",
|
|
21
|
+
connection1: " ✔ [1/2] Connection established...",
|
|
22
|
+
connection2: " ✔ [2/2] Compression enabled...",
|
|
23
|
+
timeRemaining: "⏱️ Time: {hours}h remaining",
|
|
24
|
+
|
|
25
|
+
// Footer
|
|
26
|
+
footerTitle: "🔥 KEEP THE VIBE ALIVE?",
|
|
27
|
+
footerSubtitle: "(Made with ❤️ in Vietnam)",
|
|
28
|
+
dropStar: "⭐️ Drop a Star: ",
|
|
29
|
+
sendCoffee: "☕️ Buy Coffee: ",
|
|
30
|
+
newVersion: "🚨 NEW VERSION (v{version}) detected!",
|
|
31
|
+
updateCommand: "> npm install -g nport@latest",
|
|
32
|
+
|
|
33
|
+
// Cleanup
|
|
34
|
+
tunnelShutdown: "🛑 TUNNEL SHUTDOWN.",
|
|
35
|
+
cleaningUp: "Cleaning up... ",
|
|
36
|
+
cleanupDone: "Done.",
|
|
37
|
+
cleanupFailed: "Failed.",
|
|
38
|
+
subdomainReleased: "Subdomain... Released. 🗑️",
|
|
39
|
+
serverBusy: "(Server might be down or busy)",
|
|
40
|
+
|
|
41
|
+
// Goodbye
|
|
42
|
+
goodbyeTitle: "👋 BEFORE YOU GO...",
|
|
43
|
+
goodbyeMessage: "Thanks for using NPort!",
|
|
44
|
+
website: "🌐 Website: ",
|
|
45
|
+
author: "👤 Author: ",
|
|
46
|
+
changeLanguage: "🌍 Language: ",
|
|
47
|
+
changeLanguageHint: "nport --language",
|
|
48
|
+
|
|
49
|
+
// Version
|
|
50
|
+
versionTitle: "NPort v{version}",
|
|
51
|
+
versionSubtitle: "Free & open source ngrok alternative",
|
|
52
|
+
versionLatest: "✔ You're running the latest version!",
|
|
53
|
+
versionAvailable: "🚨 New version available: v{version}",
|
|
54
|
+
versionUpdate: "Update now: ",
|
|
55
|
+
learnMore: "Learn more: ",
|
|
56
|
+
|
|
57
|
+
// Language selection
|
|
58
|
+
languagePrompt: "\n🌍 Language Selection / Chọn ngôn ngữ\n",
|
|
59
|
+
languageQuestion: "Choose your language (1-2): ",
|
|
60
|
+
languageEnglish: "1. English",
|
|
61
|
+
languageVietnamese: "2. Tiếng Việt (Vietnamese)",
|
|
62
|
+
languageInvalid: "Invalid choice. Using English by default.",
|
|
63
|
+
languageSaved: "✔ Language preference saved!",
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
vi: {
|
|
67
|
+
// Header
|
|
68
|
+
header: "N P O R T ⚡️ Việt Nam Mãi Đỉnh ❤️",
|
|
69
|
+
|
|
70
|
+
// Spinners
|
|
71
|
+
creatingTunnel: "🛠️ Đang khởi động cổng {port}... Chuẩn bị bay nào!",
|
|
72
|
+
checkingUpdates: "🔍 Đang dò la bản cập nhật mới... Đợi tí sắp có quà!",
|
|
73
|
+
|
|
74
|
+
// Success messages
|
|
75
|
+
tunnelLive: "🚀 BẬT MODE TỐC HÀNH! ĐANG BAY RỒI NÈ!",
|
|
76
|
+
connection1: " ✔ [1/2] Đang cắm dây mạng vũ trụ...",
|
|
77
|
+
connection2: " ✔ [2/2] Đang bơm siêu nén khí tốc độ ánh sáng...",
|
|
78
|
+
timeRemaining: "⏱️ Tăng tốc thần sầu: Còn {hours}h để quẩy!",
|
|
79
|
+
|
|
80
|
+
// Footer
|
|
81
|
+
footerTitle: "🔥 LƯU DANH SỬ SÁCH! KHÔNG QUÊN STAR",
|
|
82
|
+
footerSubtitle: "(Made in Việt Nam, chuẩn không cần chỉnh! ❤️)",
|
|
83
|
+
dropStar: "⭐️ Thả Star: ",
|
|
84
|
+
sendCoffee: "☕️ Tặng Coffee: ",
|
|
85
|
+
newVersion: "🚀 BẢN MỚI (v{version}) vừa hạ cánh!",
|
|
86
|
+
updateCommand: "💡 Gõ liền: npm install -g nport@latest",
|
|
87
|
+
|
|
88
|
+
// Cleanup
|
|
89
|
+
tunnelShutdown: "🛑 Đã tới giờ 'chốt' deal rồi cả nhà ơi...",
|
|
90
|
+
cleaningUp: "Đang dọn dẹp chiến trường... 🧹",
|
|
91
|
+
cleanupDone: "Xịn xò! Đã dọn xong rồi nè.",
|
|
92
|
+
cleanupFailed: "Oằn trời, dọn không nổi!",
|
|
93
|
+
subdomainReleased: "Subdomain... Xí xoá! Tạm biệt nhé 🗑️✨",
|
|
94
|
+
serverBusy: "(Có thể server đang bận order trà sữa)",
|
|
95
|
+
|
|
96
|
+
// Goodbye
|
|
97
|
+
goodbyeTitle: "👋 GẶP LẠI BẠN Ở ĐƯỜNG BĂNG KHÁC...",
|
|
98
|
+
goodbyeMessage: "Cảm ơn đã quẩy NPort! Lần sau chơi tiếp nha 😘",
|
|
99
|
+
website: "🌐 Sân chơi chính: ",
|
|
100
|
+
author: "👤 Nhà tài trợ chương trình: ",
|
|
101
|
+
changeLanguage: "🌍 Đổi ngôn ngữ: ",
|
|
102
|
+
changeLanguageHint: "nport --language",
|
|
103
|
+
|
|
104
|
+
// Version
|
|
105
|
+
versionTitle: "NPort v{version}",
|
|
106
|
+
versionSubtitle: "Hơn cả Ngrok - Ma-de in Ziệt Nam",
|
|
107
|
+
versionLatest: "🎉 Chúc mừng! Đang cùng server với bản mới nhất!",
|
|
108
|
+
versionAvailable: "🌟 Vèo vèo: Có bản mới v{version} vừa cập bến!",
|
|
109
|
+
versionUpdate: "Update khẩn trương lẹ làng: ",
|
|
110
|
+
learnMore: "Khám phá thêm cho nóng: ",
|
|
111
|
+
|
|
112
|
+
// Language selection
|
|
113
|
+
languagePrompt: "\n🌍 Chọn lựa ngôn ngữ ngay bên dưới nào!\n",
|
|
114
|
+
languageQuestion: "Chớp lấy một lựa chọn nha (1-2): ",
|
|
115
|
+
languageEnglish: "1. English (Chuẩn quốc tế!)",
|
|
116
|
+
languageVietnamese: "2. Tiếng Việt (Đỉnh của chóp)",
|
|
117
|
+
languageInvalid: "Ơ hơ, chọn sai rồi! Mặc định Tiếng Việt luôn cho nóng.",
|
|
118
|
+
languageSaved: "🎯 Xong rồi! Lưu ngôn ngữ thành công!",
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// ============================================================================
|
|
123
|
+
// Language Manager
|
|
124
|
+
// ============================================================================
|
|
125
|
+
|
|
126
|
+
class LanguageManager {
|
|
127
|
+
constructor() {
|
|
128
|
+
this.currentLanguage = "en";
|
|
129
|
+
this.configDir = path.join(os.homedir(), ".nport");
|
|
130
|
+
this.configFile = path.join(this.configDir, "lang");
|
|
131
|
+
this.availableLanguages = ["en", "vi"];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get translation string with variable substitution
|
|
136
|
+
* @param {string} key - Translation key
|
|
137
|
+
* @param {object} vars - Variables to substitute
|
|
138
|
+
* @returns {string} Translated string
|
|
139
|
+
*/
|
|
140
|
+
t(key, vars = {}) {
|
|
141
|
+
const translations = TRANSLATIONS[this.currentLanguage] || TRANSLATIONS.en;
|
|
142
|
+
let text = translations[key] || TRANSLATIONS.en[key] || key;
|
|
143
|
+
|
|
144
|
+
// Replace variables like {port}, {version}, etc.
|
|
145
|
+
Object.keys(vars).forEach(varKey => {
|
|
146
|
+
text = text.replace(`{${varKey}}`, vars[varKey]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return text;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Load saved language preference
|
|
154
|
+
* @returns {string|null} Saved language code or null
|
|
155
|
+
*/
|
|
156
|
+
loadLanguagePreference() {
|
|
157
|
+
try {
|
|
158
|
+
if (fs.existsSync(this.configFile)) {
|
|
159
|
+
const lang = fs.readFileSync(this.configFile, "utf8").trim();
|
|
160
|
+
if (this.availableLanguages.includes(lang)) {
|
|
161
|
+
return lang;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
// Ignore errors, will prompt user
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Save language preference
|
|
172
|
+
* @param {string} lang - Language code to save
|
|
173
|
+
*/
|
|
174
|
+
saveLanguagePreference(lang) {
|
|
175
|
+
try {
|
|
176
|
+
// Ensure .nport directory exists
|
|
177
|
+
if (!fs.existsSync(this.configDir)) {
|
|
178
|
+
fs.mkdirSync(this.configDir, { recursive: true });
|
|
179
|
+
}
|
|
180
|
+
fs.writeFileSync(this.configFile, lang, "utf8");
|
|
181
|
+
} catch (error) {
|
|
182
|
+
// Silently fail if can't save
|
|
183
|
+
console.warn("Warning: Could not save language preference");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Set current language
|
|
189
|
+
* @param {string} lang - Language code
|
|
190
|
+
*/
|
|
191
|
+
setLanguage(lang) {
|
|
192
|
+
if (this.availableLanguages.includes(lang)) {
|
|
193
|
+
this.currentLanguage = lang;
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Get current language
|
|
201
|
+
* @returns {string} Current language code
|
|
202
|
+
*/
|
|
203
|
+
getLanguage() {
|
|
204
|
+
return this.currentLanguage;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Prompt user to select language
|
|
209
|
+
* @returns {Promise<string>} Selected language code
|
|
210
|
+
*/
|
|
211
|
+
async promptLanguageSelection() {
|
|
212
|
+
return new Promise((resolve) => {
|
|
213
|
+
const rl = readline.createInterface({
|
|
214
|
+
input: process.stdin,
|
|
215
|
+
output: process.stdout
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
console.log(this.t("languagePrompt"));
|
|
219
|
+
console.log(` ${this.t("languageEnglish")}`);
|
|
220
|
+
console.log(` ${this.t("languageVietnamese")}\n`);
|
|
221
|
+
|
|
222
|
+
rl.question(`${this.t("languageQuestion")}`, (answer) => {
|
|
223
|
+
rl.close();
|
|
224
|
+
|
|
225
|
+
const choice = answer.trim();
|
|
226
|
+
let selectedLang = "en";
|
|
227
|
+
|
|
228
|
+
if (choice === "1") {
|
|
229
|
+
selectedLang = "en";
|
|
230
|
+
} else if (choice === "2") {
|
|
231
|
+
selectedLang = "vi";
|
|
232
|
+
} else {
|
|
233
|
+
console.log(`\n${this.t("languageInvalid")}\n`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
this.setLanguage(selectedLang);
|
|
237
|
+
this.saveLanguagePreference(selectedLang);
|
|
238
|
+
console.log(`${this.t("languageSaved")}\n`);
|
|
239
|
+
|
|
240
|
+
resolve(selectedLang);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Initialize language - load from config or prompt user
|
|
247
|
+
* @param {string|null} cliLanguage - Language from CLI argument (or 'prompt' to force prompt)
|
|
248
|
+
* @returns {Promise<string>} Selected language code
|
|
249
|
+
*/
|
|
250
|
+
async initialize(cliLanguage = null) {
|
|
251
|
+
// Priority 1: CLI argument with value (e.g., --language en)
|
|
252
|
+
if (cliLanguage && cliLanguage !== 'prompt' && this.setLanguage(cliLanguage)) {
|
|
253
|
+
this.saveLanguagePreference(cliLanguage);
|
|
254
|
+
return cliLanguage;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Priority 2: Force prompt if --language flag without value
|
|
258
|
+
if (cliLanguage === 'prompt') {
|
|
259
|
+
return await this.promptLanguageSelection();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Priority 3: Saved preference
|
|
263
|
+
const savedLang = this.loadLanguagePreference();
|
|
264
|
+
if (savedLang) {
|
|
265
|
+
this.setLanguage(savedLang);
|
|
266
|
+
return savedLang;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Priority 4: Prompt user on first run
|
|
270
|
+
return await this.promptLanguageSelection();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ============================================================================
|
|
275
|
+
// Export singleton instance
|
|
276
|
+
// ============================================================================
|
|
277
|
+
|
|
278
|
+
export const lang = new LanguageManager();
|
|
279
|
+
|