chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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('');
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user