From 3f601b391ed4f4dbb34e41f082416fca2c397dbb Mon Sep 17 00:00:00 2001 From: "junchi.zhang" <435900020@qq.com> Date: Mon, 16 Feb 2026 18:22:06 +0800 Subject: [PATCH] feat: custom domain[version 1.2.5 ] --- README.md | 127 ++++++++++++++++++++- bin/bind.ts | 165 ++++++++++++++++++++++++--- bin/index.ts | 11 +- bin/upload.ts | 260 +++++++++++++++++++++++++++++++++++------- bin/utils/help.ts | 120 +++++++++++++++---- bin/utils/pinmeApi.ts | 244 ++++++++++++++++++++++++++++++++++----- package.json | 2 +- 7 files changed, 806 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index c170256..b8e49cd 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ Website: [https://pinme.eth.limo/](https://pinme.eth.limo/) - [For AI](#for-ai) - [Installation](#installation) - [Usage](#usage) +- [Bind Domain](#bind-domain-requires-vip) +- [Command Details](#command-details) +- [VIP Membership](#vip-membership) - [Common Static File Directories](#common-static-file-directories) - [Error Handling](#error-handling) - [Upload Limits](#upload-limits) @@ -216,10 +219,30 @@ pinme upload # Specify path directly pinme upload /path/to/file-or-directory +``` -# Upload and bind to a domain -pinme upload /path/to/file-or-directory --domain -pinme upload /path/to/file-or-directory -d +### Bind Domain (requires VIP) + +```bash +# Upload and bind to a domain (auto-detected: Pinme subdomain or DNS domain) +pinme bind --domain +pinme bind -d +``` + +**Smart Auto-Detection:** +- Domains with a dot (e.g., `example.com`) → **DNS domain** +- Domains without a dot (e.g., `my-site`) → **Pinme subdomain** + +**Examples:** +```bash +# Bind to a Pinme subdomain (auto-detected) +pinme bind ./dist --domain my-site + +# Bind to a DNS domain (auto-detected by the dot) +pinme bind ./dist --domain example.com + +# Force DNS mode if needed +pinme bind ./dist --domain my-site --dns ``` ### Import CAR files @@ -337,6 +360,51 @@ The selected directory must meet: ## Command Details +### `bind` + +Upload files and bind them to a custom domain. **Domain binding requires VIP membership.** + +```bash +pinme bind [options] +``` + +**Options:** +- `path`: Path to the file or directory to upload (optional, interactive if not provided) +- `-d, --domain `: Domain name to bind (required) +- `--dns`: Force DNS domain mode (optional, auto-detected from domain format) + +**Examples:** +```bash +# Interactive mode (will prompt for path and domain) +pinme bind + +# Bind to a Pinme subdomain (auto-detected: no dot in domain) +pinme bind ./dist --domain my-site + +# Bind to a DNS domain (auto-detected: contains dot) +pinme bind ./dist --domain example.com + +# Force DNS mode with --dns flag +pinme bind ./dist --domain my-site --dns +``` + +**Auto-Detection:** +- Domains with a dot (e.g., `example.com`) are automatically treated as **DNS domains** +- Domains without a dot (e.g., `my-site`) are automatically treated as **Pinme subdomains** +- Use `--dns` or `-D` flag to force DNS domain mode when needed + +**Requirements:** +- VIP membership required for domain binding +- Valid AppKey must be set (run: `pinme set-appkey `) +- For DNS domains, you must own the domain + +**URL Formats:** +- Pinme subdomain: `https://.pinit.eth.limo` +- DNS domain: `https://` + +**DNS Setup:** +After successful DNS domain binding, visit the [DNS Configuration Guide](https://pinme.eth.limo/#/docs?id=custom-domain) to complete DNS setup. + ### `upload` Upload a file or directory to the IPFS network. @@ -347,20 +415,22 @@ pinme upload [path] [--domain ] **Options:** - `path`: Path to the file or directory to upload (optional, interactive if not provided) -- `-d, --domain `: Pinme subdomain to bind after upload (optional) +- `-d, --domain `: Pinme subdomain to bind after upload (optional, requires VIP) **Examples:** ```bash # Upload dist directory pinme upload dist -# Upload and bind to a domain (requires Plus membership) -pinme upload dist --domain my-site +# Upload only (no domain binding) +pinme upload dist # Upload a specific file pinme upload ./example.jpg ``` +**Note:** Domain binding during upload requires VIP. Use the `bind` command for domain binding. + ### `import` Import CAR (Content Addressable aRchive) files to the IPFS network. This command is specifically designed for importing CAR files while maintaining their original structure. Supports binding to a Pinme subdomain after import. @@ -475,6 +545,51 @@ List all domains owned by the current account. --- +## VIP Membership + +### Overview + +VIP membership provides access to premium features including domain binding and custom DNS support. + +### VIP Features + +| Feature | Free | VIP | +|---------|------|-----| +| Upload files to IPFS | ✅ | ✅ | +| Preview URL | ✅ | ✅ | +| Pinme subdomain binding | ❌ | ✅ | +| Custom DNS domain binding | ❌ | ✅ | +| Priority support | ❌ | ✅ | + +### Domain Binding Requirements + +Domain binding (both Pinme subdomains and custom DNS domains) requires VIP membership. + +**Before using domain binding:** + +1. **Upgrade to VIP** + - Visit [PinMe website](https://pinme.eth.limo/) to upgrade + +2. **Set AppKey** + ```bash + pinme set-appkey + ``` + +3. **Bind your domain** + ```bash + # Bind to a Pinme subdomain + pinme bind ./dist --domain my-site + + # Bind to a custom DNS domain + pinme bind ./dist --domain example.com --dns + ``` + +### Checking VIP Status + +If you attempt to bind a domain without VIP, you'll see an error message. You can check your VIP status on the [PinMe website](https://pinme.eth.limo/). + +--- + ## Error Handling ### Common Errors and Solutions diff --git a/bin/bind.ts b/bin/bind.ts index a9f72e4..5a3a2a1 100644 --- a/bin/bind.ts +++ b/bin/bind.ts @@ -2,15 +2,59 @@ import path from 'path'; import chalk from 'chalk'; import inquirer from 'inquirer'; import upload from './utils/uploadToIpfsSplit'; -import { checkDomainAvailable, bindPinmeDomain } from './utils/pinmeApi'; +import { checkDomainAvailable, bindPinmeDomain, bindDnsDomainV4, isVip } from './utils/pinmeApi'; +import { getAuthConfig } from './utils/auth'; interface Args { domain?: string; targetPath?: string; + dns?: boolean; +} + +// Check if a domain is a DNS domain (contains a dot) +function isDnsDomain(domain: string): boolean { + return domain.includes('.'); +} + +// Validate DNS domain format +function validateDnsDomain(domain: string): { valid: boolean; message?: string } { + // Remove protocol if present + const cleanDomain = domain.replace(/^https?:\/\//, '').replace(/\/$/, ''); + + // Check for valid domain format + 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('.'); + + // Check each part + if (parts.length < 2) { + return { valid: false, message: 'Invalid domain format. Please enter a complete domain (e.g., example.com)' }; + } + + // Check each label (part between dots) + 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 }; } function parseArgs(): Args { - // Usage: pinme bind --domain + // Usage: pinme bind --domain [--dns] const args = process.argv.slice(2); const res: Args = {}; const idx = args.indexOf('bind'); @@ -22,12 +66,42 @@ function parseArgs(): Args { if (dIdx >= 0 && args[dIdx + 1]) { res.domain = args[dIdx + 1]; } + const dnsIdx = args.findIndex((a) => a === '--dns' || a === '-D'); + if (dnsIdx >= 0) { + res.dns = true; + } return res; } +async function checkVipStatus(authConfig: { address: string; token: string }): Promise { + console.log(chalk.blue('Checking VIP status...')); + try { + const vipResult = await isVip(authConfig.address, authConfig.token); + if (!vipResult.data?.is_vip) { + return false; + } + console.log(chalk.green('VIP verified.')); + return true; + } catch (e: any) { + if (e.message === 'Token expired') { + throw e; + } + console.log(chalk.yellow('Failed to check VIP status, continuing...')); + return true; + } +} + export default async function bindCmd(): Promise { try { - let { domain, targetPath } = parseArgs(); + let { domain, targetPath, dns } = parseArgs(); + + // Check auth + const authConfig = getAuthConfig(); + if (!authConfig) { + console.log(chalk.red('Please login first. Run: pinme set-appkey ')); + return; + } + if (!targetPath) { const ans = await inquirer.prompt([ { type: 'input', name: 'path', message: 'Enter the path to upload and bind: ' }, @@ -36,7 +110,7 @@ export default async function bindCmd(): Promise { } if (!domain) { const ans = await inquirer.prompt([ - { type: 'input', name: 'domain', message: 'Enter the Pinme subdomain (e.g., test_abc): ' }, + { type: 'input', name: 'domain', message: 'Enter the domain to bind (e.g., my-site or example.com): ' }, ]); domain = ans.domain?.trim(); } @@ -45,13 +119,48 @@ export default async function bindCmd(): Promise { return; } - // Pre-check domain availability - const check = await checkDomainAvailable(domain); - if (!check.is_valid) { - console.log(chalk.red(`Domain not available: ${check.error || 'unknown reason'}`)); - return; + // Auto-detect domain type if not explicitly specified + const isDns = dns || isDnsDomain(domain); + console.log(isDns,'isDns') + const displayDomain = domain.replace(/^https?:\/\//, '').replace(/\/$/, ''); + + // Validate DNS domain format + if (isDns) { + const validation = validateDnsDomain(domain); + if (!validation.valid) { + console.log(chalk.red(validation.message!)); + return; + } + } + + // All domain binding requires VIP + try { + const isVipUser = await checkVipStatus(authConfig); + if (!isVipUser) { + console.log(chalk.red('Domain binding requires VIP. Please upgrade to VIP first.')); + return; + } + } catch (e: any) { + if (e.message === 'Token expired') { + return; // Token expired hint already shown in API + } + throw e; + } + + // Pre-check domain availability + try { + const check = await checkDomainAvailable(displayDomain); + if (!check.is_valid) { + console.log(chalk.red(`Domain not available: ${check.error || 'unknown reason'}`)); + return; + } + console.log(chalk.green(`Domain available: ${displayDomain}`)); + } catch (e: any) { + if (e.message === 'Token expired') { + return; // Token expired hint already shown in API + } + throw e; } - console.log(chalk.green(`Domain available: ${domain}`)); // Upload const absolutePath = path.resolve(targetPath); @@ -64,16 +173,36 @@ export default async function bindCmd(): Promise { console.log(chalk.green(`Upload success, CID: ${up.contentHash}`)); // Bind domain - const ok = await bindPinmeDomain(domain, up.contentHash); - if (!ok) { - console.log(chalk.red('Binding failed. Please try again later.')); - return; + try { + if (isDns) { + // DNS domain binding + console.log(chalk.blue('Binding DNS domain...')); + const dnsResult = await bindDnsDomainV4(displayDomain, up.contentHash, authConfig.address, authConfig.token); + if (dnsResult.code !== 200) { + console.log(chalk.red(`DNS binding failed: ${dnsResult.msg}`)); + return; + } + console.log(chalk.green(`DNS bind success: ${displayDomain}`)); + console.log(chalk.white(`Visit: https://${displayDomain}`)); + console.log(chalk.cyan('\n📚 DNS Setup Guide: https://pinme.eth.limo/#/docs?id=custom-domain')); + } else { + // Pinme subdomain binding + console.log(chalk.blue('Binding Pinme subdomain...')); + const ok = await bindPinmeDomain(displayDomain, up.contentHash); + if (!ok) { + console.log(chalk.red('Binding failed. Please try again later.')); + return; + } + console.log(chalk.green(`Bind success: ${displayDomain}`)); + console.log(chalk.white(`Visit: https://${displayDomain}.pinit.eth.limo`)); + } + } catch (e: any) { + if (e.message === 'Token expired') { + return; // Token expired hint already shown in API + } + throw e; } - console.log(chalk.green(`Bind success: ${domain}`)); - console.log(chalk.white(`Visit (Pinme subdomain example): https://${domain}.pinit.eth.limo`)); } catch (e: any) { console.log(chalk.red(`Execution failed: ${e?.message || e}`)); } } - - diff --git a/bin/index.ts b/bin/index.ts index 0efa084..3e2e50d 100644 --- a/bin/index.ts +++ b/bin/index.ts @@ -19,6 +19,7 @@ import setAppKeyCmd from './set-appkey'; import logoutCmd from './logout'; import showAppKeyCmd from './show-appkey'; import myDomainsCmd from './my-domains'; +import bindCmd from './bind'; // display the ASCII art logo function showBanner(): void { @@ -40,7 +41,8 @@ program .description( 'upload a file or directory to IPFS. Supports --domain to bind after upload', ) - .option('-d, --domain ', 'Pinme subdomain') + .option('-d, --domain ', 'Domain name to bind') + .option('--dns', 'Force DNS domain mode') .action(() => upload()); program @@ -84,6 +86,13 @@ program .description('List domains owned by current account') .action(() => myDomainsCmd()); +program + .command('bind') + .description('Upload and bind to a domain (requires VIP)') + .option('-d, --domain ', 'Domain name to bind') + .option('--dns', 'Force DNS domain mode') + .action(() => bindCmd()); + program .command('domain') .description("Alias for 'my-domains' command") diff --git a/bin/upload.ts b/bin/upload.ts index 9bb7e0d..ca8e1f2 100644 --- a/bin/upload.ts +++ b/bin/upload.ts @@ -5,8 +5,9 @@ import figlet from 'figlet'; import upload from './utils/uploadToIpfsSplit'; import fs from 'fs'; import CryptoJS from 'crypto-js'; -import { checkDomainAvailable, bindPinmeDomain } from './utils/pinmeApi'; +import { checkDomainAvailable, bindPinmeDomain, bindDnsDomainV4, isVip } from './utils/pinmeApi'; import { getUid } from './utils/getDeviceId'; +import { getAuthConfig } from './utils/auth'; // get from environment variables const URL = process.env.IPFS_PREVIEW_URL; @@ -15,6 +16,48 @@ const secretKey = process.env.SECRET_KEY; import { checkNodeVersion } from './utils/checkNodeVersion'; checkNodeVersion(); +// Check if a domain is a DNS domain (contains a dot) +function isDnsDomain(domain: string): boolean { + return domain.includes('.'); +} + +// Validate DNS domain format +function validateDnsDomain(domain: string): { valid: boolean; message?: string } { + // Remove protocol if present + const cleanDomain = domain.replace(/^https?:\/\//, '').replace(/\/$/, ''); + + // Check for valid domain format + 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('.'); + + // Check each part + if (parts.length < 2) { + return { valid: false, message: 'Invalid domain format. Please enter a complete domain (e.g., example.com)' }; + } + + // Check each label (part between dots) + 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 }; +} + // encrypt the hash with optional uid (device id) function encryptHash( contentHash: string, @@ -69,6 +112,62 @@ function getDomainFromArgs(): string | null { return null; } +function getDnsFromArgs(): boolean { + const args = process.argv.slice(2); + return args.includes('--dns') || args.includes('-D'); +} + +async function checkVipStatus(authConfig: { address: string; token: string }): Promise { + console.log(chalk.blue('Checking VIP status...')); + try { + const vipResult = await isVip(authConfig.address, authConfig.token); + if (!vipResult.data?.is_vip) { + return false; + } + console.log(chalk.green('VIP verified.')); + return true; + } catch (e: any) { + if (e.message === 'Token expired') { + throw e; + } + console.log(chalk.yellow('Failed to check VIP status, continuing...')); + return true; + } +} + +async function bindDomain( + domain: string, + contentHash: string, + isDns: boolean, + authConfig: { address: string; token: string }, +): Promise { + const displayDomain = domain.replace(/^https?:\/\//, '').replace(/\/$/, ''); + + if (isDns) { + // DNS domain binding + console.log(chalk.blue('Binding DNS domain...')); + const dnsResult = await bindDnsDomainV4(displayDomain, contentHash, authConfig.address, authConfig.token); + if (dnsResult.code !== 200) { + console.log(chalk.red(`DNS binding failed: ${dnsResult.msg}`)); + return false; + } + console.log(chalk.green(`DNS bind success: ${displayDomain}`)); + console.log(chalk.white(`Visit: https://${displayDomain}`)); + console.log(chalk.cyan('\n📚 DNS Setup Guide: https://pinme.eth.limo/#/docs?id=custom-domain')); + } else { + // Pinme subdomain binding + console.log(chalk.blue('Binding Pinme subdomain...')); + const ok = await bindPinmeDomain(displayDomain, contentHash); + if (!ok) { + console.log(chalk.red('Binding failed. Please try again later.')); + return false; + } + console.log(chalk.green(`Bind success: ${displayDomain}`)); + console.log(chalk.white(`Visit: https://${displayDomain}.pinit.eth.limo`)); + } + return true; +} + export default async (options?: UploadOptions): Promise => { try { console.log( @@ -81,9 +180,17 @@ export default async (options?: UploadOptions): Promise => { }), ); + // Check auth + const authConfig = getAuthConfig(); + if (!authConfig) { + console.log(chalk.red('Please login first. Run: pinme set-appkey ')); + return; + } + // if the parameter is passed, upload directly, pinme upload /path/to/dir const argPath = process.argv[3]; const domainArg = getDomainFromArgs(); + const dnsArg = getDnsFromArgs(); if (argPath && !argPath.startsWith('-')) { // use the synchronous path check function @@ -93,18 +200,54 @@ export default async (options?: UploadOptions): Promise => { return; } - // optional: pre-check domain availability before upload - if (domainArg) { - const check = await checkDomainAvailable(domainArg); - if (!check.is_valid) { - console.log( - chalk.red( - `Domain not available: ${check.error || 'unknown reason'}`, - ), - ); + // Auto-detect domain type + const isDns = dnsArg || (domainArg ? isDnsDomain(domainArg) : false); + const displayDomain = domainArg?.replace(/^https?:\/\//, '').replace(/\/$/, ''); + + // Validate DNS domain format + if (isDns && domainArg) { + const validation = validateDnsDomain(domainArg); + if (!validation.valid) { + console.log(chalk.red(validation.message!)); return; } - console.log(chalk.green(`Domain available: ${domainArg}`)); + } + + // Domain binding requires VIP + if (domainArg) { + try { + const isVipUser = await checkVipStatus(authConfig); + if (!isVipUser) { + console.log(chalk.red('Domain binding requires VIP. Please upgrade to VIP first.')); + return; + } + } catch (e: any) { + if (e.message === 'Token expired') { + return; + } + throw e; + } + } + + // optional: pre-check domain availability before upload + if (domainArg) { + try { + const check = await checkDomainAvailable(displayDomain!); + if (!check.is_valid) { + console.log( + chalk.red( + `Domain not available: ${check.error || 'unknown reason'}`, + ), + ); + return; + } + console.log(chalk.green(`Domain available: ${displayDomain}`)); + } catch (e: any) { + if (e.message === 'Token expired') { + return; + } + throw e; + } } console.log(chalk.blue(`uploading ${absolutePath} to ipfs...`)); @@ -120,23 +263,21 @@ export default async (options?: UploadOptions): Promise => { ); console.log(chalk.cyan(`URL:`)); console.log(chalk.cyan(`${URL}${encryptedCID}`)); + // optional: bind domain after upload if (domainArg) { console.log( chalk.blue( - `Binding domain: ${domainArg} with CID: ${result.contentHash}`, + `Binding domain: ${displayDomain} with CID: ${result.contentHash}`, ), ); - const ok = await bindPinmeDomain(domainArg, result.contentHash); - if (ok) { - console.log(chalk.green(`Bind success: ${domainArg}`)); - console.log( - chalk.white( - `Visit (Pinme subdomain example): https://${domainArg}.pinit.eth.limo`, - ), - ); - } else { - console.log(chalk.red('Binding failed. Please try again later.')); + try { + await bindDomain(domainArg, result.contentHash, isDns, authConfig); + } catch (e: any) { + if (e.message === 'Token expired') { + return; + } + throw e; } } console.log(chalk.green('\n🎉 upload successful, program exit')); @@ -163,18 +304,54 @@ export default async (options?: UploadOptions): Promise => { return; } - // optional: interactive flow may also parse --domain, reuse the same arg parsing - if (domainArg) { - const check = await checkDomainAvailable(domainArg); - if (!check.is_valid) { - console.log( - chalk.red( - `Domain not available: ${check.error || 'unknown reason'}`, - ), - ); + // Auto-detect domain type + const isDns = dnsArg || (domainArg ? isDnsDomain(domainArg) : false); + const displayDomain = domainArg?.replace(/^https?:\/\//, '').replace(/\/$/, ''); + + // Validate DNS domain format + if (isDns && domainArg) { + const validation = validateDnsDomain(domainArg); + if (!validation.valid) { + console.log(chalk.red(validation.message!)); return; } - console.log(chalk.green(`Domain available: ${domainArg}`)); + } + + // Domain binding requires VIP + if (domainArg) { + try { + const isVipUser = await checkVipStatus(authConfig); + if (!isVipUser) { + console.log(chalk.red('Domain binding requires VIP. Please upgrade to VIP first.')); + return; + } + } catch (e: any) { + if (e.message === 'Token expired') { + return; + } + throw e; + } + } + + // optional: interactive flow may also parse --domain, reuse the same arg parsing + if (domainArg) { + try { + const check = await checkDomainAvailable(displayDomain!); + if (!check.is_valid) { + console.log( + chalk.red( + `Domain not available: ${check.error || 'unknown reason'}`, + ), + ); + return; + } + console.log(chalk.green(`Domain available: ${displayDomain}`)); + } catch (e: any) { + if (e.message === 'Token expired') { + return; + } + throw e; + } } console.log(chalk.blue(`uploading ${absolutePath} to ipfs...`)); @@ -194,19 +371,16 @@ export default async (options?: UploadOptions): Promise => { if (domainArg) { console.log( chalk.blue( - `Binding domain: ${domainArg} with CID: ${result.contentHash}`, + `Binding domain: ${displayDomain} with CID: ${result.contentHash}`, ), ); - const ok = await bindPinmeDomain(domainArg, result.contentHash); - if (ok) { - console.log(chalk.green(`Bind success: ${domainArg}`)); - console.log( - chalk.white( - `Visit (Pinme subdomain example): https://${domainArg}.pinit.eth.limo`, - ), - ); - } else { - console.log(chalk.red('Binding failed. Please try again later.')); + try { + await bindDomain(domainArg, result.contentHash, isDns, authConfig); + } catch (e: any) { + if (e.message === 'Token expired') { + return; + } + throw e; } } console.log(chalk.green('\n🎉 upload successful, program exit')); diff --git a/bin/utils/help.ts b/bin/utils/help.ts index 0b1b1ea..df089fd 100644 --- a/bin/utils/help.ts +++ b/bin/utils/help.ts @@ -14,12 +14,13 @@ function showBanner(): void { // 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 VIP)"); console.log(" list Show upload history"); console.log(" ls Alias for 'list' command"); console.log(" set-appkey Set AppKey for authentication"); @@ -27,31 +28,99 @@ function showGeneralHelp(): void { console.log(" appkey Alias for 'show-appkey' 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 VIP)\n"); + + console.log("USAGE:"); + console.log(" pinme bind [options]\n"); + + console.log("DESCRIPTION:"); + console.log(" This command uploads files and binds them to a custom domain."); + console.log(" Domain binding requires VIP membership.\n"); + + console.log("OPTIONS:"); + console.log(" -d, --domain 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(" - VIP membership required for all domain binding"); + console.log(" - Valid AppKey must be set (run: pinme set-appkey )"); + console.log(" - For DNS domains, you must own the domain\n"); + + console.log("URL FORMATS:"); + console.log(" - Pinme subdomain: https://.pinit.eth.limo"); + console.log(" - DNS domain: https://.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]\n"); - + 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.\n"); - + 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 VIP).\n"); + + console.log("OPTIONS:"); + console.log(" -d, --domain Domain name to bind (optional, requires VIP)"); + 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(" - VIP membership required for domain binding"); + console.log(" - Valid AppKey must be set (run: pinme set-appkey )"); + 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"); @@ -61,14 +130,14 @@ function showUploadHelp(): void { 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 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"); @@ -79,14 +148,14 @@ function showListHelp(): void { 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 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"); @@ -97,15 +166,15 @@ function showLsHelp(): void { 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"); @@ -115,15 +184,15 @@ function showShowAppKeyHelp(): void { 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"); } @@ -134,8 +203,11 @@ function showHelp(command?: string): void { showGeneralHelp(); return; } - + switch (command) { + case 'bind': + showBindHelp(); + break; case 'upload': showUploadHelp(); break; @@ -161,4 +233,4 @@ function showHelp(command?: string): void { export { showHelp, showBanner -}; \ No newline at end of file +}; diff --git a/bin/utils/pinmeApi.ts b/bin/utils/pinmeApi.ts index d99f43a..b52664c 100644 --- a/bin/utils/pinmeApi.ts +++ b/bin/utils/pinmeApi.ts @@ -2,7 +2,56 @@ import axios, { AxiosInstance } from 'axios'; import chalk from 'chalk'; import { getAuthHeaders } from './auth'; -const DEFAULT_BASE = process.env.PINME_API_BASE || 'http://ipfs-proxy.opena.chat/api/v4'; +const DEFAULT_BASE = + process.env.PINME_API_BASE || 'http://ipfs-proxy.opena.chat/api/v4'; + +// 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 \n')); +} function createClient(): AxiosInstance { const headers = getAuthHeaders(); @@ -19,7 +68,9 @@ function createClient(): AxiosInstance { }); } -export async function bindAnonymousDevice(anonymousUid: string): Promise { +export async function bindAnonymousDevice( + anonymousUid: string, +): Promise { try { const client = createClient(); const { data } = await client.post('/bind_anonymous', { @@ -27,7 +78,13 @@ export async function bindAnonymousDevice(anonymousUid: string): Promise { +export async function checkDomainAvailable( + domainName: string, +): Promise { const client = createClient(); // Endpoint may not be fixed, prioritize environment variable, then try two common paths const configured = process.env.PINME_CHECK_DOMAIN_PATH || '/check_domain'; @@ -54,6 +113,10 @@ export async function checkDomainAvailable(domainName: string): Promise { - const client = createClient(); - const { data } = await client.post('/bind_pinme_domain', { - domain_name: domainName, - hash, - }); - return data?.code === 200; +export async function bindPinmeDomain( + domainName: string, + hash: string, +): Promise { + try { + const client = createClient(); + const { data } = await client.post('/bind_pinme_domain', { + domain_name: domainName, + hash, + }); + return data?.code === 200; + } catch (e: any) { + if (isTokenExpired(e)) { + showTokenExpiredHint(); + throw new Error('Token expired'); + } + throw e; + } } export interface MyDomainItem { @@ -78,23 +152,110 @@ export interface MyDomainItem { } export async function getMyDomains(): Promise { - const client = createClient(); - 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[]; + try { + const client = createClient(); + 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?.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, +): Promise { + try { + const client = createClient(); + const { data } = await client.post( + '/bind_dns', + { + domain_name: domainName, + hash: hash, + }, + { + 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 IsVipResponse { + code: number; + msg: string; + data?: { + is_vip: boolean; + vip_expire_time?: number; + }; +} + +export async function isVip( + tokenAddress: string, + authToken: string, +): Promise { + try { + const client = createClient(); + const { data } = await client.get('/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; } - return []; } // CAR Export API -const CAR_API_BASE = process.env.CAR_API_BASE || process.env.PINME_API_BASE || 'http://ipfs-proxy.opena.chat/api/v3'; +const CAR_API_BASE = + process.env.CAR_API_BASE || + process.env.PINME_API_BASE || + 'http://ipfs-proxy.opena.chat/api/v3'; function createCarClient(): AxiosInstance { let headers = {}; @@ -137,7 +298,10 @@ export interface CarExportStatusResponse { }; } -export async function requestCarExport(cid: string, uid: string): Promise { +export async function requestCarExport( + cid: string, + uid: string, +): Promise { try { const client = createCarClient(); // Use POST method as shown in the example @@ -152,6 +316,10 @@ export async function requestCarExport(cid: string, uid: string): Promise { +export interface BindDnsDomainV4Response { + code: number; + msg: string; + data?: { + domain_name: string; + hash: string; + bind_time?: number; + }; +} +export async function checkCarExportStatus( + taskId: string, +): Promise { try { const client = createCarClient(); - const { data } = await client.get('/car/export/status', { - params: { - task_id: taskId, + const { data } = await client.get( + '/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}`); } } - - diff --git a/package.json b/package.json index e5e2e91..55228bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pinme", - "version": "1.2.3", + "version": "1.2.5", "publishConfig": { "access": "public" },