@wanosoft/wanolink 1.0.1

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/bin-manager.js ADDED
@@ -0,0 +1,426 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import https from "https";
4
+ import os from "os";
5
+ import { execSync } from "child_process";
6
+ import { fileURLToPath } from "url";
7
+
8
+ // ============================================================================
9
+ // Configuration & Constants
10
+ // ============================================================================
11
+
12
+ // Fix for __dirname in ES modules
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+
16
+ // Binary configuration
17
+ const BIN_DIR = path.join(__dirname, "bin");
18
+ const BINARY_NAME = "cloudflared";
19
+ const COMPRESSED_SUFFIX = ".tgz";
20
+ const TEMP_ARCHIVE_NAME = "cloudflared.tgz";
21
+
22
+ // Platform detection
23
+ const PLATFORM = os.platform();
24
+ const ARCH = os.arch();
25
+ const IS_WINDOWS = PLATFORM === "win32";
26
+ const IS_MACOS = PLATFORM === "darwin";
27
+ const IS_LINUX = PLATFORM === "linux";
28
+
29
+ // Binary paths
30
+ const BIN_NAME = IS_WINDOWS ? `${BINARY_NAME}.exe` : BINARY_NAME;
31
+ const BIN_PATH = path.join(BIN_DIR, BIN_NAME);
32
+
33
+ // Download configuration
34
+ const GITHUB_BASE_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download";
35
+ const REDIRECT_CODES = [301, 302];
36
+ const SUCCESS_CODE = 200;
37
+ const UNIX_EXECUTABLE_MODE = "755";
38
+
39
+ // ============================================================================
40
+ // Platform Detection
41
+ // ============================================================================
42
+
43
+ /**
44
+ * Platform and architecture mapping for cloudflared releases
45
+ */
46
+ const PLATFORM_MAPPINGS = {
47
+ darwin: {
48
+ amd64: "cloudflared-darwin-amd64.tgz",
49
+ arm64: "cloudflared-darwin-amd64.tgz", // macOS uses universal binary
50
+ },
51
+ win32: {
52
+ x64: "cloudflared-windows-amd64.exe",
53
+ ia32: "cloudflared-windows-386.exe",
54
+ },
55
+ linux: {
56
+ x64: "cloudflared-linux-amd64",
57
+ arm64: "cloudflared-linux-arm64",
58
+ arm: "cloudflared-linux-arm",
59
+ },
60
+ };
61
+
62
+ /**
63
+ * Normalizes architecture name for mapping lookup
64
+ * @param {string} arch - Raw architecture from os.arch()
65
+ * @returns {string} Normalized architecture name
66
+ */
67
+ function normalizeArch(arch) {
68
+ const archMap = {
69
+ x64: "x64",
70
+ amd64: "amd64",
71
+ arm64: "arm64",
72
+ ia32: "ia32",
73
+ arm: "arm",
74
+ };
75
+ return archMap[arch] || arch;
76
+ }
77
+
78
+ /**
79
+ * Determines the download URL based on current platform and architecture
80
+ * @returns {string} Download URL for cloudflared binary
81
+ * @throws {Error} If platform/architecture combination is not supported
82
+ */
83
+ function getDownloadUrl() {
84
+ const normalizedArch = normalizeArch(ARCH);
85
+ const platformMapping = PLATFORM_MAPPINGS[PLATFORM];
86
+
87
+ if (!platformMapping) {
88
+ throw new Error(
89
+ `Unsupported platform: ${PLATFORM}. Supported platforms: darwin, win32, linux`
90
+ );
91
+ }
92
+
93
+ const binaryName = platformMapping[normalizedArch];
94
+
95
+ if (!binaryName) {
96
+ throw new Error(
97
+ `Unsupported architecture: ${ARCH} for platform ${PLATFORM}. ` +
98
+ `Supported architectures: ${Object.keys(platformMapping).join(", ")}`
99
+ );
100
+ }
101
+
102
+ return `${GITHUB_BASE_URL}/${binaryName}`;
103
+ }
104
+
105
+ /**
106
+ * Checks if the download URL points to a compressed archive
107
+ * @param {string} url - Download URL
108
+ * @returns {boolean} True if URL is for a compressed file
109
+ */
110
+ function isCompressedArchive(url) {
111
+ return url.endsWith(COMPRESSED_SUFFIX);
112
+ }
113
+
114
+ // ============================================================================
115
+ // File System Utilities
116
+ // ============================================================================
117
+
118
+ /**
119
+ * Ensures directory exists, creates it if it doesn't
120
+ * @param {string} dirPath - Directory path to ensure
121
+ */
122
+ function ensureDirectory(dirPath) {
123
+ if (!fs.existsSync(dirPath)) {
124
+ fs.mkdirSync(dirPath, { recursive: true });
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Safely removes a file if it exists
130
+ * @param {string} filePath - Path to file to remove
131
+ */
132
+ function safeUnlink(filePath) {
133
+ try {
134
+ if (fs.existsSync(filePath)) {
135
+ fs.unlinkSync(filePath);
136
+ }
137
+ } catch (err) {
138
+ // Ignore errors during cleanup
139
+ console.warn(`Warning: Could not remove ${filePath}:`, err.message);
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Sets executable permissions on Unix-like systems
145
+ * @param {string} filePath - Path to file
146
+ * @param {string} mode - Permission mode (e.g., "755")
147
+ */
148
+ function setExecutablePermissions(filePath, mode = UNIX_EXECUTABLE_MODE) {
149
+ if (!IS_WINDOWS) {
150
+ fs.chmodSync(filePath, mode);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Validates that a file exists at the given path
156
+ * @param {string} filePath - Path to validate
157
+ * @param {string} errorMessage - Error message if file doesn't exist
158
+ * @throws {Error} If file doesn't exist
159
+ */
160
+ function validateFileExists(filePath, errorMessage) {
161
+ if (!fs.existsSync(filePath)) {
162
+ throw new Error(errorMessage || `File not found: ${filePath}`);
163
+ }
164
+ }
165
+
166
+ // ============================================================================
167
+ // Download Utilities
168
+ // ============================================================================
169
+
170
+ /**
171
+ * Formats bytes to human readable string
172
+ * @param {number} bytes - Number of bytes
173
+ * @returns {string} Formatted string (e.g., "5.2 MB")
174
+ */
175
+ function formatBytes(bytes) {
176
+ if (bytes === 0) return "0 B";
177
+ const k = 1024;
178
+ const sizes = ["B", "KB", "MB", "GB"];
179
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
180
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
181
+ }
182
+
183
+ /**
184
+ * Downloads a file from a URL with automatic redirect handling and progress display
185
+ * @param {string} url - URL to download from
186
+ * @param {string} dest - Destination file path
187
+ * @returns {Promise<string>} Resolves with destination path on success
188
+ */
189
+ async function downloadFile(url, dest) {
190
+ return new Promise((resolve, reject) => {
191
+ const file = fs.createWriteStream(dest);
192
+ let downloadStarted = false;
193
+
194
+ const cleanupAndReject = (error) => {
195
+ // Print newline if progress was shown
196
+ if (downloadStarted) {
197
+ process.stdout.write("\n");
198
+ }
199
+ file.close();
200
+ safeUnlink(dest);
201
+ reject(error);
202
+ };
203
+
204
+ https
205
+ .get(url, (response) => {
206
+ // Handle redirects
207
+ if (REDIRECT_CODES.includes(response.statusCode)) {
208
+ file.close();
209
+ safeUnlink(dest);
210
+ downloadFile(response.headers.location, dest)
211
+ .then(resolve)
212
+ .catch(reject);
213
+ return;
214
+ }
215
+
216
+ // Handle non-success status codes
217
+ if (response.statusCode !== SUCCESS_CODE) {
218
+ cleanupAndReject(
219
+ new Error(
220
+ `Download failed with status code ${response.statusCode} from ${url}`
221
+ )
222
+ );
223
+ return;
224
+ }
225
+
226
+ // Get total size for progress calculation
227
+ const totalSize = parseInt(response.headers["content-length"], 10);
228
+ let downloadedSize = 0;
229
+ let lastPercent = 0;
230
+
231
+ // Show progress
232
+ response.on("data", (chunk) => {
233
+ downloadStarted = true;
234
+ downloadedSize += chunk.length;
235
+
236
+ if (totalSize) {
237
+ const percent = Math.floor((downloadedSize / totalSize) * 100);
238
+
239
+ // Update progress every 5%
240
+ if (percent >= lastPercent + 5 || percent === 100) {
241
+ lastPercent = percent;
242
+ process.stdout.write(`\r Downloading: ${percent}% (${formatBytes(downloadedSize)}/${formatBytes(totalSize)})`);
243
+ }
244
+ } else {
245
+ // If no content-length, just show downloaded size
246
+ process.stdout.write(`\r Downloading: ${formatBytes(downloadedSize)}`);
247
+ }
248
+ });
249
+
250
+ // Handle response errors
251
+ response.on("error", (err) => {
252
+ cleanupAndReject(new Error(`Download stream error: ${err.message}`));
253
+ });
254
+
255
+ // Stream response to file
256
+ response.pipe(file);
257
+
258
+ file.on("finish", () => {
259
+ process.stdout.write("\n"); // New line after progress
260
+ file.close(() => resolve(dest));
261
+ });
262
+
263
+ file.on("error", (err) => {
264
+ cleanupAndReject(new Error(`File write error: ${err.message}`));
265
+ });
266
+ })
267
+ .on("error", (err) => {
268
+ cleanupAndReject(new Error(`Network error: ${err.message}`));
269
+ });
270
+ });
271
+ }
272
+
273
+ // ============================================================================
274
+ // Archive Extraction
275
+ // ============================================================================
276
+
277
+ /**
278
+ * Extracts a .tgz archive to a directory
279
+ * @param {string} archivePath - Path to .tgz file
280
+ * @param {string} targetDir - Directory to extract to
281
+ * @throws {Error} If extraction fails
282
+ */
283
+ function extractTarGz(archivePath, targetDir) {
284
+ try {
285
+ execSync(`tar -xzf "${archivePath}" -C "${targetDir}"`, {
286
+ stdio: "pipe", // Suppress output
287
+ });
288
+ } catch (err) {
289
+ throw new Error(`Extraction failed: ${err.message}`);
290
+ }
291
+ }
292
+
293
+ // ============================================================================
294
+ // Logging Utilities
295
+ // ============================================================================
296
+
297
+ /**
298
+ * Console logging with consistent formatting
299
+ */
300
+ const logger = {
301
+ info: (msg) => console.log(`ℹ️ ${msg}`),
302
+ success: (msg) => console.log(`✅ ${msg}`),
303
+ warn: (msg) => console.warn(`⚠️ ${msg}`),
304
+ error: (msg) => console.error(`❌ ${msg}`),
305
+ progress: (msg) => console.log(`🚧 ${msg}`),
306
+ extract: (msg) => console.log(`📦 ${msg}`),
307
+ };
308
+
309
+ // ============================================================================
310
+ // Core Binary Management
311
+ // ============================================================================
312
+
313
+ /**
314
+ * Downloads and installs the cloudflared binary
315
+ * @returns {Promise<string>} Path to installed binary
316
+ */
317
+ async function installBinary() {
318
+ logger.progress(
319
+ "Cloudflared binary not found. Downloading... (This happens only once)"
320
+ );
321
+
322
+ const url = getDownloadUrl();
323
+ const isArchive = isCompressedArchive(url);
324
+ const downloadDest = isArchive
325
+ ? path.join(BIN_DIR, TEMP_ARCHIVE_NAME)
326
+ : BIN_PATH;
327
+
328
+ try {
329
+ // Download binary or archive
330
+ await downloadFile(url, downloadDest);
331
+
332
+ // Extract if it's an archive (macOS)
333
+ if (isArchive) {
334
+ logger.extract("Extracting binary...");
335
+ extractTarGz(downloadDest, BIN_DIR);
336
+
337
+ // Clean up archive
338
+ safeUnlink(downloadDest);
339
+
340
+ // Validate extraction succeeded
341
+ validateFileExists(
342
+ BIN_PATH,
343
+ "Extraction failed: Binary not found after extraction"
344
+ );
345
+ }
346
+
347
+ // Set executable permissions (Unix-like systems)
348
+ setExecutablePermissions(BIN_PATH);
349
+
350
+ logger.success("Download complete.");
351
+ return BIN_PATH;
352
+ } catch (error) {
353
+ // Clean up any partial downloads
354
+ safeUnlink(downloadDest);
355
+ safeUnlink(BIN_PATH);
356
+ throw error;
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Ensures cloudflared binary is available, downloading if necessary
362
+ * @returns {Promise<string>} Path to cloudflared binary
363
+ */
364
+ export async function ensureCloudflared() {
365
+ // Ensure bin directory exists
366
+ ensureDirectory(BIN_DIR);
367
+
368
+ // Check if binary already exists
369
+ if (fs.existsSync(BIN_PATH)) {
370
+ // Always ensure permissions are set correctly (in case they were lost)
371
+ setExecutablePermissions(BIN_PATH);
372
+ return BIN_PATH;
373
+ }
374
+
375
+ // Download and install
376
+ try {
377
+ return await installBinary();
378
+ } catch (error) {
379
+ logger.error(`Installation failed: ${error.message}`);
380
+ process.exit(1);
381
+ }
382
+ }
383
+
384
+ // ============================================================================
385
+ // CLI Entry Point
386
+ // ============================================================================
387
+
388
+ /**
389
+ * Checks if we're running in a CI environment
390
+ * @returns {boolean} True if in CI environment
391
+ */
392
+ function isCI() {
393
+ return !!(
394
+ process.env.CI || // Generic CI flag
395
+ process.env.GITHUB_ACTIONS || // GitHub Actions
396
+ process.env.GITLAB_CI || // GitLab CI
397
+ process.env.CIRCLECI || // CircleCI
398
+ process.env.TRAVIS || // Travis CI
399
+ process.env.JENKINS_URL || // Jenkins
400
+ process.env.BUILDKITE // Buildkite
401
+ );
402
+ }
403
+
404
+ /**
405
+ * Main function when run directly from command line
406
+ */
407
+ async function main() {
408
+ // Skip binary download in CI environments to keep package lightweight
409
+ if (isCI()) {
410
+ logger.info("Running in CI environment - skipping binary download");
411
+ return;
412
+ }
413
+
414
+ try {
415
+ const binaryPath = await ensureCloudflared();
416
+ logger.success(`Cloudflared binary is ready at: ${binaryPath}`);
417
+ } catch (error) {
418
+ logger.error(error.message);
419
+ process.exit(1);
420
+ }
421
+ }
422
+
423
+ // Run if executed directly
424
+ if (process.argv[1] === __filename) {
425
+ main();
426
+ }