nport 2.0.1 → 2.0.3
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/analytics.js +259 -0
- package/index.js +66 -9
- package/package.json +2 -1
package/analytics.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Firebase Analytics for CLI
|
|
3
|
+
// Using Google Analytics 4 Measurement Protocol
|
|
4
|
+
// ============================================================================
|
|
5
|
+
|
|
6
|
+
import axios from "axios";
|
|
7
|
+
import { createHash, randomUUID } from "crypto";
|
|
8
|
+
import os from "os";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const __dirname = path.dirname(__filename);
|
|
15
|
+
|
|
16
|
+
// Firebase/GA4 Configuration (from website/home.html)
|
|
17
|
+
// Full Firebase config for reference (if needed for future features)
|
|
18
|
+
const FIREBASE_WEB_CONFIG = {
|
|
19
|
+
apiKey: "AIzaSyArRxHZJUt4o2RxiLqX1yDSkuUd6ZFy45I",
|
|
20
|
+
authDomain: "nport-link.firebaseapp.com",
|
|
21
|
+
projectId: "nport-link",
|
|
22
|
+
storageBucket: "nport-link.firebasestorage.app",
|
|
23
|
+
messagingSenderId: "515584605320",
|
|
24
|
+
appId: "1:515584605320:web:88daabc8d77146c6e7f33d",
|
|
25
|
+
measurementId: "G-8MYXZL6PGD"
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Analytics-specific config (for GA4 Measurement Protocol)
|
|
29
|
+
const FIREBASE_CONFIG = {
|
|
30
|
+
measurementId: FIREBASE_WEB_CONFIG.measurementId,
|
|
31
|
+
apiSecret: process.env.NPORT_ANALYTICS_SECRET || "YOUR_API_SECRET_HERE", // Get from Firebase Console
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Analytics Configuration
|
|
35
|
+
const ANALYTICS_CONFIG = {
|
|
36
|
+
enabled: true, // Can be disabled by environment variable
|
|
37
|
+
debug: process.env.NPORT_DEBUG === "true",
|
|
38
|
+
timeout: 2000, // Don't block CLI for too long
|
|
39
|
+
userIdFile: path.join(os.homedir(), ".nport-analytics"),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Analytics Manager
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
class AnalyticsManager {
|
|
47
|
+
constructor() {
|
|
48
|
+
this.userId = null;
|
|
49
|
+
this.sessionId = null;
|
|
50
|
+
this.disabled = false;
|
|
51
|
+
|
|
52
|
+
// Disable analytics if environment variable is set
|
|
53
|
+
if (process.env.NPORT_ANALYTICS === "false") {
|
|
54
|
+
this.disabled = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Initialize analytics - must be called before tracking
|
|
60
|
+
*/
|
|
61
|
+
async initialize() {
|
|
62
|
+
if (this.disabled) return;
|
|
63
|
+
|
|
64
|
+
// Check if API secret is configured
|
|
65
|
+
if (!FIREBASE_CONFIG.apiSecret || FIREBASE_CONFIG.apiSecret === "YOUR_API_SECRET_HERE") {
|
|
66
|
+
if (ANALYTICS_CONFIG.debug) {
|
|
67
|
+
console.warn("[Analytics] API secret not configured. Analytics disabled.");
|
|
68
|
+
console.warn("[Analytics] Set NPORT_ANALYTICS_SECRET environment variable.");
|
|
69
|
+
}
|
|
70
|
+
this.disabled = true;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
this.userId = await this.getUserId();
|
|
76
|
+
this.sessionId = this.generateSessionId();
|
|
77
|
+
|
|
78
|
+
if (ANALYTICS_CONFIG.debug) {
|
|
79
|
+
console.log("[Analytics] Initialized successfully");
|
|
80
|
+
console.log("[Analytics] User ID:", this.userId.substring(0, 8) + "...");
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (ANALYTICS_CONFIG.debug) {
|
|
84
|
+
console.error("[Analytics] Initialization failed:", error.message);
|
|
85
|
+
}
|
|
86
|
+
this.disabled = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get or create a persistent user ID
|
|
92
|
+
*/
|
|
93
|
+
async getUserId() {
|
|
94
|
+
try {
|
|
95
|
+
// Try to read existing user ID
|
|
96
|
+
if (fs.existsSync(ANALYTICS_CONFIG.userIdFile)) {
|
|
97
|
+
const userId = fs.readFileSync(ANALYTICS_CONFIG.userIdFile, "utf8").trim();
|
|
98
|
+
if (userId) return userId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Generate new anonymous user ID
|
|
102
|
+
const userId = this.generateAnonymousId();
|
|
103
|
+
|
|
104
|
+
// Save for future use
|
|
105
|
+
fs.writeFileSync(ANALYTICS_CONFIG.userIdFile, userId, "utf8");
|
|
106
|
+
|
|
107
|
+
return userId;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// If file operations fail, use session-based ID
|
|
110
|
+
return this.generateAnonymousId();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Generate anonymous user ID based on machine characteristics
|
|
116
|
+
*/
|
|
117
|
+
generateAnonymousId() {
|
|
118
|
+
const machineId = [
|
|
119
|
+
os.hostname(),
|
|
120
|
+
os.platform(),
|
|
121
|
+
os.arch(),
|
|
122
|
+
os.homedir(),
|
|
123
|
+
].join("-");
|
|
124
|
+
|
|
125
|
+
return createHash("sha256").update(machineId).digest("hex").substring(0, 32);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Generate session ID
|
|
130
|
+
*/
|
|
131
|
+
generateSessionId() {
|
|
132
|
+
return randomUUID();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Track an event
|
|
137
|
+
*/
|
|
138
|
+
async trackEvent(eventName, params = {}) {
|
|
139
|
+
if (this.disabled || !ANALYTICS_CONFIG.enabled) return;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const payload = this.buildPayload(eventName, params);
|
|
143
|
+
|
|
144
|
+
// Send to GA4 Measurement Protocol (non-blocking)
|
|
145
|
+
axios.post(
|
|
146
|
+
`https://www.google-analytics.com/mp/collect?measurement_id=${FIREBASE_CONFIG.measurementId}&api_secret=${FIREBASE_CONFIG.apiSecret}`,
|
|
147
|
+
payload,
|
|
148
|
+
{
|
|
149
|
+
timeout: ANALYTICS_CONFIG.timeout,
|
|
150
|
+
headers: { "Content-Type": "application/json" },
|
|
151
|
+
}
|
|
152
|
+
).catch((error) => {
|
|
153
|
+
// Silently fail - don't interrupt CLI operations
|
|
154
|
+
if (ANALYTICS_CONFIG.debug) {
|
|
155
|
+
console.error("[Analytics] Failed to send event:", error.message);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (ANALYTICS_CONFIG.debug) {
|
|
160
|
+
console.log("[Analytics] Event tracked:", eventName, params);
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
// Silently fail
|
|
164
|
+
if (ANALYTICS_CONFIG.debug) {
|
|
165
|
+
console.error("[Analytics] Error tracking event:", error.message);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Build GA4 Measurement Protocol payload
|
|
172
|
+
*/
|
|
173
|
+
buildPayload(eventName, params) {
|
|
174
|
+
return {
|
|
175
|
+
client_id: this.userId,
|
|
176
|
+
events: [
|
|
177
|
+
{
|
|
178
|
+
name: eventName,
|
|
179
|
+
params: {
|
|
180
|
+
session_id: this.sessionId,
|
|
181
|
+
engagement_time_msec: "100",
|
|
182
|
+
...this.getSystemInfo(),
|
|
183
|
+
...params,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get system information for context
|
|
192
|
+
*/
|
|
193
|
+
getSystemInfo() {
|
|
194
|
+
return {
|
|
195
|
+
os_platform: os.platform(),
|
|
196
|
+
os_version: os.release(),
|
|
197
|
+
os_arch: os.arch(),
|
|
198
|
+
node_version: process.version,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Track CLI start
|
|
204
|
+
*/
|
|
205
|
+
async trackCliStart(port, subdomain, version) {
|
|
206
|
+
await this.trackEvent("cli_start", {
|
|
207
|
+
port: String(port),
|
|
208
|
+
has_custom_subdomain: subdomain && !subdomain.startsWith("user-"),
|
|
209
|
+
cli_version: version,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Track tunnel creation
|
|
215
|
+
*/
|
|
216
|
+
async trackTunnelCreated(subdomain, port) {
|
|
217
|
+
await this.trackEvent("tunnel_created", {
|
|
218
|
+
subdomain_type: subdomain.startsWith("user-") ? "random" : "custom",
|
|
219
|
+
port: String(port),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Track tunnel error
|
|
225
|
+
*/
|
|
226
|
+
async trackTunnelError(errorType, errorMessage) {
|
|
227
|
+
await this.trackEvent("tunnel_error", {
|
|
228
|
+
error_type: errorType,
|
|
229
|
+
error_message: errorMessage.substring(0, 100), // Limit length
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Track tunnel shutdown
|
|
235
|
+
*/
|
|
236
|
+
async trackTunnelShutdown(reason, durationSeconds) {
|
|
237
|
+
await this.trackEvent("tunnel_shutdown", {
|
|
238
|
+
shutdown_reason: reason, // "manual", "timeout", "error"
|
|
239
|
+
duration_seconds: String(Math.floor(durationSeconds)),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Track CLI update notification shown
|
|
245
|
+
*/
|
|
246
|
+
async trackUpdateAvailable(currentVersion, latestVersion) {
|
|
247
|
+
await this.trackEvent("update_available", {
|
|
248
|
+
current_version: currentVersion,
|
|
249
|
+
latest_version: latestVersion,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ============================================================================
|
|
255
|
+
// Export singleton instance
|
|
256
|
+
// ============================================================================
|
|
257
|
+
|
|
258
|
+
export const analytics = new AnalyticsManager();
|
|
259
|
+
|
package/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import fs from "fs";
|
|
|
8
8
|
import path from "path";
|
|
9
9
|
import { fileURLToPath } from "url";
|
|
10
10
|
import { createRequire } from "module";
|
|
11
|
+
import { analytics } from "./analytics.js";
|
|
11
12
|
|
|
12
13
|
// ============================================================================
|
|
13
14
|
// Module Setup & Constants
|
|
@@ -79,12 +80,16 @@ class TunnelState {
|
|
|
79
80
|
this.tunnelProcess = null;
|
|
80
81
|
this.timeoutId = null;
|
|
81
82
|
this.connectionCount = 0;
|
|
83
|
+
this.startTime = null;
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
setTunnel(tunnelId, subdomain, port) {
|
|
85
87
|
this.tunnelId = tunnelId;
|
|
86
88
|
this.subdomain = subdomain;
|
|
87
89
|
this.port = port;
|
|
90
|
+
if (!this.startTime) {
|
|
91
|
+
this.startTime = Date.now();
|
|
92
|
+
}
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
setProcess(process) {
|
|
@@ -115,6 +120,11 @@ class TunnelState {
|
|
|
115
120
|
return this.tunnelProcess && !this.tunnelProcess.killed;
|
|
116
121
|
}
|
|
117
122
|
|
|
123
|
+
getDurationSeconds() {
|
|
124
|
+
if (!this.startTime) return 0;
|
|
125
|
+
return (Date.now() - this.startTime) / 1000;
|
|
126
|
+
}
|
|
127
|
+
|
|
118
128
|
reset() {
|
|
119
129
|
this.clearTimeout();
|
|
120
130
|
this.tunnelId = null;
|
|
@@ -122,6 +132,7 @@ class TunnelState {
|
|
|
122
132
|
this.port = null;
|
|
123
133
|
this.tunnelProcess = null;
|
|
124
134
|
this.connectionCount = 0;
|
|
135
|
+
this.startTime = null;
|
|
125
136
|
}
|
|
126
137
|
}
|
|
127
138
|
|
|
@@ -337,6 +348,11 @@ class VersionManager {
|
|
|
337
348
|
const shouldUpdate =
|
|
338
349
|
this.compareVersions(latestVersion, CONFIG.CURRENT_VERSION) > 0;
|
|
339
350
|
|
|
351
|
+
// Track update notification if available
|
|
352
|
+
if (shouldUpdate) {
|
|
353
|
+
analytics.trackUpdateAvailable(CONFIG.CURRENT_VERSION, latestVersion);
|
|
354
|
+
}
|
|
355
|
+
|
|
340
356
|
return {
|
|
341
357
|
current: CONFIG.CURRENT_VERSION,
|
|
342
358
|
latest: latestVersion,
|
|
@@ -352,9 +368,16 @@ class VersionManager {
|
|
|
352
368
|
const parts1 = v1.split(".").map(Number);
|
|
353
369
|
const parts2 = v2.split(".").map(Number);
|
|
354
370
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
371
|
+
// Compare up to the maximum length of both version arrays
|
|
372
|
+
const maxLength = Math.max(parts1.length, parts2.length);
|
|
373
|
+
|
|
374
|
+
for (let i = 0; i < maxLength; i++) {
|
|
375
|
+
// Treat missing parts as 0 (e.g., "1.0" is "1.0.0")
|
|
376
|
+
const part1 = parts1[i] || 0;
|
|
377
|
+
const part2 = parts2[i] || 0;
|
|
378
|
+
|
|
379
|
+
if (part1 > part2) return 1;
|
|
380
|
+
if (part1 < part2) return -1;
|
|
358
381
|
}
|
|
359
382
|
|
|
360
383
|
return 0;
|
|
@@ -370,6 +393,13 @@ class UI {
|
|
|
370
393
|
if (!updateInfo || !updateInfo.shouldUpdate) return;
|
|
371
394
|
|
|
372
395
|
const border = "═".repeat(59);
|
|
396
|
+
const boxWidth = 59;
|
|
397
|
+
|
|
398
|
+
// Calculate padding dynamically
|
|
399
|
+
const currentVersionText = ` Current version: v${updateInfo.current}`;
|
|
400
|
+
const latestVersionText = ` Latest version: v${updateInfo.latest}`;
|
|
401
|
+
const runCommandText = ` Run: npm install -g ${CONFIG.PACKAGE_NAME}@latest`;
|
|
402
|
+
|
|
373
403
|
console.log(chalk.yellow(`\n╔${border}╗`));
|
|
374
404
|
console.log(
|
|
375
405
|
chalk.yellow("║") +
|
|
@@ -382,14 +412,14 @@ class UI {
|
|
|
382
412
|
chalk.yellow("║") +
|
|
383
413
|
chalk.gray(` Current version: `) +
|
|
384
414
|
chalk.red(`v${updateInfo.current}`) +
|
|
385
|
-
" ".repeat(
|
|
415
|
+
" ".repeat(boxWidth - currentVersionText.length) +
|
|
386
416
|
chalk.yellow("║")
|
|
387
417
|
);
|
|
388
418
|
console.log(
|
|
389
419
|
chalk.yellow("║") +
|
|
390
420
|
chalk.gray(` Latest version: `) +
|
|
391
421
|
chalk.green(`v${updateInfo.latest}`) +
|
|
392
|
-
" ".repeat(
|
|
422
|
+
" ".repeat(boxWidth - latestVersionText.length) +
|
|
393
423
|
chalk.yellow("║")
|
|
394
424
|
);
|
|
395
425
|
console.log(chalk.yellow(`╠${border}╣`));
|
|
@@ -397,7 +427,7 @@ class UI {
|
|
|
397
427
|
chalk.yellow("║") +
|
|
398
428
|
chalk.cyan(` Run: `) +
|
|
399
429
|
chalk.bold(`npm install -g ${CONFIG.PACKAGE_NAME}@latest`) +
|
|
400
|
-
" ".repeat(
|
|
430
|
+
" ".repeat(boxWidth - runCommandText.length) +
|
|
401
431
|
chalk.yellow("║")
|
|
402
432
|
);
|
|
403
433
|
console.log(chalk.yellow(`╚${border}╝\n`));
|
|
@@ -498,6 +528,12 @@ class TunnelOrchestrator {
|
|
|
498
528
|
static async start(config) {
|
|
499
529
|
state.setTunnel(null, config.subdomain, config.port);
|
|
500
530
|
|
|
531
|
+
// Initialize analytics
|
|
532
|
+
await analytics.initialize();
|
|
533
|
+
|
|
534
|
+
// Track CLI start
|
|
535
|
+
analytics.trackCliStart(config.port, config.subdomain, CONFIG.CURRENT_VERSION);
|
|
536
|
+
|
|
501
537
|
// Display UI
|
|
502
538
|
UI.displayStartupBanner(config.port);
|
|
503
539
|
|
|
@@ -507,6 +543,9 @@ class TunnelOrchestrator {
|
|
|
507
543
|
|
|
508
544
|
// Validate binary
|
|
509
545
|
if (!BinaryManager.validate(PATHS.BIN_PATH)) {
|
|
546
|
+
analytics.trackTunnelError("binary_missing", "Cloudflared binary not found");
|
|
547
|
+
// Give analytics a moment to send before exiting
|
|
548
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
510
549
|
process.exit(1);
|
|
511
550
|
}
|
|
512
551
|
|
|
@@ -517,6 +556,9 @@ class TunnelOrchestrator {
|
|
|
517
556
|
const tunnel = await APIClient.createTunnel(config.subdomain);
|
|
518
557
|
state.setTunnel(tunnel.tunnelId, config.subdomain, config.port);
|
|
519
558
|
|
|
559
|
+
// Track successful tunnel creation
|
|
560
|
+
analytics.trackTunnelCreated(config.subdomain, config.port);
|
|
561
|
+
|
|
520
562
|
spinner.succeed(chalk.green("Tunnel created!"));
|
|
521
563
|
UI.displayTunnelSuccess(tunnel.url);
|
|
522
564
|
|
|
@@ -532,16 +574,24 @@ class TunnelOrchestrator {
|
|
|
532
574
|
// Set timeout
|
|
533
575
|
const timeoutId = setTimeout(() => {
|
|
534
576
|
UI.displayTimeoutWarning();
|
|
535
|
-
this.cleanup();
|
|
577
|
+
this.cleanup("timeout");
|
|
536
578
|
}, TUNNEL_TIMEOUT_MS);
|
|
537
579
|
state.setTimeout(timeoutId);
|
|
538
580
|
} catch (error) {
|
|
581
|
+
// Track tunnel creation error
|
|
582
|
+
const errorType = error.message.includes("already taken")
|
|
583
|
+
? "subdomain_taken"
|
|
584
|
+
: "tunnel_creation_failed";
|
|
585
|
+
analytics.trackTunnelError(errorType, error.message);
|
|
586
|
+
|
|
539
587
|
UI.displayError(error, spinner);
|
|
588
|
+
// Give analytics a moment to send before exiting
|
|
589
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
540
590
|
process.exit(1);
|
|
541
591
|
}
|
|
542
592
|
}
|
|
543
593
|
|
|
544
|
-
static async cleanup() {
|
|
594
|
+
static async cleanup(reason = "manual") {
|
|
545
595
|
state.clearTimeout();
|
|
546
596
|
|
|
547
597
|
if (!state.hasTunnel()) {
|
|
@@ -550,6 +600,10 @@ class TunnelOrchestrator {
|
|
|
550
600
|
|
|
551
601
|
UI.displayCleanupStart();
|
|
552
602
|
|
|
603
|
+
// Track tunnel shutdown with duration
|
|
604
|
+
const duration = state.getDurationSeconds();
|
|
605
|
+
analytics.trackTunnelShutdown(reason, duration);
|
|
606
|
+
|
|
553
607
|
try {
|
|
554
608
|
// Kill process
|
|
555
609
|
if (state.hasProcess()) {
|
|
@@ -563,6 +617,9 @@ class TunnelOrchestrator {
|
|
|
563
617
|
UI.displayCleanupError();
|
|
564
618
|
}
|
|
565
619
|
|
|
620
|
+
// Give analytics a moment to send (non-blocking)
|
|
621
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
622
|
+
|
|
566
623
|
process.exit(0);
|
|
567
624
|
}
|
|
568
625
|
}
|
|
@@ -586,4 +643,4 @@ process.on("SIGINT", () => TunnelOrchestrator.cleanup());
|
|
|
586
643
|
process.on("SIGTERM", () => TunnelOrchestrator.cleanup());
|
|
587
644
|
|
|
588
645
|
// Start application
|
|
589
|
-
main();
|
|
646
|
+
main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nport",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
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",
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
},
|
|
74
74
|
"files": [
|
|
75
75
|
"index.js",
|
|
76
|
+
"analytics.js",
|
|
76
77
|
"bin-manager.js",
|
|
77
78
|
"README.md",
|
|
78
79
|
"LICENSE"
|