diff --git a/README.md b/README.md index f9384e3..af729a3 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Website: [https://pinme.eth.limo/](https://pinme.eth.limo/) - [Quick Start](#quick-start) - [For AI Agents](#for-ai-agents) - [Installation](#installation) +- [Desktop Client Downloads](#desktop-client-downloads) - [PinMe Project Workflow](#pinme-project-workflow) - [Authentication and Account Commands](#authentication-and-account-commands) - [Static Uploads and IPFS Utilities](#static-uploads-and-ipfs-utilities) @@ -169,6 +170,15 @@ Verify installation: pinme --version ``` +## Desktop Client Downloads + +PinMe Client `0.1.5` is available for macOS Apple Silicon and Windows x64: + +| Platform | Installer | +| --- | --- | +| macOS Apple Silicon | [Download `PinMe_0.1.5_aarch64.dmg`](https://36598aaa.pinme.dev/PinMe_0.1.5_aarch64.dmg) | +| Windows x64 | [Download `PinMe_0.1.5_x64-setup.exe`](https://abb42d6b.pinme.dev/PinMe_0.1.5_x64-setup.exe) | + ## PinMe Project Workflow ### What `create` sets up diff --git a/bin/utils/tracker.ts b/bin/utils/tracker.ts index b6fb991..1f94521 100644 --- a/bin/utils/tracker.ts +++ b/bin/utils/tracker.ts @@ -1,9 +1,7 @@ -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; @@ -14,6 +12,28 @@ const TRACK_VALUE_LIMIT = 200; const TRACK_REASON_LIMIT = 255; const DEFAULT_GATEWAY = 'https://pinme.dev'; const DEFAULT_PRODUCT = 'pinme-cli'; +const DEFAULT_SOURCE = 'cli'; +const TRACK_CONTEXT_KEYS = new Set([ + 'pd', + 'product', + 'p', + 'page_type', + 'ev', + 'event_type', + 'a', + 'action', + 're', + 'request_error', + 'reason', + 'rct', + 'request_cost_time', + 'rr', + 'request_requery', + 's', + 'source', + 'k', + 'keyword', +]); const ACTION_OVERRIDES: Record = { cli_login_success: 'success', @@ -31,13 +51,6 @@ const ACTION_OVERRIDES: Record = { upload_history_failed: 'fail', }; -const EV_OVERRIDES: Record = { - 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); @@ -211,13 +224,21 @@ function resolveTrackAction(event: string, data: TrackData = {}): string { } function resolveTrackEvent(event: string): string { - return EV_OVERRIDES[event] || event; + const explicitEvent = dataStringValue(event); + if (!explicitEvent) { + return 'unknown'; + } + + return explicitEvent + .replace(/_(success|failed|fail|started|submit|viewed|cleared)$/, '') + .replace(/^cli_/, ''); } -function resolveTrackReason( - data: TrackData, +function dataStringValue( + value: unknown, + maxLength = TRACK_VALUE_LIMIT, ): string | undefined { - return sanitizeTrackValue(data.re || data.reason, TRACK_REASON_LIMIT); + return sanitizeTrackValue(value, maxLength); } interface ProjectContext { @@ -276,6 +297,160 @@ export function getPathKind(pathValue: string): string { return 'unknown'; } +function resolveTrackField( + data: TrackData, + shortKey: string, + longKey: string, + fallback?: unknown, +): string | undefined { + return dataStringValue(data[shortKey] ?? data[longKey] ?? fallback); +} + +function collectTrackDetails( + data: TrackData, + projectContext?: ProjectContext, +): Record { + const details: Record = {}; + + const reason = dataStringValue( + data.re ?? data.request_error ?? data.reason, + TRACK_REASON_LIMIT, + ); + if (reason) { + details.reason = reason; + } + + for (const [key, value] of Object.entries(data)) { + if (TRACK_CONTEXT_KEYS.has(key) || value === undefined || value === null) { + continue; + } + details[key] = value; + } + + if (projectContext?.projectName && details.project_name === undefined) { + details.project_name = projectContext.projectName; + } + if (projectContext?.projectDir && details.project_dir === undefined) { + details.project_dir = projectContext.projectDir; + } + if (details.cli_version === undefined) { + details.cli_version = version; + } + + return details; +} + +function stringifyTrackDetails( + details: Record, +): string | undefined { + const entries = Object.entries(details); + if (entries.length === 0) { + return undefined; + } + + const build = (nextEntries: [string, string | number | boolean][]) => + JSON.stringify(Object.fromEntries(nextEntries)); + + let result = build(entries); + if (result.length <= TRACK_REASON_LIMIT) { + return result; + } + + const reasonIndex = entries.findIndex(([key]) => key === 'reason'); + if (reasonIndex >= 0) { + const originalReason = String(entries[reasonIndex][1]); + let low = 0; + let high = originalReason.length; + let best = ''; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const candidateEntries = entries.map((entry, index) => + index === reasonIndex + ? ([entry[0], originalReason.slice(0, mid)] as [string, string]) + : entry, + ); + const candidate = build(candidateEntries); + + if (candidate.length <= TRACK_REASON_LIMIT) { + best = originalReason.slice(0, mid); + low = mid + 1; + } else { + high = mid - 1; + } + } + + entries[reasonIndex] = ['reason', best]; + result = build(entries); + if (result.length <= TRACK_REASON_LIMIT) { + return result; + } + } + + let compactEntries = entries; + while (compactEntries.length > 0) { + const removableIndex = [...compactEntries] + .reverse() + .findIndex(([key]) => key !== 'reason'); + if (removableIndex < 0) { + break; + } + + compactEntries = compactEntries.filter( + (_, index) => index !== compactEntries.length - 1 - removableIndex, + ); + result = build(compactEntries); + if (result.length <= TRACK_REASON_LIMIT) { + return result; + } + } + + return sanitizeTrackValue(result, TRACK_REASON_LIMIT); +} + +export function buildTrackPayload( + event: string, + page: string, + data: TrackData = {}, + options: { + product?: string; + source?: string; + projectContext?: ProjectContext; + } = {}, +): Record { + const payload: TrackData = { + pd: resolveTrackField( + data, + 'pd', + 'product', + options.product || DEFAULT_PRODUCT, + ), + p: resolveTrackField(data, 'p', 'page_type', page), + ev: resolveTrackField(data, 'ev', 'event_type', resolveTrackEvent(event)), + a: resolveTrackField(data, 'a', 'action', resolveTrackAction(event, data)), + re: stringifyTrackDetails( + collectTrackDetails(data, options.projectContext), + ), + rct: resolveTrackField(data, 'rct', 'request_cost_time'), + rr: resolveTrackField(data, 'rr', 'request_requery'), + s: resolveTrackField(data, 's', 'source', options.source || DEFAULT_SOURCE), + k: resolveTrackField(data, 'k', 'keyword'), + }; + + const filtered: Record = {}; + 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; +} + class Tracker { private static instance: Tracker; private readonly gateway: string; @@ -309,7 +484,11 @@ class Tracker { } try { - const payload = this.buildPayload(event, page, data); + const payload = buildTrackPayload(event, page, data, { + product: this.product, + source: this.source, + projectContext: resolveProjectContext(), + }); const params = new URLSearchParams(payload).toString(); const url = `${this.gateway}/track.gif?${params}`; this.dispatch(url); @@ -319,47 +498,6 @@ class Tracker { return Promise.resolve(); } - - private buildPayload( - event: string, - page: string, - data: TrackData, - ): Record { - 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 = {}; - 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, diff --git a/bin/utils/uploadToIpfsSplit.ts b/bin/utils/uploadToIpfsSplit.ts index 1196e71..7bbd3ad 100644 --- a/bin/utils/uploadToIpfsSplit.ts +++ b/bin/utils/uploadToIpfsSplit.ts @@ -398,6 +398,8 @@ async function initChunkSession( md5: md5, is_directory: isDirectory, uid: deviceId, + token_address: authHeaders['token-address'], + auth_token: authHeaders['authentication-tokens'], }; if (projectName) { diff --git a/package.json b/package.json index 0b7cc37..7d4a56d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pinme", - "version": "2.0.11", + "version": "2.0.13", "publishConfig": { "access": "public" }, diff --git a/test/cli/success.test.ts b/test/cli/success.test.ts index defe50a..2275089 100644 --- a/test/cli/success.test.ts +++ b/test/cli/success.test.ts @@ -81,6 +81,8 @@ describe('pinme CLI success paths with local APIs', () => { file_name: 'index.html', is_directory: false, uid: '0x1234567890abcdef', + token_address: '0x1234567890abcdef', + auth_token: 'test-token', }); response.writeHead(200, { 'Content-Type': 'application/json' }); response.end( @@ -119,6 +121,8 @@ describe('pinme CLI success paths with local APIs', () => { uid: '0x1234567890abcdef', action: 'upload', }); + expect(JSON.parse(bodyText)).not.toHaveProperty('token_address'); + expect(JSON.parse(bodyText)).not.toHaveProperty('auth_token'); response.writeHead(200, { 'Content-Type': 'application/json' }); response.end( JSON.stringify({ diff --git a/tests/tracker-error-reason.test.mjs b/tests/tracker-error-reason.test.mjs index 9c8dccf..1bb0480 100644 --- a/tests/tracker-error-reason.test.mjs +++ b/tests/tracker-error-reason.test.mjs @@ -93,3 +93,59 @@ test('getTrackErrorReason normalizes authentication failures', async () => { cleanup(); } }); + +test('buildTrackPayload only emits backend accepted fields and serializes extra data into re', async () => { + const { helper, cleanup } = await loadHelper(); + + try { + const payload = helper.buildTrackPayload('upload_failed', 'cli_upload', { + a: 'fail', + reason: 'network_error', + path_kind: 'file', + has_domain: false, + project_name: 'demo-project', + cli_version: '2.0.11', + }); + + assert.deepEqual(Object.keys(payload).sort(), ['a', 'ev', 'p', 'pd', 're', 's']); + assert.equal(payload.pd, 'pinme-cli'); + assert.equal(payload.p, 'cli_upload'); + assert.equal(payload.ev, 'upload'); + assert.equal(payload.a, 'fail'); + assert.equal(payload.s, 'cli'); + assert.equal(typeof payload.re, 'string'); + + const re = JSON.parse(payload.re); + assert.deepEqual(re, { + reason: 'network_error', + path_kind: 'file', + has_domain: false, + project_name: 'demo-project', + cli_version: '2.0.11', + }); + } finally { + cleanup(); + } +}); + +test('buildTrackPayload keeps serialized re valid JSON within 255 chars', async () => { + const { helper, cleanup } = await loadHelper(); + + try { + const payload = helper.buildTrackPayload('upload_failed', 'cli_upload', { + reason: 'x'.repeat(500), + path_kind: 'file', + has_domain: false, + project_name: 'demo-project', + }); + + assert.ok(payload.re.length <= 255); + const re = JSON.parse(payload.re); + assert.equal(re.path_kind, 'file'); + assert.equal(re.has_domain, false); + assert.equal(re.project_name, 'demo-project'); + assert.ok(re.reason.length < 500); + } finally { + cleanup(); + } +});