diff --git a/packages/cli/src/config/globalConfig.ts b/packages/cli/src/config/globalConfig.ts new file mode 100644 index 0000000..6a9bf4e --- /dev/null +++ b/packages/cli/src/config/globalConfig.ts @@ -0,0 +1,84 @@ +import os from "os"; +import path from "path"; +import fs from "fs"; +import { v4 } from "uuid"; +import { z } from "zod"; + +export const globalConfigSchema = z.object({ + userId: z.string(), +}); + +function getConfigPath() { + const appName = "napi"; + const homeDir = os.homedir(); + + if (os.platform() === "win32") { + // Windows: Use %APPDATA% + const appData = + process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"); + return path.join(appData, appName, "config.json"); + } else if (os.platform() === "darwin") { + // macOS: Use ~/Library/Application Support + return path.join( + homeDir, + "Library", + "Application Support", + appName, + "config.json", + ); + } else { + // Linux and others: Use ~/.config + const configDir = + process.env.XDG_CONFIG_HOME || path.join(homeDir, ".config"); + return path.join(configDir, appName, "config.json"); + } +} + +async function createNewConfig(configPath: string) { + const config = { + userId: v4(), + }; + + try { + const configDir = path.dirname(configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + } catch (error) { + console.error(`Failed to write config: ${error}`); + } + + return config; +} + +export async function getOrCreateGlobalConfig() { + const configPath = getConfigPath(); + + let config: z.infer; + + try { + if (fs.existsSync(configPath)) { + const content = fs.readFileSync(configPath, "utf-8"); + + try { + config = globalConfigSchema.parse(JSON.parse(content)); + } catch (error) { + console.debug(`Failed to parse config: ${error}`); + config = await createNewConfig(configPath); + } + } else { + config = await createNewConfig(configPath); + } + } catch (error) { + console.error(`Failed to read or create config: ${error}`); + config = await createNewConfig(configPath); + } + + return config; +} + +export function updateConfig(newConfig: z.infer) { + const configPath = getConfigPath(); + fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2)); +} diff --git a/packages/cli/src/helper/checkNpmVersion.ts b/packages/cli/src/helper/checkNpmVersion.ts new file mode 100644 index 0000000..bd9c887 --- /dev/null +++ b/packages/cli/src/helper/checkNpmVersion.ts @@ -0,0 +1,33 @@ +import localPackageJson from "../../package.json"; + +export async function checkVersionMiddleware() { + const currentVersion = localPackageJson.version; + let latestVersion: string; + + try { + const response = await fetch( + `https://registry.npmjs.org/${localPackageJson.name}/latest`, + ); + if (!response.ok) { + console.warn( + "Failed to fetch latest version from npm, ignoring version check", + ); + } + + const latestPackageJson: { version: string } = await response.json(); + latestVersion = latestPackageJson.version; + } catch (err) { + console.warn( + `Failed to fetch latest version from npm, ignoring version check. Error: ${err}`, + ); + return; + } + + if (currentVersion !== latestVersion) { + console.warn( + `You are using version ${currentVersion} of ${localPackageJson.name}. ` + + `The latest version is ${latestVersion}. Please update to the latest version.`, + ); + process.exit(1); + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e3b4725..9ee79bf 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -10,6 +10,7 @@ import splitCommandHandler from "./commands/split"; import { getConfigFromWorkDir, getOpenaiApiKeyFromConfig } from "./config"; import { findAvailablePort, openInBrowser } from "./helper/server"; import { TelemetryEvents, trackEvent } from "./telemetry"; +import { checkVersionMiddleware } from "./helper/checkNpmVersion"; // remove all warning. // We need this because of some depreciation warning we have with 3rd party libraries @@ -23,6 +24,9 @@ trackEvent(TelemetryEvents.APP_START, { }); yargs(hideBin(process.argv)) + .middleware(() => { + checkVersionMiddleware(); + }) .options({ workdir: { type: "string", diff --git a/packages/cli/src/telemetry.ts b/packages/cli/src/telemetry.ts index 1e2926a..195556f 100644 --- a/packages/cli/src/telemetry.ts +++ b/packages/cli/src/telemetry.ts @@ -1,9 +1,7 @@ -import axios from "axios"; import { EventEmitter } from "events"; -import { existsSync, readFileSync, writeFileSync } from "fs"; -import { join } from "path"; -import { v4 as uuidv4 } from "uuid"; import os from "os"; +import { getOrCreateGlobalConfig } from "./config/globalConfig"; +import packageJson from "../package.json"; export enum TelemetryEvents { APP_START = "app_start", @@ -18,7 +16,9 @@ export enum TelemetryEvents { } export interface TelemetryEvent { - sessionId: string; + userId: string; + os: string; + version: string; eventId: TelemetryEvents; data: Record; timestamp: string; @@ -28,52 +28,51 @@ const telemetry = new EventEmitter(); const TELEMETRY_ENDPOINT = process.env.TELEMETRY_ENDPOINT || "https://napi-watchdog-api-gateway-33ge7a49.nw.gateway.dev/telemetryHandler"; -const SESSION_FILE_PATH = join(os.tmpdir(), "napi_session_id"); - -// getSessionId generates a new session ID and cache it in SESSION_FILE_PATH -function getSessionId() { - if (existsSync(SESSION_FILE_PATH)) { - const fileContent = readFileSync(SESSION_FILE_PATH, "utf-8"); - const [storedDate, sessionId] = fileContent.split(":"); - const today = new Date().toISOString().slice(0, 10); - - if (storedDate === today) { - return sessionId; - } - } - - const newSessionId = uuidv4(); - const today = new Date().toISOString().slice(0, 10); - writeFileSync(SESSION_FILE_PATH, `${today}:${newSessionId}`); - return newSessionId; -} - -const SESSION_ID = getSessionId(); telemetry.on("event", (data) => { sendTelemetryData(data); }); async function sendTelemetryData(data: TelemetryEvent) { + const controller = new AbortController(); + const timeoutSeconds = 15; + const timeoutId = setTimeout(() => controller.abort(), timeoutSeconds * 1000); + try { - await axios.post(TELEMETRY_ENDPOINT, data, { + const response = await fetch(TELEMETRY_ENDPOINT, { + method: "POST", headers: { "Content-Type": "application/json", "User-Agent": "napi", }, - timeout: 100000, + body: JSON.stringify(data), + signal: controller.signal, }); + + clearTimeout(timeoutId); + + if (!response.ok) { + console.debug(`Failed to send telemetry data: ${response.statusText}`); + } } catch (error) { - console.debug(`Failed to send telemetry data: ${error}`); + if (error instanceof Error && error.name === "AbortError") { + console.debug("Request timed out"); + } else { + console.debug(`Failed to send telemetry data: ${error}`); + } } } -export function trackEvent( +export async function trackEvent( eventId: TelemetryEvents, eventData: Record, ) { + const config = await getOrCreateGlobalConfig(); + const telemetryPayload: TelemetryEvent = { - sessionId: SESSION_ID, + userId: config.userId, + os: os.platform(), + version: packageJson.version, eventId, data: eventData, timestamp: new Date().toISOString(), diff --git a/tsconfig.base.json b/tsconfig.base.json index 8109ba0..f83682b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -7,6 +7,7 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, + "resolveJsonModule": true, "noFallthroughCasesInSwitch": true } }