Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
248 changes: 193 additions & 55 deletions bin/utils/tracker.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<string, string> = {
cli_login_success: 'success',
Expand All @@ -31,13 +51,6 @@ const ACTION_OVERRIDES: Record<string, string> = {
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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, string | number | boolean> {
const details: Record<string, string | number | boolean> = {};

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, string | number | boolean>,
): 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<string, string> {
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<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;
}

class Tracker {
private static instance: Tracker;
private readonly gateway: string;
Expand Down Expand Up @@ -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);
Expand All @@ -319,47 +498,6 @@ class Tracker {

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,
Expand Down
2 changes: 2 additions & 0 deletions bin/utils/uploadToIpfsSplit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pinme",
"version": "2.0.11",
"version": "2.0.13",
"publishConfig": {
"access": "public"
},
Expand Down
4 changes: 4 additions & 0 deletions test/cli/success.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading