feat: custom domain[version 1.2.5 ]

This commit is contained in:
junchi.zhang
2026-02-16 18:22:06 +08:00
parent d0e6797a10
commit 3f601b391e
7 changed files with 806 additions and 123 deletions
+121 -6
View File
@@ -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 <name>
pinme upload /path/to/file-or-directory -d <name>
### Bind Domain (requires VIP)
```bash
# Upload and bind to a domain (auto-detected: Pinme subdomain or DNS domain)
pinme bind <path> --domain <name>
pinme bind <path> -d <name>
```
**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 <path> [options]
```
**Options:**
- `path`: Path to the file or directory to upload (optional, interactive if not provided)
- `-d, --domain <name>`: 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 <AppKey>`)
- For DNS domains, you must own the domain
**URL Formats:**
- Pinme subdomain: `https://<name>.pinit.eth.limo`
- DNS domain: `https://<your-domain>`
**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 <name>]
**Options:**
- `path`: Path to the file or directory to upload (optional, interactive if not provided)
- `-d, --domain <name>`: Pinme subdomain to bind after upload (optional)
- `-d, --domain <name>`: 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 <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
+147 -18
View File
@@ -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 <path> --domain <name>
// Usage: pinme bind <path> --domain <name> [--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<boolean> {
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<void> {
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 <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<void> {
}
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<void> {
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<void> {
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}`));
}
}
+10 -1
View File
@@ -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 <name>', 'Pinme subdomain')
.option('-d, --domain <name>', '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 <name>', 'Domain name to bind')
.option('--dns', 'Force DNS domain mode')
.action(() => bindCmd());
program
.command('domain')
.description("Alias for 'my-domains' command")
+217 -43
View File
@@ -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<boolean> {
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<boolean> {
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<void> => {
try {
console.log(
@@ -81,9 +180,17 @@ export default async (options?: UploadOptions): Promise<void> => {
}),
);
// Check auth
const authConfig = getAuthConfig();
if (!authConfig) {
console.log(chalk.red('Please login first. Run: pinme set-appkey <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<void> => {
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<void> => {
);
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<void> => {
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<void> => {
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'));
+96 -24
View File
@@ -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 <path> [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 <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(" - VIP membership required for all 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]\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 <name> 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 <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 <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");
@@ -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 <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");
@@ -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
};
};
+214 -30
View File
@@ -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 <AppKey>\n'));
}
function createClient(): AxiosInstance {
const headers = getAuthHeaders();
@@ -19,7 +68,9 @@ function createClient(): AxiosInstance {
});
}
export async function bindAnonymousDevice(anonymousUid: string): Promise<boolean> {
export async function bindAnonymousDevice(
anonymousUid: string,
): Promise<boolean> {
try {
const client = createClient();
const { data } = await client.post('/bind_anonymous', {
@@ -27,7 +78,13 @@ export async function bindAnonymousDevice(anonymousUid: string): Promise<boolean
});
return data?.code === 200;
} catch (e: any) {
console.log(chalk.yellow(`Failed to trigger anonymous binding: ${e?.message || e}`));
if (isTokenExpired(e)) {
showTokenExpiredHint();
return false;
}
console.log(
chalk.yellow(`Failed to trigger anonymous binding: ${e?.message || e}`),
);
return false;
}
}
@@ -37,7 +94,9 @@ export interface CheckDomainResult {
error?: string;
}
export async function checkDomainAvailable(domainName: string): Promise<CheckDomainResult> {
export async function checkDomainAvailable(
domainName: string,
): Promise<CheckDomainResult> {
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<CheckDom
}
// Unexpected structure, continue trying next path
} catch (e: any) {
if (isTokenExpired(e)) {
showTokenExpiredHint();
throw new Error('Token expired');
}
// 404/405/500 etc., continue trying next path
}
}
@@ -61,13 +124,24 @@ export async function checkDomainAvailable(domainName: string): Promise<CheckDom
return { is_valid: true };
}
export async function bindPinmeDomain(domainName: string, hash: string): Promise<boolean> {
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<boolean> {
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<MyDomainItem[]> {
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<BindDnsDomainV4Response> {
try {
const client = createClient();
const { data } = await client.post<BindDnsDomainV4Response>(
'/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<IsVipResponse> {
try {
const client = createClient();
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;
}
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<CarExportResponse['data']> {
export async function requestCarExport(
cid: string,
uid: string,
): Promise<CarExportResponse['data']> {
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<CarExp
}
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);
}
@@ -159,24 +327,40 @@ export async function requestCarExport(cid: string, uid: string): Promise<CarExp
}
}
export async function checkCarExportStatus(taskId: string): Promise<CarExportStatusResponse['data']> {
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 = createCarClient();
const { data } = await client.get<CarExportStatusResponse>('/car/export/status', {
params: {
task_id: taskId,
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}`);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pinme",
"version": "1.2.3",
"version": "1.2.5",
"publishConfig": {
"access": "public"
},