nport 2.0.4 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nport",
3
- "version": "2.0.4",
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
- "analytics.js",
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-analytics"),
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
+