Files
2026-07-13 12:30:28 +08:00

376 lines
9.4 KiB
TypeScript

import os from 'os';
import fs from 'fs';
import path from 'path';
import { spawn } from 'child_process';
import { version } from '../../package.json';
import { getUid } from './getDeviceId';
export interface TrackData {
[key: string]: string | number | boolean | null | undefined;
}
const REQUEST_TIMEOUT_MS = 1500;
const TRACK_VALUE_LIMIT = 200;
const TRACK_REASON_LIMIT = 255;
const DEFAULT_GATEWAY = 'https://pinme.dev';
const DEFAULT_PRODUCT = 'pinme-cli';
const ACTION_OVERRIDES: Record<string, string> = {
cli_login_success: 'success',
cli_login_failed: 'fail',
appkey_set_success: 'success',
appkey_set_failed: 'fail',
appkey_shown_success: 'view',
appkey_shown_failed: 'fail',
my_domains_success: 'view',
my_domains_failed: 'fail',
wallet_balance_success: 'view',
wallet_balance_failed: 'fail',
upload_history_viewed: 'view',
upload_history_cleared: 'click',
upload_history_failed: 'fail',
};
const EV_OVERRIDES: Record<string, string> = {
upload_success: 'upload',
upload_failed: 'upload',
project_save_success: 'project_save',
project_save_failed: 'project_save',
};
const TRACK_CHILD_SCRIPT = `
const rawUrl = process.argv[1];
if (!rawUrl) process.exit(0);
try {
const transport = rawUrl.startsWith('https:') ? require('https') : require('http');
const req = transport.get(rawUrl, {
headers: {
'User-Agent': 'Pinme-CLI-Tracker'
}
}, (res) => {
res.resume();
res.on('end', () => process.exit(0));
});
req.setTimeout(${REQUEST_TIMEOUT_MS}, () => req.destroy());
req.on('error', () => process.exit(0));
req.on('close', () => process.exit(0));
} catch (_) {
process.exit(0);
}
`;
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, '');
}
function shouldDisableTracking(): boolean {
return (
process.env.PINME_TRACKING_DISABLED === '1' ||
process.env.DO_NOT_TRACK === '1'
);
}
function sanitizeTrackValue(
value: unknown,
maxLength = TRACK_VALUE_LIMIT,
): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
return String(value).trim().slice(0, maxLength);
}
export function getTrackErrorReason(error: unknown): string {
return sanitizeTrackValue(resolveErrorReason(error), TRACK_REASON_LIMIT) || 'unknown_error';
}
function responseDataMessage(data: any): string | undefined {
if (typeof data === 'string') {
return data;
}
return data?.msg
|| data?.message
|| data?.error
|| data?.data?.msg
|| data?.data?.message
|| data?.data?.error
|| data?.errors?.[0]?.message;
}
function normalizeReason(candidate: unknown, status?: number): string | undefined {
const value = sanitizeTrackValue(candidate, TRACK_REASON_LIMIT);
if (!value) {
return undefined;
}
const lower = value.toLowerCase();
if (/^\s*<!doctype\s+html/i.test(value) || /^\s*<html[\s>]/i.test(value)) {
return 'api_returned_html';
}
const statusMatch = lower.match(/request failed with status code (\d{3})/);
const statusCode = status || (statusMatch ? Number(statusMatch[1]) : undefined);
if (statusCode === 520) {
return 'gateway_520';
}
if (
lower.includes('token authentication failed') ||
lower.includes('invalid token') ||
lower.includes('token expired') ||
lower.includes('authentication failed') ||
lower.includes('auth failed') ||
lower.includes('unauthorized')
) {
return 'token_auth_failed';
}
if (lower.includes('auth not set') || lower.includes('please login first')) {
return 'auth_not_set';
}
if (lower.includes('login timeout')) {
return 'login_timeout';
}
return value;
}
function resolveErrorReason(error: unknown, seen = new Set<unknown>()): string {
if (error === undefined || error === null || seen.has(error)) {
return 'unknown_error';
}
seen.add(error);
const maybeError = error as any;
const responseData = maybeError?.response?.data;
const responseStatus = maybeError?.response?.status;
const responseReason = normalizeReason(
responseDataMessage(responseData),
responseStatus,
);
if (responseReason) {
return responseReason;
}
if (maybeError?.cause) {
const causeReason = resolveErrorReason(maybeError.cause, seen);
if (causeReason && causeReason !== 'unknown_error') {
return causeReason;
}
}
const messageReason = normalizeReason(maybeError?.message, responseStatus);
if (messageReason) {
return messageReason;
}
const stringReason = normalizeReason(maybeError?.toString?.(), responseStatus);
return stringReason || 'unknown_error';
}
function resolveTrackAction(event: string, data: TrackData = {}): string {
const explicitAction = data.a;
if (typeof explicitAction === 'string' && explicitAction.trim()) {
return explicitAction.trim();
}
if (ACTION_OVERRIDES[event]) {
return ACTION_OVERRIDES[event];
}
if (event.endsWith('_success')) {
return 'success';
}
if (event.endsWith('_failed') || event.endsWith('_fail')) {
return 'fail';
}
if (event.includes('click') || event.includes('copied')) {
return 'click';
}
if (event.includes('exposure')) {
return 'exposure';
}
if (event.includes('view')) {
return 'view';
}
if (event.endsWith('_started') || event.endsWith('_submit')) {
return 'submit';
}
return 'view';
}
function resolveTrackEvent(event: string): string {
return EV_OVERRIDES[event] || event;
}
function resolveTrackReason(
data: TrackData,
): string | undefined {
return sanitizeTrackValue(data.re || data.reason, TRACK_REASON_LIMIT);
}
interface ProjectContext {
projectName?: string;
projectDir?: string;
}
let cachedProjectContext: ProjectContext | null = null;
let cachedProjectContextCwd: string | null = null;
function resolveProjectContext(): ProjectContext {
const cwd = process.cwd();
if (cachedProjectContext && cachedProjectContextCwd === cwd) {
return cachedProjectContext;
}
const context: ProjectContext = {};
const configPath = path.join(cwd, 'pinme.toml');
if (fs.existsSync(configPath)) {
try {
const configContent = fs.readFileSync(configPath, 'utf8');
const projectNameMatch = configContent.match(
/project_name\s*=\s*"([^"]+)"/,
);
context.projectName =
sanitizeTrackValue(projectNameMatch?.[1]) ||
sanitizeTrackValue(process.env.PINME_PROJECT_NAME);
context.projectDir = sanitizeTrackValue(path.basename(cwd));
} catch (_) {
context.projectName = sanitizeTrackValue(process.env.PINME_PROJECT_NAME);
}
} else {
context.projectName = sanitizeTrackValue(process.env.PINME_PROJECT_NAME);
}
cachedProjectContext = context;
cachedProjectContextCwd = cwd;
return context;
}
export function getPathKind(pathValue: string): string {
try {
const stat = fs.statSync(pathValue);
if (stat.isDirectory()) {
return 'directory';
}
if (stat.isFile()) {
return 'file';
}
} catch (_) {
return 'unknown';
}
return 'unknown';
}
class Tracker {
private static instance: Tracker;
private readonly gateway: string;
private readonly product: string;
private readonly source: string | undefined;
private readonly disabled: boolean;
private constructor(gateway?: string, product?: string) {
this.gateway = trimTrailingSlash(
gateway || process.env.PINME_TRACKER_GATEWAY || DEFAULT_GATEWAY,
);
this.product = product || DEFAULT_PRODUCT;
this.source = sanitizeTrackValue(process.env.PINME_TRACK_SOURCE);
this.disabled = shouldDisableTracking();
}
public static getInstance(gateway?: string, product?: string): Tracker {
if (!Tracker.instance) {
Tracker.instance = new Tracker(gateway, product);
}
return Tracker.instance;
}
public trackEvent(
event: string,
page: string,
data: TrackData = {},
): Promise<void> {
if (this.disabled || !this.gateway) {
return Promise.resolve();
}
try {
const payload = this.buildPayload(event, page, data);
const params = new URLSearchParams(payload).toString();
const url = `${this.gateway}/track.gif?${params}`;
this.dispatch(url);
} catch (_) {
// Tracking is best-effort and must never interrupt CLI flows.
}
return Promise.resolve();
}
private buildPayload(
event: string,
page: string,
data: TrackData,
): Record<string, string> {
const projectContext = resolveProjectContext();
const action = resolveTrackAction(event, data);
const ev = resolveTrackEvent(event);
const payload: TrackData = {
...data,
u: getUid(),
s: this.source,
pd: this.product,
p: page,
a: action,
ev,
event,
re: resolveTrackReason(data),
project_name: projectContext.projectName || data.project_name,
project_dir: projectContext.projectDir,
cli_version: version,
node_version: process.version,
os: os.platform(),
arch: os.arch(),
};
const filtered: Record<string, string> = {};
for (const [key, value] of Object.entries(payload)) {
const normalized = sanitizeTrackValue(
value,
key === 're' ? TRACK_REASON_LIMIT : TRACK_VALUE_LIMIT,
);
if (normalized) {
filtered[key] = normalized;
}
}
return filtered;
}
private dispatch(url: string): void {
const child = spawn(process.execPath, ['-e', TRACK_CHILD_SCRIPT, url], {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
child.unref();
}
}
const tracker = Tracker.getInstance();
export default tracker;