chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:28 +08:00
commit ba73d7ef36
160 changed files with 37714 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { createApiError } from './cliError';
import { getAuthHeaders } from './webLogin';
import { APP_CONFIG } from './config';
interface CreateApiClientOptions {
baseURL?: string;
includeAuth?: boolean;
timeout?: number;
headers?: Record<string, string>;
}
function safeGetAuthHeaders(): Record<string, string> {
try {
return getAuthHeaders();
} catch (error) {
return {};
}
}
function hasBusinessCode(data: any): boolean {
return Boolean(data) && typeof data === 'object' && 'code' in data;
}
function isSuccessfulBusinessCode(code: unknown): boolean {
return String(code) === '200';
}
function getRequestDescriptor(config?: AxiosRequestConfig): string | undefined {
if (!config?.url) {
return undefined;
}
const method = (config.method || 'GET').toUpperCase();
return `${method} ${config.url}`;
}
function buildErrorContext(config?: AxiosRequestConfig): string[] {
const descriptor = getRequestDescriptor(config);
return descriptor ? [`Request: ${descriptor}`] : [];
}
function normalizeBusinessError(response: AxiosResponse): never {
throw createApiError(
'API request',
{ response, config: response.config },
buildErrorContext(response.config),
);
}
export function createApiClient(
options: CreateApiClientOptions = {},
): AxiosInstance {
const {
baseURL = APP_CONFIG.pinmeApiBase,
includeAuth = true,
timeout = 20000,
headers = {},
} = options;
const client = axios.create({
baseURL,
timeout,
headers: {
...(includeAuth ? safeGetAuthHeaders() : {}),
Accept: '*/*',
'Content-Type': 'application/json',
'User-Agent': 'Pinme-CLI',
Connection: 'keep-alive',
...headers,
},
});
client.interceptors.response.use(
(response) => {
if (
hasBusinessCode(response.data)
&& !isSuccessfulBusinessCode(response.data.code)
) {
normalizeBusinessError(response);
}
return response;
},
(error) => Promise.reject(
createApiError(
'API request',
error,
buildErrorContext(error?.config),
),
),
);
return client;
}
export function createPinmeApiClient(
options: Omit<CreateApiClientOptions, 'baseURL'> = {},
): AxiosInstance {
return createApiClient({
...options,
baseURL: APP_CONFIG.pinmeApiBase,
});
}
export function createCarApiClient(
options: Omit<CreateApiClientOptions, 'baseURL'> = {},
): AxiosInstance {
return createApiClient({
...options,
baseURL: APP_CONFIG.carApiBase,
});
}
+71
View File
@@ -0,0 +1,71 @@
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
const CONFIG_DIR = path.join(os.homedir(), '.pinme');
const AUTH_FILE = path.join(CONFIG_DIR, 'auth.json');
export interface AuthConfig {
address: string;
token: string;
}
function ensureConfigDir(): void {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
}
export function parseCombinedToken(combined: string): AuthConfig {
// combined format: "<address>-<jwt>"
// Split only at the first '-' to preserve '-' inside JWT if any.
const firstDash = combined.indexOf('-');
if (firstDash <= 0 || firstDash === combined.length - 1) {
throw new Error('Invalid token format. Expected "<address>-<jwt>".');
}
const address = combined.slice(0, firstDash).trim();
const token = combined.slice(firstDash + 1).trim();
if (!address || !token) {
throw new Error('Invalid token content. Address or token is empty.');
}
return { address, token };
}
export function setAuthToken(combined: string): AuthConfig {
ensureConfigDir();
const auth = parseCombinedToken(combined);
fs.writeJsonSync(AUTH_FILE, auth, { spaces: 2 });
return auth;
}
export function clearAuthToken(): void {
try {
if (fs.existsSync(AUTH_FILE)) {
fs.removeSync(AUTH_FILE);
}
} catch (error) {
console.error(`Failed to clear auth token: ${error}`);
}
}
export function getAuthConfig(): AuthConfig | null {
try {
if (!fs.existsSync(AUTH_FILE)) return null;
const data = fs.readJsonSync(AUTH_FILE) as AuthConfig;
if (!data?.address || !data?.token) return null;
return data;
} catch {
return null;
}
}
export function getAuthHeaders(): Record<string, string> {
const conf = getAuthConfig();
if (!conf) {
throw new Error('Auth not set. Run: pinme set-appkey <AppKey>');
}
return {
'token-address': conf.address,
'authentication-tokens': conf.token,
};
}
+62
View File
@@ -0,0 +1,62 @@
import chalk from 'chalk';
const MIN_NODE_VERSION = '16.13.0';
interface Version {
major: number;
minor: number;
patch: number;
}
function parseVersion(version: string): Version {
const parts = version.replace(/^v/, '').split('.').map(Number);
return {
major: parts[0] || 0,
minor: parts[1] || 0,
patch: parts[2] || 0,
};
}
function compareVersions(version1: Version, version2: Version): number {
if (version1.major !== version2.major) {
return version1.major - version2.major;
}
if (version1.minor !== version2.minor) {
return version1.minor - version2.minor;
}
return version1.patch - version2.patch;
}
export function checkNodeVersion(): void {
const currentVersion = process.version;
const current = parseVersion(currentVersion);
const minimum = parseVersion(MIN_NODE_VERSION);
if (compareVersions(current, minimum) < 0) {
console.error(chalk.red('❌ Node.js version requirement not met'));
console.error(chalk.yellow(`Current version: ${currentVersion}`));
console.error(chalk.yellow(`Required version: >= ${MIN_NODE_VERSION}`));
console.error('');
console.error(chalk.cyan('Please upgrade Node.js to version 16.13.0 or higher.'));
console.error(chalk.cyan('Download from: https://nodejs.org/'));
console.error('');
process.exit(1);
}
}
export function checkNodeVersionWithMessage(): void {
const currentVersion = process.version;
const current = parseVersion(currentVersion);
const minimum = parseVersion(MIN_NODE_VERSION);
if (compareVersions(current, minimum) < 0) {
console.log(chalk.red('❌ Node.js version requirement not met'));
console.log(chalk.yellow(`Current version: ${currentVersion}`));
console.log(chalk.yellow(`Required version: >= ${MIN_NODE_VERSION}`));
console.log('');
console.log(chalk.cyan('Please upgrade Node.js to version 16.13.0 or higher.'));
console.log(chalk.cyan('Download from: https://nodejs.org/'));
console.log('');
process.exit(1);
}
}
+54
View File
@@ -0,0 +1,54 @@
import fs from 'fs';
import path from 'path';
import Inquirer from "inquirer";
// Check if the path exists and return the absolute path
function checkPath(inputPath: string): string | null {
try {
// Convert to absolute path
const absolutePath = path.resolve(inputPath);
// Check if the path exists
if (fs.existsSync(absolutePath)) {
return absolutePath;
}
return null;
} catch (error: any) {
console.error(`Error checking path: ${error.message}`);
return null;
}
}
export default async function(projectName: string): Promise<boolean> {
// Get the current working directory
const cwd = process.cwd();
// Get the project directory
const targetDirectory = path.join(cwd, projectName);
// Check if the directory exists
if (fs.existsSync(targetDirectory)) {
let { isOverwrite } = await Inquirer.prompt([
{
name: "isOverwrite", // Corresponding to the return value
type: "list", // List type
message: "Target directory exists, Please choose an action",
choices: [
{ name: "Overwrite", value: true },
{ name: "Cancel", value: false },
],
},
]);
// Choose Cancel
if (!isOverwrite) {
console.log("\n Canceled \n");
return false;
} else {
// Choose Overwrite, delete the existing directory
console.log("Removing folder...");
await fs.promises.rm(targetDirectory, { recursive: true, force: true });
console.log("Folder removed!");
return true
}
} else {
return true;
}
};
+276
View File
@@ -0,0 +1,276 @@
import chalk from 'chalk';
import { getWalletRechargeUrl } from './config';
interface CliErrorOptions {
summary: string;
stage?: string;
details?: string[];
suggestions?: string[];
cause?: unknown;
}
export class CliError extends Error {
stage?: string;
details: string[];
suggestions: string[];
cause?: unknown;
constructor(options: CliErrorOptions) {
super(options.summary);
this.name = 'CliError';
this.stage = options.stage;
this.details = options.details || [];
this.suggestions = options.suggestions || [];
this.cause = options.cause;
}
}
function stringifyValue(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value);
} catch (error) {
return String(value);
}
}
function getApiMessage(data: any): string | undefined {
if (typeof data === 'string') {
return data;
}
return data?.msg
|| data?.message
|| data?.data?.msg
|| data?.data?.message
|| data?.data?.error
|| data?.errors?.[0]?.message
|| data?.error;
}
function getApiDetailMessage(data: any): string | undefined {
if (typeof data === 'string') {
return data;
}
return data?.data?.error
|| data?.data?.msg
|| data?.data?.message
|| data?.errors?.[0]?.message
|| data?.error;
}
function getBusinessCode(data: any): string | undefined {
if (data?.code === undefined || data?.code === null) {
return undefined;
}
return String(data.code);
}
function getBusinessMessage(data: any): string | undefined {
if (!data?.msg) {
return undefined;
}
return String(data.msg);
}
function dedupeSuggestions(suggestions: string[]): string[] {
return Array.from(new Set(suggestions.filter(Boolean)));
}
function getRechargeUrl(detail: string): string | null {
const prefix = 'Recharge URL: ';
if (!detail.startsWith(prefix)) {
return null;
}
return detail.slice(prefix.length).trim() || null;
}
export function printRechargeUrl(url: string, useErrorStream: boolean = false): void {
const output = useErrorStream ? console.error : console.log;
output('');
output(chalk.yellowBright.bold('Recharge URL:'));
output(chalk.blueBright.bold.underline(url));
}
function isInsufficientBalanceError(
businessCode: string | undefined,
...messages: Array<string | undefined>
): boolean {
if (businessCode === '40001') {
return true;
}
const combined = messages
.filter(Boolean)
.join(' ')
.toLowerCase();
return combined.includes('insufficient balance')
|| combined.includes('insufficient wallet balance');
}
export function createConfigError(summary: string, suggestions: string[] = []): CliError {
return new CliError({
summary,
stage: 'configuration',
suggestions,
});
}
export function createCommandError(stage: string, command: string, error: any, suggestions: string[] = []): CliError {
const exitCode = error?.status ?? error?.code;
const signal = error?.signal;
const detailLines = [`Command: ${command}`];
if (exitCode !== undefined) {
detailLines.push(`Exit code: ${exitCode}`);
}
if (signal) {
detailLines.push(`Signal: ${signal}`);
}
if (error?.message) {
detailLines.push(`Reason: ${error.message}`);
}
return new CliError({
summary: `${stage} failed.`,
stage,
details: detailLines,
suggestions,
cause: error,
});
}
export function createApiError(stage: string, error: any, context: string[] = [], suggestions: string[] = []): CliError {
const status = error?.response?.status;
const responseData = error?.response?.data;
const errorCode = error?.code;
const rawMessage = error?.message;
const apiMessage = getApiMessage(responseData);
const apiDetailMessage = getApiDetailMessage(responseData);
const businessCode = getBusinessCode(responseData);
const businessMessage = getBusinessMessage(responseData);
const hasInsufficientBalanceError = isInsufficientBalanceError(
businessCode,
apiMessage,
apiDetailMessage,
businessMessage,
rawMessage,
);
const summary = apiMessage
|| businessMessage
|| apiDetailMessage
|| rawMessage
|| `${stage} failed.`;
const detailLines = [...context];
const hasBusinessError = Boolean(businessCode);
if (businessCode) {
detailLines.push(`Business code: ${businessCode}`);
}
if (status && !hasBusinessError) {
detailLines.push(`HTTP status: ${status}`);
}
if (businessMessage && businessMessage !== summary) {
detailLines.push(`Business message: ${businessMessage}`);
}
if (apiDetailMessage && apiDetailMessage !== summary && apiDetailMessage !== businessMessage) {
detailLines.push(`Error detail: ${apiDetailMessage}`);
}
if (apiMessage && apiMessage !== summary && apiMessage !== apiDetailMessage) {
detailLines.push(`Error message: ${apiMessage}`);
}
if (hasInsufficientBalanceError) {
detailLines.push(`Recharge URL: ${getWalletRechargeUrl()}`);
}
if (errorCode && errorCode !== 'ERR_BAD_REQUEST' && !responseData) {
detailLines.push(`Error code: ${errorCode}`);
}
const isGenericAxiosStatusMessage = typeof rawMessage === 'string'
&& /^Request failed with status code \d{3}$/.test(rawMessage);
if (rawMessage && rawMessage !== summary && !(responseData && isGenericAxiosStatusMessage)) {
detailLines.push(`Reason: ${rawMessage}`);
}
return new CliError({
summary,
stage,
details: detailLines,
suggestions: dedupeSuggestions(suggestions),
cause: error,
});
}
export function normalizeCliError(error: unknown, fallbackSummary: string, suggestions: string[] = []): CliError {
if (error instanceof CliError) {
return error;
}
if (typeof error === 'object' && error !== null) {
const maybeApiError = error as Record<string, any>;
if (maybeApiError.response || maybeApiError.config) {
return createApiError('API request', maybeApiError, [], suggestions);
}
}
if (error instanceof Error) {
return new CliError({
summary: error.message || fallbackSummary,
suggestions: dedupeSuggestions(suggestions),
cause: error,
});
}
return new CliError({
summary: fallbackSummary,
details: [`Raw error: ${stringifyValue(error)}`],
suggestions: dedupeSuggestions(suggestions),
cause: error,
});
}
export function printCliError(error: unknown, fallbackSummary: string): void {
const cliError = normalizeCliError(error, fallbackSummary);
console.error(chalk.red(`\nError: ${cliError.message}`));
if (cliError.stage) {
console.error(chalk.gray(`Stage: ${cliError.stage}`));
}
for (const detail of cliError.details) {
const rechargeUrl = getRechargeUrl(detail);
if (rechargeUrl) {
printRechargeUrl(rechargeUrl, true);
continue;
}
console.error(chalk.gray(detail));
}
if (cliError.suggestions.length > 0) {
console.error(chalk.yellow('\nNext steps:'));
for (const suggestion of cliError.suggestions) {
console.error(chalk.yellow(`- ${suggestion}`));
}
}
}
+78
View File
@@ -0,0 +1,78 @@
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, '');
}
function readNumberEnv(name: string, fallback: number): number {
const rawValue = process.env[name];
if (!rawValue) {
return fallback;
}
const parsed = Number.parseInt(rawValue, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
const DEFAULT_PINME_WEB_URL = 'http://localhost:5173';
const PROD_WALLET_RECHARGE_URL = 'https://pinme.eth.limo/#/profile?tab=wallet';
const TEST_WALLET_RECHARGE_URL =
'https://test-pinme.pinit.eth.limo/#/profile?tab=wallet';
export const APP_CONFIG = {
pinmeApiBase: trimTrailingSlash(process.env.PINME_API_BASE || ''),
ipfsApiUrl: trimTrailingSlash(process.env.IPFS_API_URL || ''),
carApiBase: trimTrailingSlash(
process.env.CAR_API_BASE ||
process.env.IPFS_API_URL ||
'http://ipfs-proxy.opena.chat/api/v3',
),
pinmeWebUrl: trimTrailingSlash(
process.env.PINME_WEB_URL || DEFAULT_PINME_WEB_URL,
),
pinmeCheckDomainPath: process.env.PINME_CHECK_DOMAIN_PATH || '/check_domain',
ipfsPreviewUrl: process.env.IPFS_PREVIEW_URL || '',
projectPeviewUrl: process.env.PROJECT_PREVIEW_URL || '',
secretKey: process.env.SECRET_KEY,
pinmeProjectName: process.env.PINME_PROJECT_NAME?.trim(),
upload: {
maxRetries: readNumberEnv('MAX_RETRIES', 2),
retryDelayMs: readNumberEnv('RETRY_DELAY_MS', 1000),
timeoutMs: readNumberEnv('TIMEOUT_MS', 600000),
maxPollTimeMs: readNumberEnv('MAX_POLL_TIME_MINUTES', 5) * 60 * 1000,
pollIntervalMs: readNumberEnv('POLL_INTERVAL_SECONDS', 2) * 1000,
pollTimeoutMs: readNumberEnv('POLL_TIMEOUT_SECONDS', 10) * 1000,
},
};
export function getPinmeApiUrl(pathname: string): string {
const normalizedPath = pathname.startsWith('/') ? pathname : `/${pathname}`;
return `${APP_CONFIG.pinmeApiBase}${normalizedPath}`;
}
export function getIpfsApiUrl(pathname: string): string {
const normalizedPath = pathname.startsWith('/') ? pathname : `/${pathname}`;
return `${APP_CONFIG.ipfsApiUrl}${normalizedPath}`;
}
export function getCarApiUrl(pathname: string): string {
const normalizedPath = pathname.startsWith('/') ? pathname : `/${pathname}`;
return `${APP_CONFIG.carApiBase}${normalizedPath}`;
}
function includesAny(value: string, markers: string[]): boolean {
return markers.some((marker) => value.includes(marker));
}
export function getWalletRechargeUrl(): string {
const candidates = [APP_CONFIG.ipfsApiUrl].filter(Boolean);
if (
candidates.some((candidate) =>
includesAny(candidate, ['test-pinme', 'localhost:5173', 'benny1996.win']),
)
) {
return TEST_WALLET_RECHARGE_URL;
}
return PROD_WALLET_RECHARGE_URL;
}
+47
View File
@@ -0,0 +1,47 @@
import CryptoJS from "crypto-js";
export interface DecryptedHash {
contentHash: string;
uid?: string; // address (if logged in) or deviceId (if not logged in)
version: number; // 1 if old version (no '-' separator), 2 if new version (contentHash-uid)
}
export const decryptHash = (encryptedHash: string | undefined, key: string): DecryptedHash | null => {
try {
if (!encryptedHash) {
return null;
}
let base64 = encryptedHash.replace(/-/g, "+").replace(/_/g, "/");
while (base64.length % 4) {
base64 += "=";
}
const decrypted = CryptoJS.RC4.decrypt(base64, key);
const decryptedStr = decrypted.toString(CryptoJS.enc.Utf8);
// Check if it contains '-' separator (new version: contentHash-uid)
const dashIndex = decryptedStr.indexOf('-');
if (dashIndex > 0 && dashIndex < decryptedStr.length - 1) {
// New version: split into contentHash and uid (address or deviceId)
const contentHash = decryptedStr.slice(0, dashIndex);
const uid = decryptedStr.slice(dashIndex + 1);
return {
contentHash,
uid,
version: 2,
};
} else {
// Legacy version: only contentHash, no separator
return {
contentHash: decryptedStr,
version: 1,
};
}
} catch (error: any) {
console.error(`Decryption error: ${error.message}`);
// Return as legacy format if decryption fails
return encryptedHash ? {
contentHash: encryptedHash,
version: 1,
} : null;
}
};
+63
View File
@@ -0,0 +1,63 @@
export interface DomainValidationResult {
valid: boolean;
message?: string;
}
export function normalizeDomain(domain: string): string {
return domain.replace(/^https?:\/\//, '').replace(/\/$/, '');
}
export function isDnsDomain(domain: string): boolean {
return normalizeDomain(domain).includes('.');
}
export function validateDnsDomain(domain: string): DomainValidationResult {
const cleanDomain = normalizeDomain(domain);
const domainRegex =
/^[a-zA-Z0-9][a-zA-Z0-9-]*(\.[a-zA-Z0-9][a-zA-Z0-9-]*)*\.[a-zA-Z]{2,}$/;
const parts = cleanDomain.split('.');
if (parts.length < 2) {
return {
valid: false,
message:
'Invalid domain format. Please enter a complete domain (e.g., example.com)',
};
}
for (const part of parts) {
if (part.length === 0) {
return {
valid: false,
message: 'Invalid domain format. Consecutive dots are not allowed',
};
}
if (part.length > 63) {
return {
valid: false,
message:
'Invalid domain format. Each label must be 63 characters or less',
};
}
if (!/^[a-zA-Z0-9-]+$/.test(part)) {
return {
valid: false,
message:
'Invalid domain format. Domains can only contain letters, numbers, and hyphens',
};
}
if (/^-|-$/.test(part)) {
return {
valid: false,
message:
'Invalid domain format. Labels cannot start or end with hyphens',
};
}
}
if (!domainRegex.test(cleanDomain)) {
return { valid: false, message: 'Invalid domain format' };
}
return { valid: true };
}
+105
View File
@@ -0,0 +1,105 @@
import axios from 'axios';
import fs from 'fs-extra';
import path from 'path';
import { createWriteStream } from 'fs';
import { pipeline } from 'stream';
import { promisify } from 'util';
const pipelineAsync = promisify(pipeline);
export interface DownloadFileWithRetriesOptions {
attempts?: number;
retryDelayMs?: number;
minBytes?: number;
timeoutMs?: number;
request?: (
url: string,
options: { timeoutMs: number; headers: Record<string, string> },
) => Promise<{ data: NodeJS.ReadableStream }>;
onAttempt?: (attempt: number, attempts: number) => void;
onAttemptFailure?: (attempt: number, error: unknown) => void;
}
export interface DownloadFileResult {
attempts: number;
bytes: number;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function getDownloadErrorMessage(error: any): string {
const status = error?.response?.status;
const statusText = error?.response?.statusText;
if (status) {
return `HTTP ${status}${statusText ? ` ${statusText}` : ''}`;
}
if (error?.code && error?.message) {
return `${error.code}: ${error.message}`;
}
return error?.message || String(error);
}
async function requestDownload(
url: string,
options: { timeoutMs: number; headers: Record<string, string> },
): Promise<{ data: NodeJS.ReadableStream }> {
return axios.get(url, {
responseType: 'stream',
timeout: options.timeoutMs,
headers: options.headers,
});
}
export async function downloadFileWithRetries(
url: string,
destinationPath: string,
options: DownloadFileWithRetriesOptions = {},
): Promise<DownloadFileResult> {
const attempts = options.attempts ?? 3;
const retryDelayMs = options.retryDelayMs ?? 2000;
const minBytes = options.minBytes ?? 1;
const timeoutMs = options.timeoutMs ?? 120000;
const request = options.request ?? requestDownload;
const headers = {
'User-Agent': 'pinme-cli',
};
let lastError: unknown;
fs.ensureDirSync(path.dirname(destinationPath));
fs.removeSync(destinationPath);
for (let attempt = 1; attempt <= attempts; attempt++) {
const tempPath = `${destinationPath}.download-${process.pid}-${Date.now()}-${attempt}.tmp`;
try {
options.onAttempt?.(attempt, attempts);
const response = await request(url, { timeoutMs, headers });
await pipelineAsync(response.data, createWriteStream(tempPath));
const bytes = fs.statSync(tempPath).size;
if (bytes < minBytes) {
throw new Error(`Downloaded file is too small (${bytes} bytes; expected at least ${minBytes} bytes).`);
}
fs.moveSync(tempPath, destinationPath, { overwrite: true });
return { attempts: attempt, bytes };
} catch (error) {
lastError = error;
fs.removeSync(tempPath);
options.onAttemptFailure?.(attempt, error);
if (attempt < attempts) {
await sleep(retryDelayMs);
}
}
}
throw new Error(`Failed to download ${url} after ${attempts} attempts: ${getDownloadErrorMessage(lastError)}`);
}
+30
View File
@@ -0,0 +1,30 @@
import fs from 'fs-extra';
import path from 'path';
import os from 'os';
import { v4 as uuidv4 } from 'uuid';
import { getAuthConfig } from './webLogin';
export function getDeviceId(): string {
const configDir = path.join(os.homedir(), '.pinme');
const configFile = path.join(configDir, 'device-id');
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
if (fs.existsSync(configFile)) {
return fs.readFileSync(configFile, 'utf8').trim();
}
const deviceId = uuidv4();
fs.writeFileSync(configFile, deviceId);
return deviceId;
}
// Get uid: use address from auth if logged in, otherwise use deviceId
export function getUid(): string {
const auth = getAuthConfig();
if (auth?.address) {
return auth.address;
}
return getDeviceId();
}
+294
View File
@@ -0,0 +1,294 @@
import chalk from 'chalk';
import figlet from 'figlet';
// show ASCII art banner
function showBanner(): void {
console.log(
chalk.cyan(
figlet.textSync("Pinme CLI", { horizontalLayout: "full" })
)
);
console.log(chalk.cyan("A command-line tool for uploading files to IPFS\n"));
}
// general help
function showGeneralHelp(): void {
showBanner();
console.log("USAGE:");
console.log(" pinme [command] [options]\n");
console.log("COMMANDS:");
console.log(" upload Upload a file or directory to IPFS");
console.log(" bind Upload and bind to a domain (requires wallet balance)");
console.log(" login Login via browser (recommended)");
console.log(" list Show upload history");
console.log(" ls Alias for 'list' command");
console.log(" set-appkey Set AppKey for authentication (alternative)");
console.log(" show-appkey Show current AppKey information (masked)");
console.log(" appkey Alias for 'show-appkey' command");
console.log(" wallet Show current wallet balance");
console.log(" wallet-balance Alias for 'wallet' command");
console.log(" balance Alias for 'wallet' command");
console.log(" logout Log out and clear authentication");
console.log(" help [command] Show help for a specific command\n");
console.log("OPTIONS:");
console.log(" -v, --version Output the current version");
console.log(" -h, --help Display help for command\n");
console.log("For more information on a specific command, try:");
console.log(" pinme help [command]");
}
// bind command help
function showBindHelp(): void {
console.log("COMMAND:");
console.log(" bind - Upload and bind to a domain (requires wallet balance)\n");
console.log("USAGE:");
console.log(" pinme bind <path> [options]\n");
console.log("DESCRIPTION:");
console.log(" This command uploads files and binds them to a custom domain.");
console.log(" Domain binding deducts from your wallet balance.\n");
console.log("OPTIONS:");
console.log(" -d, --domain <name> Domain name to bind (required)");
console.log(" --dns Force DNS domain mode (optional, auto-detected)\n");
console.log("EXAMPLES:");
console.log(" # Interactive mode (will prompt for path and domain)");
console.log(" pinme bind\n");
console.log(" # Bind to a Pinme subdomain (auto-detected: no dot in domain)");
console.log(" pinme bind ./dist --domain my-site\n");
console.log(" # Bind to a DNS domain (auto-detected: contains dot)");
console.log(" pinme bind ./dist --domain example.com\n");
console.log(" # Force DNS mode with --dns flag");
console.log(" pinme bind ./dist --domain my-site --dns\n");
console.log("AUTO-DETECTION:");
console.log(" - Domains with a dot (e.g., example.com) are treated as DNS domains");
console.log(" - Domains without a dot (e.g., my-site) are treated as Pinme subdomains");
console.log(" - Use --dns flag to force DNS domain mode\n");
console.log("REQUIREMENTS:");
console.log(" - Sufficient wallet balance is required for domain binding");
console.log(" - Valid AppKey must be set (run: pinme set-appkey <AppKey>)");
console.log(" - For DNS domains, you must own the domain\n");
console.log("URL FORMATS:");
console.log(" - Pinme subdomain: https://<name>.pinit.eth.limo");
console.log(" - DNS domain: https://<your-domain>.com\n");
console.log("DNS SETUP:");
console.log(" After successful DNS binding, visit:");
console.log(" https://pinme.eth.limo/#/docs?id=custom-domain");
console.log(" for DNS configuration guide.\n");
}
// upload command help
function showUploadHelp(): void {
console.log("COMMAND:");
console.log(" upload - Upload a file or directory to IPFS\n");
console.log("USAGE:");
console.log(" pinme upload [path] [options]\n");
console.log("DESCRIPTION:");
console.log(" This command uploads files or directories to IPFS.");
console.log(" If no path is provided, it will start in interactive mode.");
console.log(" Supports --domain option to bind a custom domain after upload (requires wallet balance).\n");
console.log("OPTIONS:");
console.log(" -d, --domain <name> Domain name to bind (optional, requires wallet balance)");
console.log(" --dns Force DNS domain mode (optional, auto-detected)\n");
console.log("EXAMPLES:");
console.log(" # Upload without binding");
console.log(" pinme upload");
console.log(" pinme upload ./my-website\n");
console.log(" # Upload and bind to a Pinme subdomain (auto-detected: no dot)");
console.log(" pinme upload ./dist --domain my-site\n");
console.log(" # Upload and bind to a DNS domain (auto-detected: contains dot)");
console.log(" pinme upload ./dist --domain example.com\n");
console.log(" # Force DNS mode with --dns flag");
console.log(" pinme upload ./dist --domain my-site --dns\n");
console.log("AUTO-DETECTION:");
console.log(" - Domains with a dot (e.g., example.com) are treated as DNS domains");
console.log(" - Domains without a dot (e.g., my-site) are treated as Pinme subdomains");
console.log(" - Use --dns flag to force DNS domain mode\n");
console.log("REQUIREMENTS:");
console.log(" - Sufficient wallet balance is required for domain binding");
console.log(" - Valid AppKey must be set (run: pinme login)");
console.log(" - For DNS domains, you must own the domain\n");
console.log("LIMITATIONS:");
console.log(" - Maximum file size: 10MB");
console.log(" - Maximum directory size: 500MB");
}
// login command help
function showLoginHelp(): void {
console.log("COMMAND:");
console.log(" login - Login via browser (recommended)\n");
console.log("USAGE:");
console.log(" pinme login\n");
console.log("DESCRIPTION:");
console.log(" This command opens the PinMe website in your browser for authentication.");
console.log(" After logging in, your token will be automatically saved.\n");
console.log("EXAMPLES:");
console.log(" pinme login\n");
console.log("HOW IT WORKS:");
console.log(" 1. CLI starts a local server");
console.log(" 2. Opens browser to PinMe login page");
console.log(" 3. User logs in on the website");
console.log(" 4. Website redirects back to CLI with token");
console.log(" 5. Token is saved locally\n");
console.log("NOTE:");
console.log(" - If you prefer manual input, use: pinme set-appkey <AppKey>");
console.log(" - Get your AppKey from: https://pinme.eth.limo/\n");
}
// list command help
function showListHelp(): void {
console.log("COMMAND:");
console.log(" list - Show upload history\n");
console.log("USAGE:");
console.log(" pinme list [options]\n");
console.log("OPTIONS:");
console.log(" -l, --limit <number> Limit the number of records to show");
console.log(" -c, --clear Clear all upload history\n");
console.log("EXAMPLES:");
console.log(" pinme list");
console.log(" pinme list -l 5");
console.log(" pinme list -c");
}
// ls command help (can reuse the list command help)
function showLsHelp(): void {
console.log("COMMAND:");
console.log(" ls - Alias for 'list' command\n");
console.log("USAGE:");
console.log(" pinme ls [options]\n");
console.log("OPTIONS:");
console.log(" -l, --limit <number> Limit the number of records to show");
console.log(" -c, --clear Clear all upload history\n");
console.log("EXAMPLES:");
console.log(" pinme ls");
console.log(" pinme ls -l 5");
console.log(" pinme ls -c");
}
// show-appkey command help
function showShowAppKeyHelp(): void {
console.log("COMMAND:");
console.log(" show-appkey - Show current AppKey information (masked)\n");
console.log("USAGE:");
console.log(" pinme show-appkey");
console.log(" pinme appkey\n");
console.log("DESCRIPTION:");
console.log(" This command displays the current AppKey information.");
console.log(" Sensitive information (token and AppKey) will be masked for security.\n");
console.log("EXAMPLES:");
console.log(" pinme show-appkey");
console.log(" pinme appkey");
}
function showWalletBalanceHelp(): void {
console.log("COMMAND:");
console.log(" wallet - Show current wallet balance\n");
console.log("USAGE:");
console.log(" pinme wallet");
console.log(" pinme wallet-balance");
console.log(" pinme balance\n");
console.log("DESCRIPTION:");
console.log(" This command fetches the current wallet balance for the logged-in account.");
console.log(" The value is displayed in USD.\n");
console.log("EXAMPLES:");
console.log(" pinme wallet");
console.log(" pinme wallet-balance");
console.log(" pinme balance");
}
// logout command help
function showLogoutHelp(): void {
console.log("COMMAND:");
console.log(" logout - Log out and clear authentication\n");
console.log("USAGE:");
console.log(" pinme logout\n");
console.log("DESCRIPTION:");
console.log(" This command logs out the current user and clears the authentication");
console.log(" information from local storage. You will need to set AppKey again");
console.log(" to use authenticated features.\n");
console.log("EXAMPLES:");
console.log(" pinme logout");
}
// show the help for the command
function showHelp(command?: string): void {
if (!command) {
showGeneralHelp();
return;
}
switch (command) {
case 'bind':
showBindHelp();
break;
case 'upload':
showUploadHelp();
break;
case 'login':
showLoginHelp();
break;
case 'list':
showListHelp();
break;
case 'ls':
showLsHelp();
break;
case 'show-appkey':
case 'appkey':
showShowAppKeyHelp();
break;
case 'wallet':
case 'wallet-balance':
case 'balance':
showWalletBalanceHelp();
break;
case 'logout':
showLogoutHelp();
break;
default:
console.log(`Unknown command: ${command}`);
showGeneralHelp();
}
}
export {
showHelp,
showBanner
};
+249
View File
@@ -0,0 +1,249 @@
import fs from 'fs-extra';
import path from 'path';
import os from 'os';
import dayjs from 'dayjs';
import chalk from 'chalk';
import { formatSize } from './uploadLimits';
import { getRootDomain } from './pinmeApi';
import tracker, { getTrackErrorReason } from './tracker';
import {
TRACK_EVENTS,
TRACK_PAGES,
resolveTrackAction,
} from './trackerEvents';
// history file path
const HISTORY_DIR = path.join(os.homedir(), '.pinme');
const HISTORY_FILE = path.join(HISTORY_DIR, 'upload-history.json');
interface UploadRecord {
timestamp: number;
date: string;
path: string;
filename: string;
contentHash: string;
previewHash: string | null;
size: number;
fileCount: number;
type: 'directory' | 'file';
shortUrl?: string | null;
pinmeUrl?: string | null;
dnsUrl?: string | null;
}
interface UploadHistory {
uploads: UploadRecord[];
}
interface UploadData {
path: string;
filename?: string;
contentHash: string;
previewHash?: string | null;
size: number;
fileCount?: number;
isDirectory?: boolean;
shortUrl?: string | null;
pinmeUrl?: string | null;
dnsUrl?: string | null;
}
// ensure the history directory exists
const ensureHistoryDir = (): void => {
if (!fs.existsSync(HISTORY_DIR)) {
fs.mkdirSync(HISTORY_DIR, { recursive: true });
}
if (!fs.existsSync(HISTORY_FILE)) {
fs.writeJsonSync(HISTORY_FILE, { uploads: [] });
}
};
// save the upload history
const saveUploadHistory = (uploadData: UploadData): boolean => {
try {
ensureHistoryDir();
const history = fs.readJsonSync(HISTORY_FILE) as UploadHistory;
// add new upload record
const newRecord: UploadRecord = {
timestamp: Date.now(),
date: dayjs().format('YYYY-MM-DD HH:mm:ss'),
path: uploadData.path,
filename: uploadData.filename || path.basename(uploadData.path),
contentHash: uploadData.contentHash,
previewHash: uploadData.previewHash,
size: uploadData.size,
fileCount: uploadData.fileCount || 1,
type: uploadData.isDirectory ? 'directory' : 'file',
shortUrl: uploadData?.shortUrl || null,
pinmeUrl: uploadData?.pinmeUrl || null,
dnsUrl: uploadData?.dnsUrl || null,
};
history.uploads.unshift(newRecord); // add to the beginning
// write to file
fs.writeJsonSync(HISTORY_FILE, history, { spaces: 2 });
return true;
} catch (error: any) {
console.error(chalk.red(`Error saving upload history: ${error.message}`));
return false;
}
};
// get the upload history
const getUploadHistory = (limit: number = 10): UploadRecord[] => {
try {
ensureHistoryDir();
const history = fs.readJsonSync(HISTORY_FILE) as UploadHistory;
return history.uploads.slice(0, limit);
} catch (error: any) {
console.error(chalk.red(`Error reading upload history: ${error.message}`));
return [];
}
};
async function formatHistoryUrl(
value?: string | null,
options?: { appendRootDomain?: boolean; rootDomain?: string | null },
): Promise<string | null> {
if (!value) {
return null;
}
const normalized = value.trim();
if (!normalized) {
return null;
}
if (/^https?:\/\//.test(normalized)) {
if (
options?.appendRootDomain &&
options.rootDomain &&
(() => {
try {
return !new URL(normalized).hostname.includes('.');
} catch {
return false;
}
})()
) {
const url = new URL(normalized);
url.hostname = `${url.hostname}.${options.rootDomain}`;
return url.toString().replace(/\/$/, '');
}
return normalized.replace(/\/$/, '');
}
if (normalized.includes('.')) {
return `https://${normalized}`;
}
if (options?.appendRootDomain && options.rootDomain) {
return `https://${normalized}.${options.rootDomain}`;
}
return `https://${normalized}`;
}
// display the upload history
const displayUploadHistory = async (limit: number = 10): Promise<void> => {
try {
const history = getUploadHistory(limit);
void tracker.trackEvent(TRACK_EVENTS.uploadHistoryViewed, TRACK_PAGES.upload, {
a: resolveTrackAction(TRACK_EVENTS.uploadHistoryViewed),
record_count: history.length,
limit,
});
if (history.length === 0) {
console.log(chalk.yellow('No upload history found.'));
return;
}
console.log(chalk.cyan('Upload History:'));
console.log(chalk.cyan('-'.repeat(80)));
let rootDomain: string | null = null;
try {
rootDomain = await getRootDomain();
} catch {
rootDomain = null;
}
// Display recent records, up to limit records
const recentHistory = history.slice(-limit);
for (const [index, item] of recentHistory.entries()) {
console.log(chalk.green(`${index + 1}. ${item.filename}`));
console.log(chalk.white(` Path: ${item.path}`));
console.log(chalk.white(` IPFS CID: ${item.contentHash}`));
const preferredUrl =
(await formatHistoryUrl(item.dnsUrl)) ||
(await formatHistoryUrl(item.pinmeUrl, {
appendRootDomain: true,
rootDomain,
})) ||
(await formatHistoryUrl(item.shortUrl, {
appendRootDomain: true,
rootDomain,
}));
if (preferredUrl) {
console.log(chalk.white(` URL: ${preferredUrl}`));
}
console.log(chalk.white(` Size: ${formatSize(item.size)}`));
console.log(chalk.white(` Files: ${item.fileCount}`));
console.log(chalk.white(` Type: ${item.type === 'directory' ? 'Directory' : 'File'}`));
if (item.timestamp) {
console.log(chalk.white(` Date: ${new Date(item.timestamp).toLocaleString()}`));
}
console.log(chalk.cyan('-'.repeat(80)));
}
// display the statistics
const totalSize = history.reduce((sum, record) => sum + record.size, 0);
const totalFiles = history.reduce((sum, record) => sum + record.fileCount, 0);
console.log(chalk.bold(`Total Uploads: ${history.length}`));
console.log(chalk.bold(`Total Files: ${totalFiles}`));
console.log(chalk.bold(`Total Size: ${formatSize(totalSize)}`));
} catch (error: any) {
void tracker.trackEvent(TRACK_EVENTS.uploadHistoryFailed, TRACK_PAGES.upload, {
a: resolveTrackAction(TRACK_EVENTS.uploadHistoryFailed),
action: 'view',
reason: getTrackErrorReason(error),
});
throw error;
}
};
// clear the upload history
const clearUploadHistory = (): boolean => {
try {
ensureHistoryDir();
fs.writeJsonSync(HISTORY_FILE, { uploads: [] });
void tracker.trackEvent(TRACK_EVENTS.uploadHistoryCleared, TRACK_PAGES.upload, {
a: resolveTrackAction(TRACK_EVENTS.uploadHistoryCleared),
cleared: true,
});
console.log(chalk.green('Upload history cleared successfully.'));
return true;
} catch (error: any) {
void tracker.trackEvent(TRACK_EVENTS.uploadHistoryFailed, TRACK_PAGES.upload, {
a: resolveTrackAction(TRACK_EVENTS.uploadHistoryFailed),
action: 'clear',
reason: getTrackErrorReason(error),
});
console.error(chalk.red(`Error clearing upload history: ${error.message}`));
return false;
}
};
export {
saveUploadHistory,
getUploadHistory,
displayUploadHistory,
clearUploadHistory,
formatHistoryUrl,
};
+503
View File
@@ -0,0 +1,503 @@
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import chalk from 'chalk';
import { spawn } from 'child_process';
type InstallScript = 'ci' | 'install';
interface InstallProjectDependenciesOptions {
mode?: 'auto' | InstallScript;
}
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
const SLOW_INSTALL_NOTICE_MS = 60 * 1000;
const CAPTURED_OUTPUT_LIMIT = 12000;
const STALE_LOG_ONLY_MARKER_MS = 90 * 1000;
const STOP_BACKGROUND_GRACE_MS = 3000;
const STOP_BACKGROUND_KILL_GRACE_MS = 2000;
const STOP_BACKGROUND_POLL_MS = 200;
// Marker files written by the background install so other commands (`pinme save`)
// can tell whether dependencies are still installing, finished, or failed.
export const INSTALL_LOG_FILE = '.pinme-install.log';
export const INSTALL_EXITCODE_FILE = '.pinme-install.exitcode';
export const INSTALL_PID_FILE = '.pinme-install.pid';
export type BackgroundInstallStatus =
| 'idle'
| 'running'
| 'success'
| 'failed'
| 'interrupted';
export interface BackgroundInstallState {
status: BackgroundInstallStatus;
exitCode: number | null;
logPath: string;
}
export class DependencyInstallError extends Error {
command: string;
constructor(command: string, cause: unknown) {
const reason = cause instanceof Error ? cause.message : String(cause);
super(`${command} failed: ${reason}`);
this.name = 'DependencyInstallError';
this.command = command;
this.cause = cause;
}
}
function makeTempCacheDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'pinme-npm-cache-'));
}
function getNpmCommand(): string {
// On Windows, npm is often installed as npm.cmd
if (process.platform === 'win32') {
return 'npm.cmd';
}
return 'npm';
}
function hasPackageLock(cwd: string): boolean {
return fs.existsSync(path.join(cwd, 'package-lock.json'));
}
function getInstallScript(cwd: string, mode: 'auto' | InstallScript): InstallScript {
if (mode !== 'auto') {
return mode;
}
return hasPackageLock(cwd) ? 'ci' : 'install';
}
function getInstallArgs(script: InstallScript, cacheDir?: string): string[] {
const args = [
script,
'--no-audit',
'--no-fund',
'--fetch-retries=3',
'--fetch-retry-factor=2',
'--fetch-retry-mintimeout=10000',
'--fetch-retry-maxtimeout=60000',
'--fetch-timeout=60000',
];
if (cacheDir) {
args.splice(1, 0, '--cache', cacheDir);
}
return args;
}
function formatInstallCommand(
script: InstallScript,
cacheMode: 'default' | 'isolated' = 'default',
): string {
const parts = [
`npm ${script}`,
'--no-audit',
'--no-fund',
'--fetch-retries=3',
'--fetch-timeout=60000',
];
if (cacheMode === 'isolated') {
parts.splice(1, 0, '--cache <isolated npm cache>');
}
return parts.join(' ');
}
function appendCapturedOutput(output: string, chunk: Buffer): string {
const next = output + chunk.toString();
if (next.length <= CAPTURED_OUTPUT_LIMIT) {
return next;
}
return next.slice(next.length - CAPTURED_OUTPUT_LIMIT);
}
function runInstall(
cwd: string,
script: InstallScript,
cacheDir?: string,
): Promise<void> {
const npm = getNpmCommand();
const args = getInstallArgs(script, cacheDir);
const env: NodeJS.ProcessEnv = {
...process.env,
npm_config_audit: 'false',
npm_config_fund: 'false',
npm_config_fetch_retries: '3',
npm_config_fetch_retry_factor: '2',
npm_config_fetch_retry_mintimeout: '10000',
npm_config_fetch_retry_maxtimeout: '60000',
npm_config_fetch_timeout: '60000',
};
if (cacheDir) {
env.npm_config_cache = cacheDir;
}
return new Promise((resolve, reject) => {
let capturedOutput = '';
let settled = false;
let timedOut = false;
const child = spawn(npm, args, {
cwd,
stdio: ['inherit', 'pipe', 'pipe'],
shell: process.platform === 'win32',
env,
});
const slowNoticeTimer = setTimeout(() => {
console.log(chalk.yellow(' Still installing dependencies. Slow npm registry or network can make this take a few minutes...'));
}, SLOW_INSTALL_NOTICE_MS);
const timeoutTimer = setTimeout(() => {
timedOut = true;
child.kill();
}, INSTALL_TIMEOUT_MS);
const cleanup = () => {
clearTimeout(slowNoticeTimer);
clearTimeout(timeoutTimer);
};
child.stdout?.on('data', (chunk: Buffer) => {
process.stdout.write(chunk);
capturedOutput = appendCapturedOutput(capturedOutput, chunk);
});
child.stderr?.on('data', (chunk: Buffer) => {
process.stderr.write(chunk);
capturedOutput = appendCapturedOutput(capturedOutput, chunk);
});
child.on('error', (error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
});
child.on('close', (code, signal) => {
if (settled) return;
settled = true;
cleanup();
if (timedOut) {
reject(new Error(`npm ${script} timed out after 10 minutes.\n${capturedOutput}`));
return;
}
if (code !== 0) {
reject(new Error(`npm ${script} failed with exit code ${code}${signal ? ` and signal ${signal}` : ''}.\n${capturedOutput}`));
return;
}
resolve();
});
});
}
type ShellPlatform = NodeJS.Platform | 'posix';
interface BackgroundInstallCommand {
shellBin: string;
shellArgs: string[];
installCommand: string;
}
function quoteForShell(value: string, platform: ShellPlatform = process.platform): string {
// tmp paths / project paths can contain spaces; wrap everything in quotes.
if (platform === 'win32') {
return `"${value}"`;
}
return `'${value.replace(/'/g, `'\\''`)}'`;
}
export function buildBackgroundInstallCommand(
script: InstallScript,
logPath: string,
exitCodePath: string,
platform: ShellPlatform = process.platform,
): BackgroundInstallCommand {
const args = getInstallArgs(script);
const npmCmd = platform === 'win32' ? 'npm' : getNpmCommand();
const installCommand = `${npmCmd} ${args.map((arg) => quoteForShell(arg, platform)).join(' ')}`;
const qLog = quoteForShell(logPath, platform);
const qExit = quoteForShell(exitCodePath, platform);
if (platform === 'win32') {
// Enable delayed expansion so !errorlevel! is evaluated after npm exits.
return {
shellBin: process.env.ComSpec || 'cmd.exe',
shellArgs: [
'/d',
'/s',
'/v:on',
'/c',
`${installCommand} >> ${qLog} 2>&1 & echo !errorlevel! > ${qExit}`,
],
installCommand,
};
}
return {
shellBin: '/bin/sh',
shellArgs: [
'-c',
`${installCommand} >> ${qLog} 2>&1; printf '%s' "$?" > ${qExit}`,
],
installCommand,
};
}
/**
* Start a dependency install in a detached background process and return
* immediately. The child keeps running after the current CLI process exits,
* so `pinme create` can finish (and `process.exit`) while dependencies keep
* installing for later use (`pinme save`, local dev).
*
* The install is wrapped in a shell command that:
* 1. Clears any previous exit-code marker.
* 2. Streams all output to `<cwd>/.pinme-install.log`.
* 3. Writes the install's exit code to `<cwd>/.pinme-install.exitcode` when done.
*
* Other commands read those markers (see {@link readBackgroundInstallStatus}) to
* know whether the install is still running, succeeded, or failed.
*/
export function startBackgroundInstall(cwd: string): { logPath: string } {
const script = getInstallScript(cwd, 'auto');
const logPath = path.join(cwd, INSTALL_LOG_FILE);
const exitCodePath = path.join(cwd, INSTALL_EXITCODE_FILE);
const pidPath = path.join(cwd, INSTALL_PID_FILE);
// Reset markers from any previous run.
fs.removeSync(exitCodePath);
fs.removeSync(pidPath);
fs.writeFileSync(logPath, `[pinme] ${new Date().toISOString()} starting "npm ${script}"\n`);
const { shellBin, shellArgs } = buildBackgroundInstallCommand(script, logPath, exitCodePath);
const child = spawn(shellBin, shellArgs, {
cwd,
detached: true,
stdio: 'ignore',
env: {
...process.env,
npm_config_audit: 'false',
npm_config_fund: 'false',
npm_config_fetch_retries: '3',
npm_config_fetch_retry_factor: '2',
npm_config_fetch_retry_mintimeout: '10000',
npm_config_fetch_retry_maxtimeout: '60000',
npm_config_fetch_timeout: '60000',
},
});
child.unref();
if (child.pid) {
fs.writeFileSync(pidPath, String(child.pid));
}
return { logPath };
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error: any) {
return error?.code === 'EPERM';
}
}
function readPidFile(pidPath: string): number | null {
if (!fs.existsSync(pidPath)) {
return null;
}
const raw = fs.readFileSync(pidPath, 'utf-8').trim();
const pid = Number.parseInt(raw, 10);
if (!Number.isFinite(pid) || pid <= 0) {
return null;
}
return pid;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function signalInstallProcess(pid: number, signal: NodeJS.Signals): boolean {
try {
if (process.platform === 'win32') {
process.kill(pid, signal);
} else {
process.kill(-pid, signal);
}
return true;
} catch {
try {
process.kill(pid, signal);
return true;
} catch {
return false;
}
}
}
async function waitForProcessExit(
pid: number,
timeoutMs: number,
): Promise<boolean> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (!isProcessAlive(pid)) {
return true;
}
await sleep(STOP_BACKGROUND_POLL_MS);
}
return !isProcessAlive(pid);
}
export async function stopBackgroundInstall(cwd: string): Promise<boolean> {
const pidPath = path.join(cwd, INSTALL_PID_FILE);
const pid = readPidFile(pidPath);
let stopped = false;
if (pid !== null && isProcessAlive(pid)) {
if (signalInstallProcess(pid, 'SIGTERM')) {
stopped = true;
const stoppedGracefully = await waitForProcessExit(
pid,
STOP_BACKGROUND_GRACE_MS,
);
if (!stoppedGracefully) {
signalInstallProcess(pid, 'SIGKILL');
await waitForProcessExit(pid, STOP_BACKGROUND_KILL_GRACE_MS);
}
}
}
fs.removeSync(pidPath);
return stopped;
}
function isStaleLogOnlyMarker(logPath: string): boolean {
try {
const ageMs = Date.now() - fs.statSync(logPath).mtimeMs;
return ageMs > STALE_LOG_ONLY_MARKER_MS;
} catch {
return false;
}
}
/**
* Read the state of a background install started by {@link startBackgroundInstall}.
* - `idle`: no install was ever started here (no log, no exit-code marker).
* - `running`: a log exists but the exit code has not been written yet.
* - `success` / `failed`: the install finished with the recorded exit code.
* - `interrupted`: an install started, but the tracked background process is
* gone or a legacy log-only marker is too old to trust.
*/
export function readBackgroundInstallStatus(cwd: string): BackgroundInstallState {
const logPath = path.join(cwd, INSTALL_LOG_FILE);
const exitCodePath = path.join(cwd, INSTALL_EXITCODE_FILE);
const pidPath = path.join(cwd, INSTALL_PID_FILE);
if (fs.existsSync(exitCodePath)) {
const raw = fs.readFileSync(exitCodePath, 'utf-8').trim();
const code = Number.parseInt(raw, 10);
if (Number.isNaN(code)) {
// The marker is being written but the value is not flushed yet.
return { status: 'running', exitCode: null, logPath };
}
return { status: code === 0 ? 'success' : 'failed', exitCode: code, logPath };
}
if (fs.existsSync(logPath)) {
const pid = readPidFile(pidPath);
if (pid !== null) {
return {
status: isProcessAlive(pid) ? 'running' : 'interrupted',
exitCode: null,
logPath,
};
}
if (isStaleLogOnlyMarker(logPath)) {
return { status: 'interrupted', exitCode: null, logPath };
}
return { status: 'running', exitCode: null, logPath };
}
return { status: 'idle', exitCode: null, logPath };
}
/** Return the tail of the background install log, for surfacing failure causes. */
export function readBackgroundInstallLogTail(cwd: string, maxChars = 1500): string {
const logPath = path.join(cwd, INSTALL_LOG_FILE);
if (!fs.existsSync(logPath)) {
return '';
}
const content = fs.readFileSync(logPath, 'utf-8');
return content.length > maxChars ? content.slice(content.length - maxChars) : content;
}
export async function installProjectDependencies(
cwd: string,
options: InstallProjectDependenciesOptions = {},
): Promise<void> {
const mode = options.mode || 'auto';
const script = getInstallScript(cwd, mode);
const command = formatInstallCommand(script);
let lastError: unknown;
if (script === 'ci' && !hasPackageLock(cwd)) {
throw new DependencyInstallError(
command,
new Error('package-lock.json is required for npm ci but was not found.'),
);
}
for (let attempt = 1; attempt <= 2; attempt += 1) {
const useIsolatedCache = attempt > 1;
const cacheDir = useIsolatedCache ? makeTempCacheDir() : undefined;
try {
if (attempt > 1) {
console.log(chalk.yellow(' Retrying dependency install with a fresh npm cache...'));
}
if (attempt === 1 && script === 'ci' && mode === 'auto') {
console.log(chalk.gray(' package-lock.json found; using npm ci for a reproducible install.'));
} else if (attempt === 1 && script === 'ci') {
console.log(chalk.gray(' Using npm ci for a clean, reproducible install.'));
}
await runInstall(cwd, script, cacheDir);
return;
} catch (error) {
lastError = error;
} finally {
if (cacheDir) {
fs.removeSync(cacheDir);
}
}
}
throw new DependencyInstallError(
formatInstallCommand(script, 'isolated'),
lastError,
);
}
+405
View File
@@ -0,0 +1,405 @@
import chalk from 'chalk';
import { createCarApiClient, createPinmeApiClient } from './apiClient';
import { APP_CONFIG } from './config';
import { isDnsDomain } from './domainValidator';
// Token expired error codes
const TOKEN_EXPIRED_CODES = [
401,
403,
10001,
10002,
20001,
'TOKEN_EXPIRED',
'AUTH_FAILED',
];
const TOKEN_EXPIRED_MESSAGES = [
'token expired',
'invalid token',
'authentication failed',
'auth failed',
'unauthorized',
'登录',
'过期',
'token',
'鉴权',
];
function isTokenExpired(error: any): boolean {
const status = error.response?.status;
const message =
error.response?.data?.msg ||
error.response?.data?.message ||
error.message ||
'';
const code = error.response?.data?.code;
if (status && TOKEN_EXPIRED_CODES.includes(status)) {
return true;
}
if (code && TOKEN_EXPIRED_CODES.includes(code)) {
return true;
}
const lowerMessage = message.toLowerCase();
return TOKEN_EXPIRED_MESSAGES.some((m) =>
lowerMessage.includes(m.toLowerCase()),
);
}
function showTokenExpiredHint(): void {
console.log(chalk.red('\n⚠️ Token has expired or is invalid.'));
console.log(chalk.yellow('Please re-run: pinme set-appkey <AppKey>\n'));
}
export async function bindAnonymousDevice(
anonymousUid: string,
): Promise<boolean> {
try {
const client = createPinmeApiClient();
const { data } = await client.post('/bind_anonymous', {
anonymous_uid: anonymousUid,
});
return data?.code === 200;
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
return false;
}
console.log(
chalk.yellow(`Failed to trigger anonymous binding: ${e?.message || e}`),
);
return false;
}
}
export interface CheckDomainResult {
is_valid: boolean;
error?: string;
}
export interface GetRootDomainResponse {
code: number;
msg: string;
data: {
domain: string;
};
}
let rootDomainCache: string | null = null;
export async function getRootDomain(
forceRefresh: boolean = false,
): Promise<string> {
if (!forceRefresh && rootDomainCache) {
return rootDomainCache;
}
const client = createPinmeApiClient();
const { data } = await client.get<GetRootDomainResponse>('/root_domain');
if (data?.code === 200 && data?.data?.domain) {
rootDomainCache = data.data.domain;
return rootDomainCache;
}
throw new Error(data?.msg || 'Failed to get root domain');
}
export async function checkDomainAvailable(
domainName: string,
): Promise<CheckDomainResult> {
if (isDnsDomain(domainName)) {
return await {
is_valid: true,
};
}
const client = createPinmeApiClient();
// Endpoint may not be fixed, prioritize environment variable, then try two common paths
const configured = APP_CONFIG.pinmeCheckDomainPath;
const fallbacks = [configured];
let lastRecoverableError: any;
for (const p of fallbacks) {
try {
const { data } = await client.post(p, { domain_name: domainName });
if (typeof data?.is_valid === 'boolean') {
return { is_valid: data.is_valid, error: data?.error };
}
if (data?.data && typeof data.data.is_valid === 'boolean') {
return { is_valid: data.data.is_valid, error: data.data?.error };
}
// Unexpected structure, continue trying next path
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
const status = e?.response?.status;
if (status && ![404, 405].includes(status)) {
throw e;
}
lastRecoverableError = e;
}
}
if (lastRecoverableError) {
throw lastRecoverableError;
}
// If all attempts fail silently, return unknown state and let the bind step decide.
return { is_valid: true };
}
export async function bindPinmeDomain(
domainName: string,
hash: string,
projectName?: string,
): Promise<boolean> {
try {
const client = createPinmeApiClient();
const { data } = await client.post('/bind_pinme_domain', {
domain_name: domainName,
hash,
...(projectName ? { project_name: projectName } : {}),
});
return data?.code === 200;
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
throw e;
}
}
export interface MyDomainItem {
domain_name: string;
domain_type: number;
bind_time: number;
expire_time: number;
}
export async function getMyDomains(): Promise<MyDomainItem[]> {
try {
const client = createPinmeApiClient();
const { data } = await client.get('/my_domains');
if (data?.code === 200) {
if (Array.isArray(data?.data)) {
// v4: data is array
return data.data as MyDomainItem[];
}
if (data?.data?.list && Array.isArray(data.data.list)) {
// fallback: sometimes wrapped in { list: [] }
return data.data.list as MyDomainItem[];
}
}
if (data?.code === 401 || data?.code === 403) {
showTokenExpiredHint();
throw new Error('Token expired');
}
return [];
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
throw e;
}
}
export interface BindDnsDomainV4Response {
code: number;
msg: string;
data?: {
domain_name: string;
hash: string;
bind_time?: number;
};
}
export async function bindDnsDomainV4(
domainName: string,
hash: string,
tokenAddress: string,
authToken: string,
projectName?: string,
): Promise<BindDnsDomainV4Response> {
try {
const client = createPinmeApiClient();
const { data } = await client.post<BindDnsDomainV4Response>(
'/bind_dns',
{
domain_name: domainName,
hash: hash,
...(projectName ? { project_name: projectName } : {}),
},
{
headers: {
'x-auth-token': authToken,
'x-token-address': tokenAddress,
},
},
);
return data as BindDnsDomainV4Response;
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
throw e;
}
}
export interface WalletBalanceResponse {
code: number;
msg: string;
data?: {
wallet_balance_usd?: number;
};
}
export async function getWalletBalance(
tokenAddress: string,
authToken: string,
): Promise<WalletBalanceResponse> {
try {
const client = createPinmeApiClient();
const { data } = await client.get<WalletBalanceResponse>(
'/pay/wallet/balance',
{
headers: {
'authentication-tokens': authToken,
'token-address': tokenAddress,
},
},
);
return data as WalletBalanceResponse;
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
throw e;
}
}
export interface IsVipResponse {
code: number;
msg: string;
data?: {
is_vip: boolean;
vip_expire_time?: number;
};
}
export async function isVip(
tokenAddress: string,
authToken: string,
): Promise<IsVipResponse> {
try {
const client = createPinmeApiClient();
const { data } = await client.get<IsVipResponse>('/is_vip', {
headers: {
'x-auth-token': authToken,
'x-token-address': tokenAddress,
},
});
return data as IsVipResponse;
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
throw e;
}
}
export interface CarExportResponse {
code: number;
msg: string;
data: {
cid: string;
status: string;
task_id: string;
};
}
export interface CarExportStatusResponse {
code: number;
msg: string;
data: {
task_id: string;
cid: string;
status: 'processing' | 'completed' | 'failed';
download_url?: string;
};
}
export async function requestCarExport(
cid: string,
uid: string,
): Promise<CarExportResponse['data']> {
try {
const client = createCarApiClient();
// Use POST method as shown in the example
const { data } = await client.post<CarExportResponse>('/car/export', null, {
params: {
cid,
uid,
},
});
if (data?.code === 200 && data?.data) {
return data.data;
}
throw new Error(data?.msg || 'Failed to request CAR export');
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
if (e.response?.data?.msg) {
throw new Error(e.response.data.msg);
}
throw new Error(`Failed to request CAR export: ${e?.message || e}`);
}
}
export interface BindDnsDomainV4Response {
code: number;
msg: string;
data?: {
domain_name: string;
hash: string;
bind_time?: number;
};
}
export async function checkCarExportStatus(
taskId: string,
): Promise<CarExportStatusResponse['data']> {
try {
const client = createCarApiClient();
const { data } = await client.get<CarExportStatusResponse>(
'/car/export/status',
{
params: {
task_id: taskId,
},
},
);
if (data?.code === 200 && data?.data) {
return data.data;
}
throw new Error(data?.msg || 'Failed to check export status');
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
if (e.response?.data?.msg) {
throw new Error(e.response.data.msg);
}
throw new Error(`Failed to check export status: ${e?.message || e}`);
}
}
+139
View File
@@ -0,0 +1,139 @@
import fs from 'fs-extra';
import path from 'path';
import { createConfigError } from './cliError';
const PLACEHOLDERS = {
apiUrl: '__PINME_VITE_API_URL__',
authApiKey: '__PINME_AUTH_API_KEY__',
authDomain: '__PINME_AUTH_DOMAIN__',
authProjectId: '__PINME_AUTH_PROJECT_ID__',
tenantId: '__PINME_TENANT_ID__',
} as const;
const PATCHABLE_EXTENSIONS = new Set([
'.html',
'.js',
'.css',
'.json',
'.map',
]);
interface PrebuiltDistWorkerData {
api_domain?: string;
public_client_config?: Record<string, any>;
}
export interface PatchPrebuiltFrontendDistResult {
filesScanned: number;
filesPatched: number;
apiUrlReplacements: number;
authReplacements: number;
}
function toReplacementValue(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
return String(value);
}
function countOccurrences(content: string, needle: string): number {
if (!needle) {
return 0;
}
return content.split(needle).length - 1;
}
function listPatchableFiles(dir: string): string[] {
const result: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
result.push(...listPatchableFiles(entryPath));
continue;
}
if (entry.isFile() && PATCHABLE_EXTENSIONS.has(path.extname(entry.name))) {
result.push(entryPath);
}
}
return result;
}
export function patchPrebuiltFrontendDist(
frontendDistDir: string,
workerData: PrebuiltDistWorkerData,
): PatchPrebuiltFrontendDistResult {
if (!workerData.api_domain) {
throw createConfigError('`api_domain` is missing from project creation response.', [
'Retry `pinme create`.',
'If the problem persists, check the `/create_worker` API response.',
]);
}
if (!fs.existsSync(frontendDistDir)) {
throw createConfigError('Prebuilt frontend output not found: `frontend/dist/`.', [
'The template should ship a prebuilt `frontend/dist/`.',
'Once dependencies finish installing, run `npm run build:frontend` in the project, then `pinme save`.',
]);
}
const authConfig = workerData.public_client_config ?? {};
const replacements = new Map<string, string>([
[PLACEHOLDERS.apiUrl, workerData.api_domain],
[PLACEHOLDERS.authApiKey, toReplacementValue(authConfig.auth_api_key)],
[PLACEHOLDERS.authDomain, toReplacementValue(authConfig.auth_domain)],
[PLACEHOLDERS.authProjectId, toReplacementValue(authConfig.auth_project_id)],
[PLACEHOLDERS.tenantId, toReplacementValue(authConfig.tenant_id)],
]);
const files = listPatchableFiles(frontendDistDir);
let filesPatched = 0;
let apiUrlReplacements = 0;
let authReplacements = 0;
for (const filePath of files) {
const content = fs.readFileSync(filePath, 'utf8');
let nextContent = content;
for (const [placeholder, replacement] of replacements) {
const count = countOccurrences(nextContent, placeholder);
if (count === 0) {
continue;
}
if (placeholder === PLACEHOLDERS.apiUrl) {
apiUrlReplacements += count;
} else {
authReplacements += count;
}
nextContent = nextContent.split(placeholder).join(replacement);
}
if (nextContent !== content) {
fs.writeFileSync(filePath, nextContent);
filesPatched += 1;
}
}
if (apiUrlReplacements === 0) {
throw createConfigError('Prebuilt frontend dist is missing required Pinme config placeholder.', [
'Expected to find `__PINME_VITE_API_URL__` in `frontend/dist`.',
'Rebuild the template frontend from placeholder-enabled source before publishing the template.',
'After dependencies finish installing, users can recover by running `npm run build:frontend` and `pinme save`.',
]);
}
return {
filesScanned: files.length,
filesPatched,
apiUrlReplacements,
authReplacements,
};
}
+105
View File
@@ -0,0 +1,105 @@
import axios from 'axios';
import chalk from 'chalk';
import { getUid } from './getDeviceId';
import { APP_CONFIG } from './config';
// Get API base URL from environment variables
const ipfsApiUrl = APP_CONFIG.ipfsApiUrl;
interface RemoveResponse {
code: number;
msg: string;
data: any;
}
/**
* Remove file from IPFS network
* @param value - IPFS content hash or subname
* @param type - Type of the value: 'hash' or 'subname'
* @returns Promise<boolean> - Whether deletion was successful
*/
export async function removeFromIpfs(
value: string,
type: 'hash' | 'subname' = 'hash',
): Promise<boolean> {
try {
const uid = getUid();
console.log(chalk.blue(`Removing content from IPFS: ${value}...`));
// Build query parameters based on type
const queryParams = new URLSearchParams({
uid: uid,
});
if (type === 'subname') {
queryParams.append('subname', value);
} else {
queryParams.append('arg', value);
}
const response = await axios.post<RemoveResponse>(
`${ipfsApiUrl}/block/rm?${queryParams.toString()}`,
{
timeout: 30000, // 30 seconds timeout
},
);
const { code, msg, data } = response.data;
if (code === 200) {
console.log(chalk.green('✓ Removal successful!'));
console.log(
chalk.cyan(
`Content ${type}: ${value} has been removed from IPFS network`,
),
);
return true;
} else {
console.log(chalk.red('✗ Removal failed'));
console.log(chalk.red(`Error: ${msg || 'Unknown error occurred'}`));
return false;
}
} catch (error: any) {
console.log(chalk.red('✗ Removal failed', error));
if (error.response) {
// Server responded with error status code
const { status, data } = error.response;
console.log(
chalk.red(`HTTP Error ${status}: ${data?.msg || 'Server error'}`),
);
if (status === 404) {
console.log(
chalk.yellow('Content not found on the network or already removed'),
);
} else if (status === 403) {
console.log(
chalk.yellow(
'Permission denied - you may not have access to remove this content',
),
);
} else if (status === 500) {
console.log(
chalk.yellow('Server internal error - please try again later'),
);
}
} else if (error.request) {
// Request was made but no response received
console.log(
chalk.red('Network error: Unable to connect to IPFS service'),
);
console.log(
chalk.yellow('Please check your internet connection and try again'),
);
} else {
// Other errors
console.log(chalk.red(`Error: ${error.message}`));
}
return false;
}
}
export default removeFromIpfs;
+375
View File
@@ -0,0 +1,375 @@
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;
+114
View File
@@ -0,0 +1,114 @@
export const TRACK_PAGES = {
auth: 'cli_auth',
login: 'cli_login',
upload: 'cli_upload',
import: 'cli_import',
export: 'cli_export',
remove: 'cli_remove',
domain: 'cli_domain',
wallet: 'cli_wallet',
project: 'cli_project',
deploy: 'cli_deploy',
} as const;
export const TRACK_EVENTS = {
cliLoginSuccess: 'cli_login_success',
cliLoginFailed: 'cli_login_failed',
appKeySetSuccess: 'appkey_set_success',
appKeySetFailed: 'appkey_set_failed',
logoutSuccess: 'logout_success',
logoutFailed: 'logout_failed',
uploadSuccess: 'upload_success',
uploadFailed: 'upload_failed',
importSuccess: 'import_success',
importFailed: 'import_failed',
exportSuccess: 'export_success',
exportFailed: 'export_failed',
removeSuccess: 'remove_success',
removeFailed: 'remove_failed',
domainBindSuccess: 'domain_bind_success',
domainBindFailed: 'domain_bind_failed',
myDomainsSuccess: 'my_domains_success',
myDomainsFailed: 'my_domains_failed',
walletBalanceSuccess: 'wallet_balance_success',
walletBalanceFailed: 'wallet_balance_failed',
projectCreateSuccess: 'project_create_success',
projectCreateFailed: 'project_create_failed',
projectSaveSuccess: 'project_save_success',
projectSaveFailed: 'project_save_failed',
projectUpdateDbSuccess: 'project_update_db_success',
projectUpdateDbFailed: 'project_update_db_failed',
projectUpdateWorkerSuccess: 'project_update_worker_success',
projectUpdateWorkerFailed: 'project_update_worker_failed',
projectDeleteSuccess: 'project_delete_success',
projectDeleteFailed: 'project_delete_failed',
projectUpdateWebSuccess: 'project_update_web_success',
projectUpdateWebFailed: 'project_update_web_failed',
appKeyShownSuccess: 'appkey_shown_success',
appKeyShownFailed: 'appkey_shown_failed',
uploadHistoryViewed: 'upload_history_viewed',
uploadHistoryCleared: 'upload_history_cleared',
uploadHistoryFailed: 'upload_history_failed',
} as const;
export const TRACK_ACTIONS = {
init: 'init',
click: 'click',
view: 'view',
exposure: 'exposure',
submit: 'submit',
success: 'success',
fail: 'fail',
} as const;
export function resolveTrackAction(event: string): string {
switch (event) {
case TRACK_EVENTS.uploadHistoryViewed:
case TRACK_EVENTS.myDomainsSuccess:
case TRACK_EVENTS.walletBalanceSuccess:
case TRACK_EVENTS.appKeyShownSuccess:
return TRACK_ACTIONS.view;
case TRACK_EVENTS.uploadHistoryCleared:
return TRACK_ACTIONS.click;
case TRACK_EVENTS.uploadSuccess:
case TRACK_EVENTS.importSuccess:
case TRACK_EVENTS.exportSuccess:
case TRACK_EVENTS.removeSuccess:
case TRACK_EVENTS.domainBindSuccess:
case TRACK_EVENTS.cliLoginSuccess:
case TRACK_EVENTS.appKeySetSuccess:
case TRACK_EVENTS.logoutSuccess:
case TRACK_EVENTS.projectCreateSuccess:
case TRACK_EVENTS.projectSaveSuccess:
case TRACK_EVENTS.projectUpdateDbSuccess:
case TRACK_EVENTS.projectUpdateWorkerSuccess:
case TRACK_EVENTS.projectUpdateWebSuccess:
case TRACK_EVENTS.projectDeleteSuccess:
return TRACK_ACTIONS.success;
case TRACK_EVENTS.uploadFailed:
case TRACK_EVENTS.importFailed:
case TRACK_EVENTS.exportFailed:
case TRACK_EVENTS.removeFailed:
case TRACK_EVENTS.domainBindFailed:
case TRACK_EVENTS.cliLoginFailed:
case TRACK_EVENTS.appKeySetFailed:
case TRACK_EVENTS.logoutFailed:
case TRACK_EVENTS.myDomainsFailed:
case TRACK_EVENTS.walletBalanceFailed:
case TRACK_EVENTS.projectCreateFailed:
case TRACK_EVENTS.projectSaveFailed:
case TRACK_EVENTS.projectUpdateDbFailed:
case TRACK_EVENTS.projectUpdateWorkerFailed:
case TRACK_EVENTS.projectUpdateWebFailed:
case TRACK_EVENTS.projectDeleteFailed:
case TRACK_EVENTS.appKeyShownFailed:
case TRACK_EVENTS.uploadHistoryFailed:
return TRACK_ACTIONS.fail;
default:
return TRACK_ACTIONS.view;
}
}
+69
View File
@@ -0,0 +1,69 @@
import fs from 'fs';
import path from 'path';
const FILE_SIZE_LIMIT = parseInt(process.env.FILE_SIZE_LIMIT || '100', 10) * 1024 * 1024; // MB to bytes
const DIRECTORY_SIZE_LIMIT = parseInt(process.env.DIRECTORY_SIZE_LIMIT || '500', 10) * 1024 * 1024; // MB to bytes
interface FileSizeCheck {
size: number;
exceeds: boolean;
limit: number;
}
interface DirectorySizeCheck {
size: number;
limit: number;
exceeds: boolean;
}
function checkFileSizeLimit(filePath: string): FileSizeCheck {
const stats = fs.statSync(filePath);
return {
size: stats.size,
limit: FILE_SIZE_LIMIT,
exceeds: stats.size > FILE_SIZE_LIMIT
};
}
function checkDirectorySizeLimit(directoryPath: string): DirectorySizeCheck {
const totalSize = calculateDirectorySize(directoryPath);
return {
size: totalSize,
limit: DIRECTORY_SIZE_LIMIT,
exceeds: totalSize > DIRECTORY_SIZE_LIMIT
};
}
function calculateDirectorySize(directoryPath: string): number {
let totalSize = 0;
const files = fs.readdirSync(directoryPath);
for (const file of files) {
const filePath = path.join(directoryPath, file);
const stats = fs.statSync(filePath);
if (stats.isFile()) {
totalSize += stats.size;
} else if (stats.isDirectory()) {
totalSize += calculateDirectorySize(filePath);
}
}
return totalSize;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' bytes';
else if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
else if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
else return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
export {
checkFileSizeLimit,
checkDirectorySizeLimit,
calculateDirectorySize,
formatSize,
FILE_SIZE_LIMIT,
DIRECTORY_SIZE_LIMIT
};
+20
View File
@@ -0,0 +1,20 @@
import uploadToIpfsSplit from './uploadToIpfsSplit';
import type { UploadAction } from './uploadToIpfsSplit';
/**
* @deprecated Legacy upload entry kept for compatibility.
* Use `uploadToIpfsSplit` directly for all new code.
*/
export default async function uploadToIpfs(
filePath: string,
options?: {
action?: UploadAction;
importAsCar?: boolean;
projectName?: string;
},
): Promise<{
contentHash: string;
shortUrl?: string;
} | null> {
return uploadToIpfsSplit(filePath, options);
}
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
import chalk from 'chalk';
type UrlTone = 'primary' | 'management';
export function printHighlightedUrl(
label: string,
url: string,
tone: UrlTone = 'primary',
): void {
const safeLabel = label.trim() || 'URL';
const safeUrl = url.trim();
const labelStyle = chalk.black.bgWhiteBright.bold;
const urlStyle =
tone === 'management'
? chalk.blueBright.bold.underline
: chalk.cyanBright.bold;
console.log('');
console.log(labelStyle(` ${safeLabel} `));
console.log(urlStyle(safeUrl));
console.log('');
}
+585
View File
@@ -0,0 +1,585 @@
import crypto from 'crypto';
import http from 'http';
import { URL } from 'url';
import chalk from 'chalk';
import { exec } from 'child_process';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { APP_CONFIG } from './config';
/* c8 ignore start -- Browser callback/login UI is covered by manual/e2e flows; token helpers below are unit-tested. */
/* Stryker disable all: Browser opening is OS integration; token helpers below are mutation-tested. */
// Cross-platform browser opener
function openBrowser(url: string): void {
const platform = process.platform;
let command: string;
if (platform === 'darwin') {
command = `open "${url}"`;
} else if (platform === 'win32') {
command = `start "" "${url}"`;
} else {
// linux
command = `xdg-open "${url}"`;
}
exec(command, (err) => {
if (err) {
console.log(chalk.yellow(`Unable to open browser automatically. Please visit manually: ${url}`));
}
});
}
/* Stryker restore all */
const CONFIG_DIR = path.join(os.homedir(), '.pinme');
const AUTH_FILE = path.join(CONFIG_DIR, 'auth.json');
export interface AuthConfig {
address: string;
token: string;
expires_at?: number;
user_id?: string;
email?: string;
}
export interface LoginOptions {
apiBaseUrl?: string;
webBaseUrl?: string;
callbackPort?: number;
callbackPath?: string;
}
const DEFAULT_OPTIONS: Required<LoginOptions> = {
apiBaseUrl: APP_CONFIG.pinmeApiBase,
webBaseUrl: APP_CONFIG.pinmeWebUrl,
callbackPort: 34567,
callbackPath: '/cli/callback',
};
/* Stryker disable all: Interactive browser login and callback HTML are covered by manual/e2e flows. */
export class WebLoginManager {
private config: Required<LoginOptions>;
private server: http.Server | null = null;
private resolvePromise: ((value: string) => void) | null = null;
private rejectPromise: ((reason: Error) => void) | null = null;
private loginToken: string = '';
constructor(options: LoginOptions = {}) {
this.config = { ...DEFAULT_OPTIONS, ...options };
}
async login(): Promise<AuthConfig> {
console.log(chalk.blue('Starting login flow...\n'));
// 1. Generate temporary login token
this.loginToken = this.generateLoginToken();
// 2. Start local server to wait for callback
console.log(chalk.blue('Starting local callback server...'));
await this.startCallbackServer();
try {
// 3. Build login URL and open browser
const loginUrl = this.buildLoginUrl();
console.log(chalk.blue('Opening browser...'));
console.log(chalk.white('If browser does not open automatically, please visit manually:'));
console.log(chalk.cyan(` ${loginUrl}\n`));
openBrowser(loginUrl);
console.log(chalk.yellow('Please complete login in browser...'));
console.log(chalk.gray('Browser will close automatically after successful login.\n'));
// 4. Wait for user to complete login
const authToken = await this.waitForCallback();
// 5. Parse token
const authConfig = this.parseAuthToken(authToken);
// 6. Save auth config
this.saveAuthConfig(authConfig);
console.log(chalk.green('\nLogin successful!'));
if (authConfig.email) {
console.log(chalk.green(`Welcome, ${authConfig.email}`));
}
console.log(chalk.gray(`Address: ${authConfig.address}`));
return authConfig;
} catch (error: any) {
console.error(chalk.red(`\nLogin failed: ${error.message}`));
throw error;
} finally {
this.closeServer();
}
}
private generateLoginToken(): string {
return crypto.randomBytes(32).toString('hex');
}
private async startCallbackServer(): Promise<void> {
return new Promise((resolve, reject) => {
this.server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url || '', `http://localhost:${this.config.callbackPort}`);
if (url.pathname === this.config.callbackPath) {
const authToken = url.searchParams.get('token');
const error = url.searchParams.get('error');
const loginToken = url.searchParams.get('login_token');
if (error) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(this.getErrorHtml(error));
if (this.rejectPromise) {
this.rejectPromise(new Error(error));
}
return;
}
if (!authToken) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(this.getErrorHtml('Auth token not received'));
if (this.rejectPromise) {
this.rejectPromise(new Error('Auth token not received'));
}
return;
}
// Verify loginToken
if (loginToken !== this.loginToken) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(this.getErrorHtml('Login token invalid or expired'));
if (this.rejectPromise) {
this.rejectPromise(new Error('Login token invalid or expired'));
}
return;
}
// Return success page
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(this.getSuccessHtml());
// Pass token
if (this.resolvePromise) {
this.resolvePromise(authToken);
}
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
} catch (err: any) {
console.error('Callback error:', err);
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
}
});
this.server.on('error', (err) => {
reject(err);
});
this.server.listen(this.config.callbackPort, '127.0.0.1', () => {
console.log(chalk.gray(`Local server: http://localhost:${this.config.callbackPort}`));
resolve();
});
// Set timeout (10 minutes)
setTimeout(() => {
if (this.rejectPromise) {
this.rejectPromise(new Error('Login timeout, please try again'));
}
this.closeServer();
}, 10 * 60 * 1000);
});
}
private buildLoginUrl(): string {
const callbackUrl = `http://localhost:${this.config.callbackPort}${this.config.callbackPath}`;
const url = `${this.config.webBaseUrl}/#/cli-login?` +
`login_token=${this.loginToken}&` +
`callback_url=${encodeURIComponent(callbackUrl)}`;
return url;
}
private waitForCallback(): Promise<string> {
return new Promise((resolve, reject) => {
this.resolvePromise = resolve;
this.rejectPromise = reject;
});
}
private parseAuthToken(authToken: string): AuthConfig {
// authToken format: "<address>-<jwt>"
const firstDash = authToken.indexOf('-');
if (firstDash <= 0 || firstDash === authToken.length - 1) {
throw new Error('Invalid token format');
}
const address = authToken.slice(0, firstDash).trim();
const token = authToken.slice(firstDash + 1).trim();
if (!address || !token) {
throw new Error('Token parsing failed: address or token is empty');
}
return { address, token };
}
private saveAuthConfig(config: AuthConfig): void {
fs.ensureDirSync(CONFIG_DIR);
fs.writeJsonSync(AUTH_FILE, config, { spaces: 2 });
// Set file permissions (only current user can read)
fs.chmodSync(AUTH_FILE, 0o600);
}
private closeServer(): void {
if (this.server) {
this.server.close();
this.server = null;
}
}
// HTML templates
private getSuccessHtml(): string {
return `
<!DOCTYPE html>
<html>
<head>
<title>Login Success - PinMe</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #000;
overflow: hidden;
}
.bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
radial-gradient(ellipse at 20% 80%, rgba(120, 0, 255, 0.3) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(0, 200, 255, 0.3) 0%, transparent 50%),
radial-gradient(ellipse at 50% 50%, rgba(255, 0, 150, 0.15) 0%, transparent 60%);
animation: bgPulse 6s ease-in-out infinite;
}
@keyframes bgPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.05); }
}
.grid {
position: fixed;
top: 0;
left: 0;
width: 200%;
height: 200%;
background-image:
linear-gradient(rgba(0, 200, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 200, 255, 0.03) 1px, transparent 1px);
background-size: 50px 50px;
transform: perspective(500px) rotateX(60deg) translateY(-50%) translateZ(-200px);
animation: gridMove 20s linear infinite;
}
@keyframes gridMove {
0% { transform: perspective(500px) rotateX(60deg) translateY(0) translateZ(-200px); }
100% { transform: perspective(500px) rotateX(60deg) translateY(50px) translateZ(-200px); }
}
.container {
position: relative;
z-index: 10;
background: linear-gradient(135deg, rgba(20, 20, 40, 0.9) 0%, rgba(10, 10, 30, 0.95) 100%);
padding: 3.5rem 4rem;
border-radius: 32px;
box-shadow:
0 0 60px rgba(0, 200, 255, 0.15),
0 25px 50px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 -1px 0 rgba(0, 200, 255, 0.1);
text-align: center;
border: 1px solid rgba(0, 200, 255, 0.2);
max-width: 440px;
backdrop-filter: blur(30px);
}
.container::before {
content: '';
position: absolute;
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
border-radius: 32px;
background: linear-gradient(135deg, rgba(0, 200, 255, 0.5), rgba(255, 0, 150, 0.5), rgba(120, 0, 255, 0.5));
z-index: -1;
opacity: 0.5;
animation: borderGlow 3s ease-in-out infinite;
}
@keyframes borderGlow {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.7; }
}
.success-icon {
font-size: 5rem;
margin-bottom: 1.5rem;
animation: bounceIn 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55);
filter: drop-shadow(0 0 20px rgba(0, 200, 255, 0.5));
}
@keyframes bounceIn {
0% { transform: scale(0); opacity: 0; }
50% { transform: scale(1.2); }
100% { transform: scale(1); opacity: 1; }
}
h1 {
color: #fff;
font-size: 2.2rem;
font-weight: 700;
margin: 0 0 0.75rem 0;
background: linear-gradient(90deg, #fff, #00d4ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
p {
color: rgba(255, 255, 255, 0.6);
font-size: 1.1rem;
margin: 0 0 2rem 0;
line-height: 1.6;
}
.highlight { color: #00d4ff; font-weight: 600; }
.sparkle {
position: absolute;
width: 4px;
height: 4px;
background: #00d4ff;
border-radius: 50%;
animation: sparkle 2s ease-in-out infinite;
}
.sparkle:nth-child(1) { top: 20%; left: 10%; animation-delay: 0s; }
.sparkle:nth-child(2) { top: 30%; right: 15%; animation-delay: 0.5s; }
.sparkle:nth-child(3) { bottom: 25%; left: 20%; animation-delay: 1s; }
.sparkle:nth-child(4) { bottom: 35%; right: 10%; animation-delay: 1.5s; }
@keyframes sparkle {
0%, 100% { opacity: 0; transform: scale(0); }
50% { opacity: 1; transform: scale(1); }
}
</style>
</head>
<body>
<div class="bg"></div>
<div class="grid"></div>
<div class="container">
<div class="sparkle"></div>
<div class="sparkle"></div>
<div class="sparkle"></div>
<div class="sparkle"></div>
<div class="success-icon">🎉</div>
<h1>Welcome to PinMe</h1>
<p>You are now logged in! <span class="highlight">🚀</span><br>Return to your terminal to continue.</p>
</div>
</body>
</html>`;
}
private getErrorHtml(error: string): string {
const encodedError = encodeURIComponent(error);
return `
<!DOCTYPE html>
<html>
<head>
<title>Login Failed - PinMe</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #000;
overflow: hidden;
}
.bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
radial-gradient(ellipse at 20% 80%, rgba(255, 50, 50, 0.2) 0%, transparent 50%),
radial-gradient(ellipse at 80% 20%, rgba(255, 100, 50, 0.2) 0%, transparent 50%),
radial-gradient(ellipse at 50% 50%, rgba(100, 0, 50, 0.15) 0%, transparent 60%);
animation: bgPulse 6s ease-in-out infinite;
}
@keyframes bgPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.05); }
}
.grid {
position: fixed;
top: 0;
left: 0;
width: 200%;
height: 200%;
background-image:
linear-gradient(rgba(255, 80, 80, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 80, 80, 0.03) 1px, transparent 1px);
background-size: 50px 50px;
transform: perspective(500px) rotateX(60deg) translateY(-50%) translateZ(-200px);
animation: gridMove 20s linear infinite;
}
@keyframes gridMove {
0% { transform: perspective(500px) rotateX(60deg) translateY(0) translateZ(-200px); }
100% { transform: perspective(500px) rotateX(60deg) translateY(50px) translateZ(-200px); }
}
.container {
position: relative;
z-index: 10;
background: linear-gradient(135deg, rgba(40, 20, 20, 0.9) 0%, rgba(30, 10, 10, 0.95) 100%);
padding: 3.5rem 4rem;
border-radius: 32px;
box-shadow:
0 0 60px rgba(255, 50, 50, 0.15),
0 25px 50px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 -1px 0 rgba(255, 50, 50, 0.1);
text-align: center;
border: 1px solid rgba(255, 50, 50, 0.2);
max-width: 440px;
backdrop-filter: blur(30px);
}
.container::before {
content: '';
position: absolute;
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
border-radius: 32px;
background: linear-gradient(135deg, rgba(255, 50, 50, 0.5), rgba(255, 150, 50, 0.5), rgba(150, 0, 50, 0.5));
z-index: -1;
opacity: 0.5;
animation: borderGlow 3s ease-in-out infinite;
}
@keyframes borderGlow {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.7; }
}
.error-icon {
font-size: 5rem;
margin-bottom: 1.5rem;
animation: shake 0.5s ease-in-out;
filter: drop-shadow(0 0 20px rgba(255, 50, 50, 0.5));
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-10px) rotate(-5deg); }
40% { transform: translateX(10px) rotate(5deg); }
60% { transform: translateX(-10px) rotate(-5deg); }
80% { transform: translateX(10px) rotate(5deg); }
}
h1 {
color: #fff;
font-size: 2.2rem;
font-weight: 700;
margin: 0 0 0.75rem 0;
background: linear-gradient(90deg, #fff, #ff5050);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.error {
color: #ff6b6b;
font-size: 1rem;
margin: 0 0 2rem 0;
padding: 1.25rem;
background: rgba(255, 50, 50, 0.1);
border-radius: 16px;
border: 1px solid rgba(255, 50, 50, 0.2);
font-weight: 500;
box-shadow: 0 0 20px rgba(255, 50, 50, 0.1);
}
</style>
</head>
<body>
<div class="bg"></div>
<div class="grid"></div>
<div class="container">
<div class="error-icon">😵</div>
<h1>Oops!</h1>
<div class="error">${error}</div>
</div>
</body>
</html>`;
}
}
// Export singleton
export const webLoginManager = new WebLoginManager();
/* Stryker restore all */
/* c8 ignore stop */
// Legacy interface
export function setAuthToken(combined: string): AuthConfig {
const firstDash = combined.indexOf('-');
if (firstDash <= 0 || firstDash === combined.length - 1) {
throw new Error('Invalid token format. Expected "<address>-<jwt>".');
}
const address = combined.slice(0, firstDash).trim();
const token = combined.slice(firstDash + 1).trim();
if (!address || !token) {
throw new Error('Invalid token content. Address or token is empty.');
}
const config: AuthConfig = { address, token };
fs.ensureDirSync(CONFIG_DIR);
fs.writeJsonSync(AUTH_FILE, config, { spaces: 2 });
return config;
}
export function getAuthConfig(): AuthConfig | null {
try {
if (!fs.existsSync(AUTH_FILE)) return null;
const data = fs.readJsonSync(AUTH_FILE) as AuthConfig;
if (!data?.address || !data?.token) return null;
return data;
} catch {
return null;
}
}
export function clearAuthToken(): void {
try {
if (fs.existsSync(AUTH_FILE)) {
fs.removeSync(AUTH_FILE);
}
} catch (error) {
console.error(`Failed to clear auth token: ${error}`);
}
}
export function getAuthHeaders(): Record<string, string> {
const conf = getAuthConfig();
if (!conf) {
throw new Error('Auth not set. Run: pinme login');
}
return {
'token-address': conf.address,
'authentication-tokens': conf.token,
};
}
export async function login(): Promise<AuthConfig> {
return webLoginManager.login();
}
export async function logout(): Promise<void> {
clearAuthToken();
console.log(chalk.green('Logged out successfully'));
}
+87
View File
@@ -0,0 +1,87 @@
import { createConfigError } from './cliError';
interface WorkerMetadataBinding {
name?: string;
text?: unknown;
}
interface WorkerMetadata {
project_name?: string;
bindings?: WorkerMetadataBinding[];
}
function parseWorkerMetadata(metadataContent: string): WorkerMetadata {
try {
return JSON.parse(metadataContent);
} catch (error) {
throw createConfigError('Worker metadata must be valid JSON.', [
'The `/create_worker` API should return JSON metadata for backend deployment.',
'Retry `pinme create`.',
]);
}
}
function isRealBindingValue(value: unknown): value is string {
if (typeof value !== 'string') {
return false;
}
const trimmed = value.trim();
return Boolean(trimmed)
&& trimmed !== 'xxx'
&& trimmed !== 'project_name'
&& !trimmed.startsWith('__PINME_');
}
function findBinding(metadata: WorkerMetadata, name: string): WorkerMetadataBinding | undefined {
return metadata.bindings?.find((binding) => binding.name === name);
}
export function validateWorkerMetadataForCreate(
metadataContent: string,
projectName: string,
): void {
const metadata = parseWorkerMetadata(metadataContent);
const apiKeyBinding = findBinding(metadata, 'API_KEY');
const projectNameBinding = findBinding(metadata, 'PROJECT_NAME');
if (!isRealBindingValue(apiKeyBinding?.text)) {
throw createConfigError('Worker metadata is missing a real API_KEY binding.', [
'The template metadata contains placeholder values and cannot be deployed as-is.',
'Retry `pinme create` so Pinme can fetch fresh worker metadata from `/create_worker`.',
]);
}
if (projectNameBinding?.text !== projectName) {
throw createConfigError('Worker metadata is missing a matching PROJECT_NAME binding.', [
`Expected PROJECT_NAME binding text to be \`${projectName}\`.`,
'Retry `pinme create` so Pinme can fetch fresh worker metadata from `/create_worker`.',
]);
}
if (metadata.project_name && metadata.project_name !== projectName) {
throw createConfigError('Worker metadata project_name does not match the created project.', [
`Expected metadata project_name to be \`${projectName}\`.`,
`Received metadata project_name \`${metadata.project_name}\`.`,
]);
}
}
export function getValidatedWorkerMetadataContent(
metadata: unknown,
projectName: string,
): string {
if (!metadata) {
throw createConfigError('Worker metadata is missing from project creation response.', [
'The `/create_worker` API should return backend metadata before the prebuilt worker is deployed.',
'Retry `pinme create`.',
]);
}
const metadataContent = typeof metadata === 'string'
? metadata
: JSON.stringify(metadata, null, 2);
validateWorkerMetadataForCreate(metadataContent, projectName);
return metadataContent;
}